mirror of
https://github.com/flarum/framework.git
synced 2024-11-26 02:10:09 +08:00
Editor Driver Abstraction (#2594)
This will allow drop-in replacements of the editor with a more advanced WYSIWYG solution such as ProseMirror
This commit is contained in:
parent
26d291199a
commit
edc81bb380
5
framework/core/js/package-lock.json
generated
5
framework/core/js/package-lock.json
generated
|
@ -4474,6 +4474,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"textarea-caret": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz",
|
||||
"integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q=="
|
||||
},
|
||||
"through2": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.1.4"
|
||||
|
|
|
@ -19,7 +19,6 @@ import extract from './utils/extract';
|
|||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
import subclassOf from './utils/subclassOf';
|
||||
import SuperTextarea from './utils/SuperTextarea';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import proxifyCompat from './utils/proxifyCompat';
|
||||
import classList from './utils/classList';
|
||||
|
@ -92,7 +91,6 @@ export default {
|
|||
'utils/stringToColor': stringToColor,
|
||||
'utils/Stream': Stream,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/SuperTextarea': SuperTextarea,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/proxifyCompat': proxifyCompat,
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
|
@ -73,6 +73,7 @@ import DiscussionListItem from './components/DiscussionListItem';
|
|||
import LoadingPost from './components/LoadingPost';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import BasicEditorDriver from './utils/BasicEditorDriver';
|
||||
import routes from './routes';
|
||||
import ForumApplication from './ForumApplication';
|
||||
|
||||
|
@ -85,6 +86,8 @@ export default Object.assign(compat, {
|
|||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/SuperTextarea': BasicEditorDriver, // @deprecated beta 16, remove beta 17
|
||||
'states/ComposerState': ComposerState,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
|
|
|
@ -76,13 +76,13 @@ export default class Composer extends Component {
|
|||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
this.$().on('focus blur', ':input', (e) => {
|
||||
this.$().on('focus blur', ':input,.TextEditor-editorContainer', (e) => {
|
||||
this.active = e.type === 'focusin';
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
||||
this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());
|
||||
|
||||
this.handlers = {};
|
||||
|
||||
|
@ -157,7 +157,7 @@ export default class Composer extends Component {
|
|||
* Draw focus to the first focusable content element (the text editor).
|
||||
*/
|
||||
focus() {
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
this.$('.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import SuperTextarea from '../../common/utils/SuperTextarea';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
* submit button.
|
||||
|
@ -22,25 +23,22 @@ export default class TextEditor extends Component {
|
|||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the textarea.
|
||||
* 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">
|
||||
<textarea
|
||||
className="FormControl Composer-flexible"
|
||||
oninput={(e) => {
|
||||
this.oninput(e.target.value, e);
|
||||
}}
|
||||
placeholder={this.attrs.placeholder || ''}
|
||||
disabled={!!this.attrs.disabled}
|
||||
value={this.value}
|
||||
/>
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
|
@ -53,15 +51,35 @@ export default class TextEditor extends Component {
|
|||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const handler = () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.$('textarea').bind('keydown', 'meta+return', handler);
|
||||
this.$('textarea').bind('keydown', 'ctrl+return', handler);
|
||||
|
||||
this.attrs.composer.editor = new SuperTextarea(this.$('textarea')[0]);
|
||||
buildEditor(dom) {
|
||||
return new BasicEditorDriver(dom, this.buildEditorParams());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -115,12 +133,10 @@ export default class TextEditor extends Component {
|
|||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
oninput(value, e) {
|
||||
oninput(value) {
|
||||
this.value = value;
|
||||
|
||||
this.attrs.onchange(this.value);
|
||||
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import EditorDriverInterface from '../utils/EditorDriverInterface';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
|
@ -29,7 +30,7 @@ class ComposerState {
|
|||
/**
|
||||
* A reference to the text editor that allows text manipulation.
|
||||
*
|
||||
* @type {SuperTextArea|null}
|
||||
* @type {EditorDriverInterface|null}
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
|
@ -66,12 +67,16 @@ class ComposerState {
|
|||
clear() {
|
||||
this.position = ComposerState.Position.HIDDEN;
|
||||
this.body = { attrs: {} };
|
||||
this.editor = null;
|
||||
this.onExit = null;
|
||||
|
||||
this.fields = {
|
||||
content: Stream(''),
|
||||
};
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
124
framework/core/js/src/forum/utils/BasicEditorDriver.ts
Normal file
124
framework/core/js/src/forum/utils/BasicEditorDriver.ts
Normal 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();
|
||||
}
|
||||
}
|
105
framework/core/js/src/forum/utils/EditorDriverInterface.ts
Normal file
105
framework/core/js/src/forum/utils/EditorDriverInterface.ts
Normal 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;
|
||||
}
|
|
@ -293,7 +293,7 @@
|
|||
}
|
||||
}
|
||||
.ComposerBody-editor {
|
||||
.fullScreen & textarea {
|
||||
.fullScreen & .TextEditor-editor {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
@ -323,7 +323,7 @@
|
|||
// ------------------------------------
|
||||
// Text Editor
|
||||
|
||||
.TextEditor textarea {
|
||||
.TextEditor .TextEditor-editor {
|
||||
border-radius: 0;
|
||||
padding: 0 0 10px;
|
||||
border: 0;
|
||||
|
|
Loading…
Reference in New Issue
Block a user