mirror of
https://github.com/penpot/penpot.git
synced 2025-02-12 18:18:24 -05:00
545 lines
13 KiB
JavaScript
545 lines
13 KiB
JavaScript
/**
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*
|
|
* Copyright (c) KALEIDOS INC
|
|
*/
|
|
|
|
import clipboard from "./clipboard/index.js";
|
|
import commands from "./commands/index.js";
|
|
import ChangeController from './controllers/ChangeController.js';
|
|
import SelectionController from './controllers/SelectionController.js';
|
|
import { createSelectionImposterFromClientRects } from './selection/Imposter.js';
|
|
import { addEventListeners, removeEventListeners } from "./Event.js";
|
|
import { createRoot, createEmptyRoot } from './content/dom/Root.js';
|
|
import { createParagraph, fixParagraph, getParagraph } from './content/dom/Paragraph.js';
|
|
import { createEmptyInline, createInline } from './content/dom/Inline.js';
|
|
import { isLineBreak } from './content/dom/LineBreak.js';
|
|
import LayoutType from './layout/LayoutType.js';
|
|
|
|
/**
|
|
* Text Editor.
|
|
*/
|
|
export class TextEditor extends EventTarget {
|
|
/**
|
|
* Element content editable to be used by the TextEditor
|
|
*
|
|
* @type {HTMLElement}
|
|
*/
|
|
#element = null;
|
|
|
|
/**
|
|
* Map/Dictionary of events.
|
|
*
|
|
* @type {Object.<string, Function>}
|
|
*/
|
|
#events = null;
|
|
|
|
/**
|
|
* Root element that will contain the content.
|
|
*
|
|
* @type {HTMLElement}
|
|
*/
|
|
#root = null;
|
|
|
|
/**
|
|
* Change controller controls when we should notify changes.
|
|
*
|
|
* @type {ChangeController}
|
|
*/
|
|
#changeController = null;
|
|
|
|
/**
|
|
* Selection controller controls the current/saved selection.
|
|
*
|
|
* @type {SelectionController}
|
|
*/
|
|
#selectionController = null;
|
|
|
|
/**
|
|
* Selection imposter keeps selection elements.
|
|
*
|
|
* @type {HTMLElement}
|
|
*/
|
|
#selectionImposterElement = null;
|
|
|
|
/**
|
|
* Style defaults.
|
|
*
|
|
* @type {Object.<string, *>}
|
|
*/
|
|
#styleDefaults = null;
|
|
|
|
/**
|
|
* FIXME: There is a weird case where the events
|
|
* `beforeinput` and `input` have different `data` when
|
|
* characters are deleted when the input type is
|
|
* `insertCompositionText`.
|
|
*/
|
|
#fixInsertCompositionText = false;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
constructor(element, options) {
|
|
super();
|
|
if (!(element instanceof HTMLElement))
|
|
throw new TypeError("Invalid text editor element");
|
|
|
|
this.#element = element;
|
|
this.#selectionImposterElement = options?.selectionImposterElement;
|
|
this.#events = {
|
|
blur: this.#onBlur,
|
|
focus: this.#onFocus,
|
|
|
|
paste: this.#onPaste,
|
|
cut: this.#onCut,
|
|
copy: this.#onCopy,
|
|
|
|
beforeinput: this.#onBeforeInput,
|
|
input: this.#onInput,
|
|
};
|
|
this.#styleDefaults = options?.styleDefaults;
|
|
this.#setup(options);
|
|
}
|
|
|
|
/**
|
|
* Setups editor properties.
|
|
*/
|
|
#setupElementProperties() {
|
|
if (!this.#element.isContentEditable) {
|
|
this.#element.contentEditable = "true";
|
|
// In `jsdom` it isn't enough to set the attribute 'contentEditable'
|
|
// to `true` to work.
|
|
// FIXME: Remove this when `jsdom` implements this interface.
|
|
if (!this.#element.isContentEditable) {
|
|
this.#element.setAttribute("contenteditable", "true");
|
|
}
|
|
}
|
|
if (this.#element.spellcheck) this.#element.spellcheck = false;
|
|
if (this.#element.autocapitalize) this.#element.autocapitalize = false;
|
|
if (!this.#element.autofocus) this.#element.autofocus = true;
|
|
if (!this.#element.role || this.#element.role !== "textbox")
|
|
this.#element.role = "textbox";
|
|
if (this.#element.ariaAutoComplete) this.#element.ariaAutoComplete = false;
|
|
if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true;
|
|
this.#element.dataset.itype = "editor";
|
|
}
|
|
|
|
/**
|
|
* Setups the root element.
|
|
*/
|
|
#setupRoot() {
|
|
this.#root = createEmptyRoot(this.#styleDefaults);
|
|
this.#element.appendChild(this.#root);
|
|
}
|
|
|
|
/**
|
|
* Dispatchs a `change` event.
|
|
*
|
|
* @param {CustomEvent} e
|
|
* @returns {void}
|
|
*/
|
|
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e));
|
|
|
|
/**
|
|
* Dispatchs a `stylechange` event.
|
|
*
|
|
* @param {CustomEvent} e
|
|
* @returns {void}
|
|
*/
|
|
#onStyleChange = (e) => {
|
|
if (this.#selectionImposterElement.children.length > 0) {
|
|
// We need to recreate the selection imposter when we've
|
|
// already have one.
|
|
this.#createSelectionImposter();
|
|
}
|
|
this.dispatchEvent(new e.constructor(e.type, e));
|
|
};
|
|
|
|
/**
|
|
* Setups the elements, the properties and the
|
|
* initial content.
|
|
*/
|
|
#setup(options) {
|
|
this.#setupElementProperties();
|
|
this.#setupRoot();
|
|
this.#changeController = new ChangeController(this);
|
|
this.#changeController.addEventListener("change", this.#onChange);
|
|
this.#selectionController = new SelectionController(
|
|
this,
|
|
document.getSelection(),
|
|
options
|
|
);
|
|
this.#selectionController.addEventListener(
|
|
"stylechange",
|
|
this.#onStyleChange
|
|
);
|
|
addEventListeners(this.#element, this.#events, {
|
|
capture: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates the selection imposter.
|
|
*/
|
|
#createSelectionImposter() {
|
|
// We only create a selection imposter if there's any selection
|
|
// and if there is a selection imposter element to attach the
|
|
// rects.
|
|
if (
|
|
this.#selectionImposterElement &&
|
|
!this.#selectionController.isCollapsed
|
|
) {
|
|
const rects = this.#selectionController.range?.getClientRects();
|
|
if (rects) {
|
|
const rect = this.#selectionImposterElement.getBoundingClientRect();
|
|
this.#selectionImposterElement.replaceChildren(
|
|
createSelectionImposterFromClientRects(rect, rects)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On blur we create a new FakeSelection if there's any.
|
|
*
|
|
* @param {FocusEvent} e
|
|
*/
|
|
#onBlur = (e) => {
|
|
this.#changeController.notifyImmediately();
|
|
this.#selectionController.saveSelection();
|
|
this.#createSelectionImposter();
|
|
this.dispatchEvent(new FocusEvent(e.type, e));
|
|
};
|
|
|
|
/**
|
|
* On focus we should restore the FakeSelection from the current
|
|
* selection.
|
|
*
|
|
* @param {FocusEvent} e
|
|
*/
|
|
#onFocus = (e) => {
|
|
this.#selectionController.restoreSelection();
|
|
if (this.#selectionImposterElement) {
|
|
this.#selectionImposterElement.replaceChildren();
|
|
}
|
|
this.dispatchEvent(new FocusEvent(e.type, e));
|
|
};
|
|
|
|
/**
|
|
* Event called when the user pastes some text into the
|
|
* editor.
|
|
*
|
|
* @param {ClipboardEvent} e
|
|
*/
|
|
#onPaste = (e) => {
|
|
clipboard.paste(e, this, this.#selectionController);
|
|
this.#notifyLayout(LayoutType.FULL, null);
|
|
};
|
|
|
|
/**
|
|
* Event called when the user cuts some text from the
|
|
* editor.
|
|
*
|
|
* @param {ClipboardEvent} e
|
|
*/
|
|
#onCut = (e) => clipboard.cut(e, this, this.#selectionController);
|
|
|
|
/**
|
|
* Event called when the user copies some text from the
|
|
* editor.
|
|
*
|
|
* @param {ClipboardEvent} e
|
|
*/
|
|
#onCopy = (e) => clipboard.copy(e, this, this.#selectionController);
|
|
|
|
/**
|
|
* Event called before the DOM is modified.
|
|
*
|
|
* @param {InputEvent} e
|
|
*/
|
|
#onBeforeInput = (e) => {
|
|
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
|
|
return;
|
|
}
|
|
|
|
if (e.inputType === "insertCompositionText" && !e.data) {
|
|
e.preventDefault();
|
|
this.#fixInsertCompositionText = true;
|
|
return;
|
|
}
|
|
|
|
if (!(e.inputType in commands)) {
|
|
if (e.inputType !== "insertCompositionText") {
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.inputType in commands) {
|
|
const command = commands[e.inputType];
|
|
if (!this.#selectionController.startMutation()) {
|
|
return;
|
|
}
|
|
command(e, this, this.#selectionController);
|
|
const mutations = this.#selectionController.endMutation();
|
|
this.#notifyLayout(LayoutType.FULL, mutations);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Event called after the DOM is modified.
|
|
*
|
|
* @param {InputEvent} e
|
|
*/
|
|
#onInput = (e) => {
|
|
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
|
|
return;
|
|
}
|
|
|
|
if (e.inputType === "insertCompositionText" && this.#fixInsertCompositionText) {
|
|
e.preventDefault();
|
|
this.#fixInsertCompositionText = false;
|
|
if (e.data) {
|
|
this.#selectionController.fixInsertCompositionText();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.inputType === "insertCompositionText" && e.data) {
|
|
this.#notifyLayout(LayoutType.FULL, null);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Notifies that the edited texts needs layout.
|
|
*
|
|
* @param {'full'|'partial'} type
|
|
* @param {CommandMutations} mutations
|
|
*/
|
|
#notifyLayout(type = LayoutType.FULL, mutations) {
|
|
this.dispatchEvent(
|
|
new CustomEvent("needslayout", {
|
|
detail: {
|
|
type: type,
|
|
mutations: mutations,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Root element that contains all the paragraphs.
|
|
*
|
|
* @type {HTMLDivElement}
|
|
*/
|
|
get root() {
|
|
return this.#root;
|
|
}
|
|
|
|
set root(newRoot) {
|
|
const previousRoot = this.#root;
|
|
this.#root = newRoot;
|
|
previousRoot.replaceWith(newRoot);
|
|
}
|
|
|
|
/**
|
|
* Element that contains the root and that has the
|
|
* contenteditable attribute.
|
|
*
|
|
* @type {HTMLElement}
|
|
*/
|
|
get element() {
|
|
return this.#element;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the content is in an empty state.
|
|
*
|
|
* @type {boolean}
|
|
*/
|
|
get isEmpty() {
|
|
return (
|
|
this.#root.children.length === 1 &&
|
|
this.#root.firstElementChild.children.length === 1 &&
|
|
isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Indicates the amount of paragraphs in the current content.
|
|
*
|
|
* @type {number}
|
|
*/
|
|
get numParagraphs() {
|
|
return this.#root.children.length;
|
|
}
|
|
|
|
/**
|
|
* CSS Style declaration for the current inline. From here we
|
|
* can infer root, paragraph and inline declarations.
|
|
*
|
|
* @type {CSSStyleDeclaration}
|
|
*/
|
|
get currentStyle() {
|
|
return this.#selectionController.currentStyle;
|
|
}
|
|
|
|
/**
|
|
* Focus the element
|
|
*/
|
|
focus() {
|
|
return this.#element.focus();
|
|
}
|
|
|
|
/**
|
|
* Blurs the element
|
|
*/
|
|
blur() {
|
|
return this.#element.blur();
|
|
}
|
|
|
|
/**
|
|
* Creates a new root.
|
|
*
|
|
* @param {...any} args
|
|
* @returns {HTMLDivElement}
|
|
*/
|
|
createRoot(...args) {
|
|
return createRoot(...args);
|
|
}
|
|
|
|
/**
|
|
* Creates a new paragraph.
|
|
*
|
|
* @param {...any} args
|
|
* @returns {HTMLDivElement}
|
|
*/
|
|
createParagraph(...args) {
|
|
return createParagraph(...args);
|
|
}
|
|
|
|
/**
|
|
* Creates a new inline from a string.
|
|
*
|
|
* @param {string} text
|
|
* @param {Object.<string,*>|CSSStyleDeclaration} styles
|
|
* @returns {HTMLSpanElement}
|
|
*/
|
|
createInlineFromString(text, styles) {
|
|
if (text === "") {
|
|
return createEmptyInline(styles);
|
|
}
|
|
return createInline(new Text(text), styles);
|
|
}
|
|
|
|
/**
|
|
* Creates a new inline.
|
|
*
|
|
* @param {...any} args
|
|
* @returns {HTMLSpanElement}
|
|
*/
|
|
createInline(...args) {
|
|
return createInline(...args);
|
|
}
|
|
|
|
/**
|
|
* Applies the current styles to the selection or
|
|
* the current DOM node at the caret.
|
|
*
|
|
* @param {*} styles
|
|
*/
|
|
applyStylesToSelection(styles) {
|
|
this.#selectionController.startMutation();
|
|
this.#selectionController.applyStyles(styles);
|
|
const mutations = this.#selectionController.endMutation();
|
|
this.#notifyLayout(LayoutType.FULL, mutations);
|
|
this.#changeController.notifyImmediately();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Selects all content.
|
|
*/
|
|
selectAll() {
|
|
this.#selectionController.selectAll();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Moves cursor to end.
|
|
*
|
|
* @returns
|
|
*/
|
|
cursorToEnd() {
|
|
this.#selectionController.cursorToEnd();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Disposes everything.
|
|
*/
|
|
dispose() {
|
|
this.#changeController.removeEventListener("change", this.#onChange);
|
|
this.#changeController.dispose();
|
|
this.#changeController = null;
|
|
this.#selectionController.removeEventListener(
|
|
"stylechange",
|
|
this.#onStyleChange
|
|
);
|
|
this.#selectionController.dispose();
|
|
this.#selectionController = null;
|
|
removeEventListeners(this.#element, this.#events);
|
|
this.#element = null;
|
|
this.#root = null;
|
|
}
|
|
}
|
|
|
|
export function isEditor(instance) {
|
|
return (instance instanceof TextEditor);
|
|
}
|
|
|
|
/* Convenience function based API for Text Editor */
|
|
export function getRoot(instance) {
|
|
if (isEditor(instance)) {
|
|
return instance.root;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function setRoot(instance, root) {
|
|
if (isEditor(instance)) {
|
|
instance.root = root;
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
export function create(element, options) {
|
|
return new TextEditor(element, {...options});
|
|
}
|
|
|
|
export function getCurrentStyle(instance) {
|
|
if (isEditor(instance)) {
|
|
return instance.currentStyle;
|
|
}
|
|
}
|
|
|
|
export function applyStylesToSelection(instance, styles) {
|
|
if (isEditor(instance)) {
|
|
return instance.applyStylesToSelection(styles);
|
|
}
|
|
}
|
|
|
|
export function dispose(instance) {
|
|
if (isEditor(instance)) {
|
|
instance.dispose();
|
|
}
|
|
}
|
|
|
|
export default TextEditor;
|