fchat-rising/bbcode/Editor.vue

530 lines
18 KiB
Vue
Raw Permalink Normal View History

2017-09-02 01:50:31 +00:00
<template>
2018-07-20 01:12:26 +00:00
<div class="bbcode-editor" style="display:flex;flex-wrap:wrap;justify-content:flex-end">
2017-09-02 01:50:31 +00:00
<slot></slot>
2020-03-15 16:23:39 +00:00
<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>
2022-12-25 05:44:55 +00:00
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :disabled="disabled" :style="showToolbar ? {display: 'flex'} : undefined" @mousedown.stop.prevent
2023-05-28 03:00:51 +00:00
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">
2023-05-30 01:35:04 +00:00
<button v-for="btnCol in buttonColors" type="button" class="btn text-color" :class="btnCol" :title="btnCol" @click.prevent.stop="colorApply(btnCol)" tabindex="0"></button>
2023-05-28 03:00:51 +00:00
</div>
</div>
</div>
2023-05-30 01:35:04 +00:00
<EIconSelector :onSelect="onSelectEIcon" ref="eIconSelector"></EIconSelector>
2023-05-29 04:13:16 +00:00
2023-05-28 03:00:51 +00:00
<div class="btn-group toolbar-buttons" style="flex-wrap:wrap">
2022-12-10 22:42:15 +00:00
<div v-if="!!characterName" class="character-btn">
<icon :character="characterName"></icon>
</div>
2023-05-28 03:00:51 +00:00
<div class="btn btn-light btn-sm" v-for="button in buttons" :class="button.outerClass" :title="button.title" @click.prevent.stop="apply(button)">
2020-03-15 16:23:39 +00:00
<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">&times;</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>
2017-09-02 01:50:31 +00:00
</div>
</template>
<script lang="ts">
2019-01-03 17:38:17 +00:00
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
2023-05-28 03:00:51 +00:00
import _ from 'lodash';
2017-09-02 01:50:31 +00:00
import Vue from 'vue';
import { mixin as clickaway } from 'vue-clickaway';
2017-09-02 01:50:31 +00:00
import {getKey} from '../chat/common';
import {Keys} from '../keys';
2019-09-17 17:14:14 +00:00
import {BBCodeElement, CoreBBCodeParser, urlRegex} from './core';
2017-09-02 01:50:31 +00:00
import {defaultButtons, EditorButton, EditorSelection} from './editor';
import {BBCodeParser} from './parser';
2022-12-10 22:42:15 +00:00
import {default as IconView} from './IconView.vue';
2023-05-29 04:13:16 +00:00
import {default as EIconSelector} from './EIconSelector.vue';
2023-05-30 01:35:04 +00:00
import Modal from '../components/Modal.vue';
2017-09-02 01:50:31 +00:00
2022-12-10 22:42:15 +00:00
@Component({
components: {
2023-05-29 04:13:16 +00:00
'icon': IconView,
'EIconSelector': EIconSelector
},
mixins: [ clickaway ]
2022-12-10 22:42:15 +00:00
})
2017-09-02 01:50:31 +00:00
export default class Editor extends Vue {
2019-09-17 17:14:14 +00:00
@Prop
2017-09-02 01:50:31 +00:00
readonly extras?: EditorButton[];
2020-03-15 15:22:55 +00:00
2017-09-02 01:50:31 +00:00
@Prop({default: 1000})
readonly maxlength!: number;
2020-03-15 15:22:55 +00:00
2019-09-17 17:14:14 +00:00
@Prop
2017-09-02 01:50:31 +00:00
readonly classes?: string;
2020-03-15 15:22:55 +00:00
2019-09-17 17:14:14 +00:00
@Prop
readonly value?: string | undefined = undefined;
2020-03-15 15:22:55 +00:00
2019-09-17 17:14:14 +00:00
@Prop
2017-09-02 01:50:31 +00:00
readonly disabled?: boolean;
2020-03-15 15:22:55 +00:00
2019-09-17 17:14:14 +00:00
@Prop
2017-09-02 01:50:31 +00:00
readonly placeholder?: string;
2020-03-15 15:22:55 +00:00
2018-07-20 01:12:26 +00:00
@Prop({default: true})
readonly hasToolbar!: boolean;
2020-03-15 15:22:55 +00:00
@Prop({default: false, type: Boolean})
readonly invalid!: boolean;
2020-03-15 15:22:55 +00:00
2022-12-10 22:42:15 +00:00
@Prop({default: null})
readonly characterName: string | null = null;
2023-05-30 01:35:04 +00:00
@Prop({default: 'normal'})
readonly type: 'normal' | 'big' = 'normal';
2023-05-28 03:00:51 +00:00
buttonColors = ['red', 'orange', 'yellow', 'green', 'cyan', 'purple', 'blue', 'pink', 'black', 'brown', 'white', 'gray'];
colorPopupVisible = false;
2017-09-02 01:50:31 +00:00
preview = false;
previewWarnings: ReadonlyArray<string> = [];
previewResult = '';
2019-07-06 16:49:19 +00:00
// tslint:disable-next-line: no-unnecessary-type-assertion
text: string = (this.value !== undefined ? this.value : '') as string;
element!: HTMLTextAreaElement;
2018-04-11 19:17:58 +00:00
sizer!: HTMLTextAreaElement;
maxHeight!: number;
minHeight!: number;
2018-01-06 16:14:21 +00:00
showToolbar = false;
2020-03-15 15:22:55 +00:00
protected parser!: BBCodeParser;
2017-09-02 01:50:31 +00:00
protected defaultButtons = defaultButtons;
2020-03-15 15:22:55 +00:00
2017-09-02 01:50:31 +00:00
private isShiftPressed = false;
2018-01-06 16:14:21 +00:00
private undoStack: string[] = [];
private undoIndex = 0;
private lastInput = 0;
2018-03-28 13:51:05 +00:00
//tslint:disable:strict-boolean-expressions
private resizeListener!: () => void;
2017-09-02 01:50:31 +00:00
2019-01-03 17:38:17 +00:00
@Hook('created')
created(): void {
2020-03-15 16:23:39 +00:00
// console.log('EDITOR', 'created');
this.parser = new CoreBBCodeParser();
2018-03-28 13:51:05 +00:00
this.resizeListener = () => {
const styles = getComputedStyle(this.element);
2020-03-15 14:02:31 +00:00
this.maxHeight = parseInt(styles.maxHeight, 10) || 250;
this.minHeight = parseInt(styles.minHeight, 10) || 60;
2018-03-28 13:51:05 +00:00
};
}
2019-01-03 17:38:17 +00:00
@Hook('mounted')
2017-09-02 01:50:31 +00:00
mounted(): void {
2020-03-15 16:23:39 +00:00
// console.log('EDITOR', 'mounted');
2017-09-02 01:50:31 +00:00
this.element = <HTMLTextAreaElement>this.$refs['input'];
const styles = getComputedStyle(this.element);
2020-03-15 14:02:31 +00:00
this.maxHeight = parseInt(styles.maxHeight, 10) || 250;
this.minHeight = parseInt(styles.minHeight, 10) || 60;
2018-01-06 16:14:21 +00:00
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);
2018-04-11 19:17:58 +00:00
this.sizer = <HTMLTextAreaElement>this.$refs['sizer'];
this.sizer.style.cssText = styles.cssText;
this.sizer.style.height = '0';
2018-04-16 23:14:13 +00:00
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();
2018-03-28 13:51:05 +00:00
window.addEventListener('resize', this.resizeListener);
}
2019-01-03 17:38:17 +00:00
2018-03-28 13:51:05 +00:00
//tslint:enable
2019-01-03 17:38:17 +00:00
@Hook('destroyed')
2018-03-28 13:51:05 +00:00
destroyed(): void {
2020-03-17 22:24:34 +00:00
// console.log('EDITOR', 'destroyed');
2018-03-28 13:51:05 +00:00
window.removeEventListener('resize', this.resizeListener);
}
get finalClasses(): string | undefined {
let classes = this.classes;
if(this.invalid)
classes += ' is-invalid';
return classes;
2017-09-02 01:50:31 +00:00
}
get buttons(): EditorButton[] {
const buttons = this.defaultButtons.slice();
2023-05-28 03:00:51 +00:00
2017-09-02 01:50:31 +00:00
if(this.extras !== undefined)
for(let i = 0, l = this.extras.length; i < l; i++)
buttons.push(this.extras[i]);
2023-05-28 03:00:51 +00:00
2023-05-29 04:13:16 +00:00
const colorButtonIndex = _.findIndex(buttons, (b) => b.tag === 'color');
2023-05-28 03:00:51 +00:00
if (this.colorPopupVisible) {
2023-06-06 21:42:05 +00:00
const colorButton = _.clone(buttons[colorButtonIndex]);
2023-05-28 03:00:51 +00:00
colorButton.outerClass = 'toggled';
buttons[colorButtonIndex] = colorButton;
}
2017-09-02 01:50:31 +00:00
return buttons;
}
2023-05-29 04:13:16 +00:00
getButtonByTag(tag: string): EditorButton {
const btn = _.find(this.buttons, (b) => b.tag === tag);
if (!btn) {
throw new Error('Unknown button');
}
return btn;
2023-05-28 03:00:51 +00:00
}
2017-09-02 01:50:31 +00:00
@Watch('value')
watchValue(newValue: string): void {
this.$nextTick(() => this.resize());
2018-01-06 16:14:21 +00:00
if(this.text === newValue) return;
this.text = newValue;
this.lastInput = 0;
this.undoIndex = 0;
this.undoStack = [];
2017-09-02 01:50:31 +00:00
}
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);
}
2023-05-29 04:13:16 +00:00
applyText(startText: string, endText: string, withInject?: string): void {
2017-09-02 01:50:31 +00:00
const selection = this.getSelection();
if(selection.length > 0) {
2023-05-29 04:13:16 +00:00
const replacement = startText + (withInject || selection.text) + endText;
2017-09-02 01:50:31 +00:00
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);
2023-05-29 04:13:16 +00:00
this.text = start + (withInject || '') + end;
2023-05-30 01:35:04 +00:00
const selectionPoint = withInject ? start.length + withInject.length + endText.length : start.length;
this.$nextTick(() => this.setSelection(selectionPoint));
2017-09-02 01:50:31 +00:00
}
this.$emit('input', this.text);
}
2023-05-28 03:00:51 +00:00
dismissColorSelector(): void {
this.colorPopupVisible = false;
}
colorApply(btnColor: string): void {
2023-05-29 04:13:16 +00:00
const button = this.getButtonByTag('color');
2023-05-28 03:00:51 +00:00
this.applyButtonEffect(button, btnColor);
this.colorPopupVisible = false;
}
2023-05-29 04:13:16 +00:00
dismissEIconSelector(): void {
2023-05-30 01:35:04 +00:00
(this.$refs['eIconSelector'] as Modal).hide();
}
showEIconSelector(): void {
(this.$refs['eIconSelector'] as Modal).show();
setTimeout(() => (this.$refs['eIconSelector'] as any).setFocus(), 50);
2023-05-29 04:13:16 +00:00
}
2023-12-21 04:06:58 +00:00
onSelectEIcon(eiconId: string, shift: boolean): void {
this.eiconApply(eiconId, shift);
2023-05-29 04:13:16 +00:00
}
2023-12-21 04:06:58 +00:00
eiconApply(eiconId: string, shift: boolean): void {
2023-05-29 04:13:16 +00:00
const button = this.getButtonByTag('eicon');
this.applyButtonEffect(button, undefined, eiconId);
2023-12-21 04:06:58 +00:00
if (!shift) {
this.dismissEIconSelector();
}
2023-05-29 04:13:16 +00:00
}
2017-09-02 01:50:31 +00:00
apply(button: EditorButton): void {
2023-05-28 03:00:51 +00:00
if (button.tag === 'color') {
this.colorPopupVisible = !this.colorPopupVisible;
return;
} else if (button.tag === 'eicon') {
2023-05-30 01:35:04 +00:00
this.showEIconSelector();
this.colorPopupVisible = false;
2023-05-29 04:13:16 +00:00
return;
} else {
this.colorPopupVisible = false;
2023-05-28 03:00:51 +00:00
}
this.applyButtonEffect(button);
}
2023-05-29 04:13:16 +00:00
applyButtonEffect(button: EditorButton, withArgument?: string, withInject?: string): void {
2017-09-02 01:50:31 +00:00
// Allow emitted variations for custom buttons.
this.$once('insert', (startText: string, endText: string) => this.applyText(startText, endText));
2019-07-06 16:49:19 +00:00
// noinspection TypeScriptValidateTypes
2020-03-15 14:02:31 +00:00
if(button.handler !== undefined) {
// tslint:ignore-next-line:no-any
2020-03-15 02:31:28 +00:00
return button.handler.call(this as any, this);
2020-03-15 14:02:31 +00:00
}
2023-05-28 03:00:51 +00:00
if(button.startText === undefined || withArgument)
button.startText = `[${button.tag}${withArgument ? '=' + withArgument : ''}]`;
2017-09-02 01:50:31 +00:00
if(button.endText === undefined)
button.endText = `[/${button.tag}]`;
2019-07-06 16:49:19 +00:00
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;
2023-05-29 04:13:16 +00:00
this.applyText(button.startText || '', button.endText || '', withInject);
2018-01-06 16:14:21 +00:00
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();
2017-09-02 01:50:31 +00:00
}
onKeyDown(e: KeyboardEvent): void {
const key = getKey(e);
if((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
if(key === Keys.KeyZ) {
2018-01-06 16:14:21 +00:00
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];
2018-08-18 19:37:53 +00:00
this.$emit('input', this.text);
2018-01-06 16:14:21 +00:00
this.lastInput = Date.now();
}
} else if(key === Keys.KeyY) {
2018-01-06 16:14:21 +00:00
e.preventDefault();
if(this.undoIndex > 0) {
this.text = this.undoStack[--this.undoIndex];
2018-08-18 19:37:53 +00:00
this.$emit('input', this.text);
2018-01-06 16:14:21 +00:00
this.lastInput = Date.now();
}
}
2017-09-02 01:50:31 +00:00
for(const button of this.buttons)
if(button.key === key) {
e.stopPropagation();
e.preventDefault();
2023-05-28 03:00:51 +00:00
this.applyButtonEffect(button);
2017-09-02 01:50:31 +00:00
break;
}
} else if(e.shiftKey) this.isShiftPressed = true;
2017-09-02 01:50:31 +00:00
this.$emit('keydown', e);
}
onKeyUp(e: KeyboardEvent): void {
if(!e.shiftKey) this.isShiftPressed = false;
2017-09-02 01:50:31 +00:00
this.$emit('keyup', e);
}
resize(): void {
this.sizer.style.fontSize = this.element.style.fontSize;
this.sizer.style.lineHeight = this.element.style.lineHeight;
2018-03-28 13:51:05 +00:00
this.sizer.style.width = `${this.element.offsetWidth}px`;
2018-04-11 19:17:58 +00:00
this.sizer.value = this.element.value;
this.element.style.height = `${Math.max(Math.min(this.sizer.scrollHeight, this.maxHeight), this.minHeight)}px`;
2018-03-28 13:51:05 +00:00
this.sizer.style.width = '0';
2017-09-02 01:50:31 +00:00
}
onPaste(e: ClipboardEvent): void {
2019-09-17 17:14:14 +00:00
const data = e.clipboardData!.getData('text/plain');
2017-09-02 01:50:31 +00:00
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);
2019-07-06 16:49:19 +00:00
// noinspection TypeScriptValidateTypes
2017-09-02 01:50:31 +00:00
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>
2022-12-10 22:42:15 +00:00
<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;
}
}
}
2023-05-28 03:00:51 +00:00
.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);
}
}
}
}
}
2022-12-10 22:42:15 +00:00
</style>