1
0
mirror of https://github.com/flarum/core.git synced 2025-10-15 08:55:53 +02:00

Move TextEditor to common (#2649)

This commit is contained in:
Alexander Skvortsov
2021-03-04 16:51:34 -05:00
committed by GitHub
parent 08ba2599d7
commit ddac07d991
8 changed files with 18 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
import * as extend from './extend';
import Session from './Session';
import Store from './Store';
import BasicEditorDriver from './utils/BasicEditorDriver';
import evented from './utils/evented';
import liveHumanTimes from './utils/liveHumanTimes';
import ItemList from './utils/ItemList';
@@ -56,6 +57,8 @@ import ModalManager from './components/ModalManager';
import Button from './components/Button';
import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
@@ -74,6 +77,7 @@ export default {
extend: extend,
Session: Session,
Store: Store,
'utils/BasicEditorDriver': BasicEditorDriver,
'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
@@ -130,6 +134,8 @@ export default {
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,

View File

@@ -0,0 +1,148 @@
import Component from '../Component';
import ItemList from '../utils/ItemList';
import listItems from '../helpers/listItems';
import Button from './Button';
import BasicEditorDriver from '../utils/BasicEditorDriver';
/**
* The `TextEditor` component displays a textarea with controls, including a
* submit button.
*
* ### Attrs
*
* - `composer`
* - `submitLabel`
* - `value`
* - `placeholder`
* - `disabled`
* - `preview`
*/
export default class TextEditor extends Component {
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the editor.
*
* @type {String}
*/
this.value = this.attrs.value || '';
/**
* Whether the editor is disabled.
*/
this.disabled = !!this.attrs.disabled;
}
view() {
return (
<div className="TextEditor">
<div className="TextEditor-editorContainer"></div>
<ul className="TextEditor-controls Composer-footer">
{listItems(this.controlItems().toArray())}
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
</ul>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
}
onupdate() {
const newDisabled = !!this.attrs.disabled;
if (this.disabled !== newDisabled) {
this.disabled = newDisabled;
this.attrs.composer.editor.disabled(newDisabled);
}
}
buildEditorParams() {
return {
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
disabled: this.disabled,
placeholder: this.attrs.placeholder || '',
value: this.value,
oninput: this.oninput.bind(this),
inputListeners: [],
onsubmit: () => {
this.onsubmit();
m.redraw();
},
};
}
buildEditor(dom) {
return new BasicEditorDriver(dom, this.buildEditorParams());
}
/**
* Build an item list for the text editor controls.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
items.add(
'submit',
Button.component(
{
icon: 'fas fa-paper-plane',
className: 'Button Button--primary',
itemClassName: 'App-primaryControl',
onclick: this.onsubmit.bind(this),
},
this.attrs.submitLabel
)
);
if (this.attrs.preview) {
items.add(
'preview',
Button.component({
icon: 'far fa-eye',
className: 'Button Button--icon',
onclick: this.attrs.preview,
title: app.translator.trans('core.forum.composer.preview_tooltip'),
oncreate: (vnode) => $(vnode.dom).tooltip(),
})
);
}
return items;
}
/**
* Build an item list for the toolbar controls.
*
* @return {ItemList}
*/
toolbarItems() {
return new ItemList();
}
/**
* Handle input into the textarea.
*
* @param {String} value
*/
oninput(value) {
this.value = value;
this.attrs.onchange(this.value);
}
/**
* Handle the submit button being clicked.
*/
onsubmit() {
this.attrs.onsubmit(this.value);
}
}

View File

@@ -0,0 +1,19 @@
import Button from './Button';
/**
* The `TextEditorButton` component displays a button suitable for the text
* editor toolbar.
*/
export default class TextEditorButton extends Button {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
oncreate(vnode) {
super.oncreate(vnode);
this.$().tooltip();
}
}

View File

@@ -0,0 +1,124 @@
import getCaretCoordinates from 'textarea-caret';
import EditorDriverInterface, { EditorDriverParams } from './EditorDriverInterface';
export default class BasicEditorDriver implements EditorDriverInterface {
el: HTMLTextAreaElement;
constructor(dom: HTMLElement, params: EditorDriverParams) {
this.el = document.createElement('textarea');
this.build(dom, params);
}
build(dom: HTMLElement, params: EditorDriverParams) {
this.el.className = params.classNames.join(' ');
this.el.disabled = params.disabled;
this.el.placeholder = params.placeholder;
this.el.value = params.value;
const callInputListeners = (e) => {
params.inputListeners.forEach((listener) => {
listener();
});
e.redraw = false;
};
this.el.oninput = (e) => {
params.oninput(this.el.value);
callInputListeners(e);
};
this.el.onclick = callInputListeners;
this.el.onkeyup = callInputListeners;
this.el.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
params.onsubmit();
}
});
dom.append(this.el);
}
protected setValue(value: string) {
$(this.el).val(value).trigger('input');
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
moveCursorTo(position: number) {
this.setSelectionRange(position, position);
}
getSelectionRange(): Array<number> {
return [this.el.selectionStart, this.el.selectionEnd];
}
getLastNChars(n: number): string {
const value = this.el.value;
return value.slice(Math.max(0, this.el.selectionStart - n), this.el.selectionStart);
}
insertAtCursor(text: string) {
this.insertAt(this.el.selectionStart, text);
}
insertAt(pos: number, text: string) {
this.insertBetween(pos, pos, text);
}
insertBetween(start: number, end: number, text: string) {
const value = this.el.value;
const before = value.slice(0, start);
const after = value.slice(end);
this.setValue(`${before}${text}${after}`);
// Move the textarea cursor to the end of the content we just inserted.
this.moveCursorTo(start + text.length);
}
replaceBeforeCursor(start: number, text: string) {
this.insertBetween(start, this.el.selectionStart, text);
}
protected setSelectionRange(start: number, end: number) {
this.el.setSelectionRange(start, end);
this.focus();
}
getCaretCoordinates(position: number) {
const relCoords = getCaretCoordinates(this.el, position);
return {
top: relCoords.top - this.el.scrollTop,
left: relCoords.left,
};
}
// DOM Interactions
/**
* Set the disabled status of the editor.
*/
disabled(disabled: boolean) {
this.el.disabled = disabled;
}
/**
* Focus on the editor.
*/
focus() {
this.el.focus();
}
/**
* Destroy the editor
*/
destroy() {
this.el.remove();
}
}

View File

@@ -0,0 +1,105 @@
export interface EditorDriverParams {
/**
* An array of HTML class names to apply to the editor's main DOM element.
*/
classNames: string[];
/**
* Whether the editor should be initially disabled.
*/
disabled: boolean;
/**
* An optional placeholder for the editor.
*/
placeholder: string;
/**
* An optional initial value for the editor.
*/
value: string;
/**
* This is separate from inputListeners since the full serialized content will be passed to it.
* It is considered private API, and should not be used/modified by extensions not implementing
* EditorDriverInterface.
*/
oninput: Function;
/**
* Each of these functions will be called on click, input, and keyup.
* No arguments will be passed.
*/
inputListeners: Function[];
/**
* This function will be called if submission is triggered programmatically via keybind.
* No arguments should be passed.
*/
onsubmit: Function;
}
export default interface EditorDriverInterface {
/**
* Focus the editor and place the cursor at the given position.
*/
moveCursorTo(position: number): void;
/**
* Get the selected range of the editor.
*/
getSelectionRange(): Array<number>;
/**
* Get the last N characters from the current "text block".
*
* A textarea-based driver would just return the last N characters,
* but more advanced implementations might restrict to the current block.
*
* This is useful for monitoring recent user input to trigger autocomplete.
*/
getLastNChars(n: number): string;
/**
* Insert content into the editor at the position of the cursor.
*/
insertAtCursor(text: string, escape: boolean): void;
/**
* Insert content into the editor at the given position.
*/
insertAt(pos: number, text: string, escape: boolean): void;
/**
* Insert content into the editor between the given positions.
*
* If the start and end positions are different, any text between them will be
* overwritten.
*/
insertBetween(start: number, end: number, text: string, escape: boolean): void;
/**
* Replace existing content from the start to the current cursor position.
*/
replaceBeforeCursor(start: number, text: string, escape: boolean): void;
/**
* Get left and top coordinates of the caret relative to the editor viewport.
*/
getCaretCoordinates(position: number): { left: number; top: number };
/**
* Set the disabled status of the editor.
*/
disabled(disabled: boolean): void;
/**
* Focus on the editor.
*/
focus(): void;
/**
* Destroy the editor
*/
destroy(): void;
}