<template> <div class="bbcode-editor" style="display:flex;flex-wrap:wrap;justify-content:flex-end"> <slot></slot> <a tabindex="0" class="btn btn-light bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false" style="border-bottom-left-radius:0;border-bottom-right-radius:0" v-if="hasToolbar"> <i class="fa fa-code"></i> </a> <div class="bbcode-toolbar btn-toolbar" role="toolbar" :disabled="disabled" :style="showToolbar ? {display: 'flex'} : undefined" @mousedown.stop.prevent v-if="hasToolbar" style="flex:1 51%; position: relative"> <div class="popover popover-top color-selector" v-show="colorPopupVisible" v-on-clickaway="dismissColorSelector"> <div class="popover-body"> <div class="btn-group" role="group" aria-label="Color"> <button v-for="btnCol in buttonColors" type="button" class="btn text-color" :class="btnCol" :title="btnCol" @click.prevent.stop="colorApply(btnCol)" tabindex="0"></button> </div> </div> </div> <EIconSelector :onSelect="onSelectEIcon" ref="eIconSelector"></EIconSelector> <div class="btn-group toolbar-buttons" style="flex-wrap:wrap"> <div v-if="!!characterName" class="character-btn"> <icon :character="characterName"></icon> </div> <div class="btn btn-light btn-sm" v-for="button in buttons" :class="button.outerClass" :title="button.title" @click.prevent.stop="apply(button)"> <i :class="(button.class ? button.class : 'fa ') + button.icon"></i> </div> <div @click="previewBBCode" class="btn btn-light btn-sm" :class="preview ? 'active' : ''" :title="preview ? 'Close Preview' : 'Preview'"> <i class="fa fa-eye"></i> </div> </div> <button type="button" class="close" aria-label="Close" style="margin-left:10px" @click="showToolbar = false">×</button> </div> <div class="bbcode-editor-text-area" style="order:100;width:100%;"> <textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength" :placeholder="placeholder" :class="finalClasses" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" @keypress="$emit('keypress', $event)" :style="hasToolbar ? {'border-top-left-radius': 0} : undefined" @keydown="onKeyDown"></textarea> <textarea ref="sizer"></textarea> <div class="bbcode-preview" v-show="preview"> <div class="bbcode-preview-warnings"> <div class="alert alert-danger" v-show="previewWarnings.length"> <li v-for="warning in previewWarnings">{{warning}}</li> </div> </div> <div class="bbcode" ref="preview-element"></div> </div> </div> </div> </template> <script lang="ts"> import {Component, Hook, Prop, Watch} from '@f-list/vue-ts'; import _ from 'lodash'; import Vue from 'vue'; import { mixin as clickaway } from 'vue-clickaway'; import {getKey} from '../chat/common'; import {Keys} from '../keys'; import {BBCodeElement, CoreBBCodeParser, urlRegex} from './core'; import {defaultButtons, EditorButton, EditorSelection} from './editor'; import {BBCodeParser} from './parser'; import {default as IconView} from './IconView.vue'; import {default as EIconSelector} from './EIconSelector.vue'; import Modal from '../components/Modal.vue'; @Component({ components: { 'icon': IconView, 'EIconSelector': EIconSelector }, mixins: [ clickaway ] }) export default class Editor extends Vue { @Prop readonly extras?: EditorButton[]; @Prop({default: 1000}) readonly maxlength!: number; @Prop readonly classes?: string; @Prop readonly value?: string | undefined = undefined; @Prop readonly disabled?: boolean; @Prop readonly placeholder?: string; @Prop({default: true}) readonly hasToolbar!: boolean; @Prop({default: false, type: Boolean}) readonly invalid!: boolean; @Prop({default: null}) readonly characterName: string | null = null; @Prop({default: 'normal'}) readonly type: 'normal' | 'big' = 'normal'; buttonColors = ['red', 'orange', 'yellow', 'green', 'cyan', 'purple', 'blue', 'pink', 'black', 'brown', 'white', 'gray']; colorPopupVisible = false; preview = false; previewWarnings: ReadonlyArray<string> = []; previewResult = ''; // tslint:disable-next-line: no-unnecessary-type-assertion text: string = (this.value !== undefined ? this.value : '') as string; element!: HTMLTextAreaElement; sizer!: HTMLTextAreaElement; maxHeight!: number; minHeight!: number; showToolbar = false; protected parser!: BBCodeParser; protected defaultButtons = defaultButtons; private isShiftPressed = false; private undoStack: string[] = []; private undoIndex = 0; private lastInput = 0; //tslint:disable:strict-boolean-expressions private resizeListener!: () => void; @Hook('created') created(): void { // console.log('EDITOR', 'created'); this.parser = new CoreBBCodeParser(); this.resizeListener = () => { const styles = getComputedStyle(this.element); this.maxHeight = parseInt(styles.maxHeight, 10) || 250; this.minHeight = parseInt(styles.minHeight, 10) || 60; }; } @Hook('mounted') mounted(): void { // console.log('EDITOR', 'mounted'); this.element = <HTMLTextAreaElement>this.$refs['input']; const styles = getComputedStyle(this.element); this.maxHeight = parseInt(styles.maxHeight, 10) || 250; this.minHeight = parseInt(styles.minHeight, 10) || 60; setInterval(() => { if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) { if(this.undoStack.length >= 30) this.undoStack.pop(); this.undoStack.unshift(this.text); } }, 500); this.sizer = <HTMLTextAreaElement>this.$refs['sizer']; this.sizer.style.cssText = styles.cssText; this.sizer.style.height = '0'; this.sizer.style.minHeight = '0'; this.sizer.style.overflow = 'hidden'; this.sizer.style.position = 'absolute'; this.sizer.style.top = '0'; this.sizer.style.visibility = 'hidden'; this.resize(); window.addEventListener('resize', this.resizeListener); } //tslint:enable @Hook('destroyed') destroyed(): void { // console.log('EDITOR', 'destroyed'); window.removeEventListener('resize', this.resizeListener); } get finalClasses(): string | undefined { let classes = this.classes; if(this.invalid) classes += ' is-invalid'; return classes; } get buttons(): EditorButton[] { const buttons = this.defaultButtons.slice(); if(this.extras !== undefined) for(let i = 0, l = this.extras.length; i < l; i++) buttons.push(this.extras[i]); const colorButtonIndex = _.findIndex(buttons, (b) => b.tag === 'color'); if (this.colorPopupVisible) { const colorButton = _.clone(buttons[colorButtonIndex]); colorButton.outerClass = 'toggled'; buttons[colorButtonIndex] = colorButton; } return buttons; } getButtonByTag(tag: string): EditorButton { const btn = _.find(this.buttons, (b) => b.tag === tag); if (!btn) { throw new Error('Unknown button'); } return btn; } @Watch('value') watchValue(newValue: string): void { this.$nextTick(() => this.resize()); if(this.text === newValue) return; this.text = newValue; this.lastInput = 0; this.undoIndex = 0; this.undoStack = []; } getSelection(): EditorSelection { const length = this.element.selectionEnd - this.element.selectionStart; return { start: this.element.selectionStart, end: this.element.selectionEnd, length, text: this.element.value.substr(this.element.selectionStart, length) }; } replaceSelection(replacement: string): string { const selection = this.getSelection(); const start = this.element.value.substr(0, selection.start) + replacement; const end = this.element.value.substr(selection.end); this.element.value = start + end; this.element.dispatchEvent(new Event('input')); return start + end; } setSelection(start: number, end?: number): void { if(end === undefined) end = start; this.element.focus(); this.element.setSelectionRange(start, end); } applyText(startText: string, endText: string, withInject?: string): void { const selection = this.getSelection(); if(selection.length > 0) { const replacement = startText + (withInject || selection.text) + endText; this.text = this.replaceSelection(replacement); this.setSelection(selection.start, selection.start + replacement.length); } else { const start = this.text.substr(0, selection.start) + startText; const end = endText + this.text.substr(selection.start); this.text = start + (withInject || '') + end; const selectionPoint = withInject ? start.length + withInject.length + endText.length : start.length; this.$nextTick(() => this.setSelection(selectionPoint)); } this.$emit('input', this.text); } dismissColorSelector(): void { this.colorPopupVisible = false; } colorApply(btnColor: string): void { const button = this.getButtonByTag('color'); this.applyButtonEffect(button, btnColor); this.colorPopupVisible = false; } dismissEIconSelector(): void { (this.$refs['eIconSelector'] as Modal).hide(); } showEIconSelector(): void { (this.$refs['eIconSelector'] as Modal).show(); setTimeout(() => (this.$refs['eIconSelector'] as any).setFocus(), 50); } onSelectEIcon(eiconId: string): void { this.eiconApply(eiconId); } eiconApply(eiconId: string): void { const button = this.getButtonByTag('eicon'); this.applyButtonEffect(button, undefined, eiconId); this.dismissEIconSelector(); } apply(button: EditorButton): void { if (button.tag === 'color') { this.colorPopupVisible = !this.colorPopupVisible; return; } else if (button.tag === 'eicon') { this.showEIconSelector(); this.colorPopupVisible = false; return; } else { this.colorPopupVisible = false; } this.applyButtonEffect(button); } applyButtonEffect(button: EditorButton, withArgument?: string, withInject?: string): void { // Allow emitted variations for custom buttons. this.$once('insert', (startText: string, endText: string) => this.applyText(startText, endText)); // noinspection TypeScriptValidateTypes if(button.handler !== undefined) { // tslint:ignore-next-line:no-any return button.handler.call(this as any, this); } if(button.startText === undefined || withArgument) button.startText = `[${button.tag}${withArgument ? '=' + withArgument : ''}]`; if(button.endText === undefined) button.endText = `[/${button.tag}]`; const ebl = button.endText ? button.endText.length : 0; const sbl = button.startText ? button.startText.length : 0; if(this.text.length + sbl + ebl > this.maxlength) return; this.applyText(button.startText || '', button.endText || '', withInject); this.lastInput = Date.now(); } onInput(): void { if(this.undoIndex > 0) { this.undoStack = this.undoStack.slice(this.undoIndex); this.undoIndex = 0; } this.$emit('input', this.text); this.lastInput = Date.now(); } onKeyDown(e: KeyboardEvent): void { const key = getKey(e); if((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { if(key === Keys.KeyZ) { e.preventDefault(); if(this.undoIndex === 0 && this.undoStack[0] !== this.text) this.undoStack.unshift(this.text); if(this.undoStack.length > this.undoIndex + 1) { this.text = this.undoStack[++this.undoIndex]; this.$emit('input', this.text); this.lastInput = Date.now(); } } else if(key === Keys.KeyY) { e.preventDefault(); if(this.undoIndex > 0) { this.text = this.undoStack[--this.undoIndex]; this.$emit('input', this.text); this.lastInput = Date.now(); } } for(const button of this.buttons) if(button.key === key) { e.stopPropagation(); e.preventDefault(); this.applyButtonEffect(button); break; } } else if(e.shiftKey) this.isShiftPressed = true; this.$emit('keydown', e); } onKeyUp(e: KeyboardEvent): void { if(!e.shiftKey) this.isShiftPressed = false; this.$emit('keyup', e); } resize(): void { this.sizer.style.fontSize = this.element.style.fontSize; this.sizer.style.lineHeight = this.element.style.lineHeight; this.sizer.style.width = `${this.element.offsetWidth}px`; this.sizer.value = this.element.value; this.element.style.height = `${Math.max(Math.min(this.sizer.scrollHeight, this.maxHeight), this.minHeight)}px`; this.sizer.style.width = '0'; } onPaste(e: ClipboardEvent): void { const data = e.clipboardData!.getData('text/plain'); if(!this.isShiftPressed && urlRegex.test(data)) { e.preventDefault(); this.applyText(`[url=${data}]`, '[/url]'); } } focus(): void { this.element.focus(); } previewBBCode(): void { this.doPreview(); } protected doPreview(): void { const targetElement = <HTMLElement>this.$refs['preview-element']; if(this.preview) { this.preview = false; this.previewWarnings = []; this.previewResult = ''; const previewElement = (<BBCodeElement>targetElement.firstChild); // noinspection TypeScriptValidateTypes if(previewElement.cleanup !== undefined) previewElement.cleanup(); if(targetElement.firstChild !== null) targetElement.removeChild(targetElement.firstChild); } else { this.preview = true; this.parser.storeWarnings = true; targetElement.appendChild(this.parser.parseEverything(this.text)); this.previewWarnings = this.parser.warnings; this.parser.storeWarnings = false; } } } </script> <style lang="scss"> .bbcode-editor .bbcode-toolbar .character-btn { width: 30px; height: 30px; overflow: hidden; a { width: 100%; height: 100%; img { width: inherit; height: inherit; } } } .bbcode-toolbar { .toolbar-buttons { .btn.toggled { background-color: var(--secondary) !important; } } .color-selector { max-width: 145px; top: -57px; left: 94px; line-height: 1; z-index: 1000; background-color: var(--input-bg); .btn-group { display: block; } .btn { &.text-color { border-radius: 0 !important; margin: 0 !important; padding: 0 !important; margin-right: -1px !important; margin-bottom: -1px !important; border: 1px solid var(--secondary); width: 1.3rem; height: 1.3rem; &::before { display: none !important; } &:hover { border-color: var(--gray-dark) !important; } &.red { background-color: var(--textRedColor); } &.orange { background-color: var(--textOrangeColor); } &.yellow { background-color: var(--textYellowColor); } &.green { background-color: var(--textGreenColor); } &.cyan { background-color: var(--textCyanColor); } &.purple { background-color: var(--textPurpleColor); } &.blue { background-color: var(--textBlueColor); } &.pink { background-color: var(--textPinkColor); } &.black { background-color: var(--textBlackColor); } &.brown { background-color: var(--textBrownColor); } &.white { background-color: var(--textWhiteColor); } &.gray { background-color: var(--textGrayColor); } } } } } </style>