Files
penpot/frontend/text-editor/src/editor/TextEditor.js
2025-12-01 15:56:52 +01:00

782 lines
18 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 { addEventListeners, removeEventListeners } from "./Event.js";
import {
mapContentFragmentFromHTML,
mapContentFragmentFromString,
} from "./content/dom/Content.js";
import { resetInertElement } from "./content/dom/Style.js";
import { createRoot, createEmptyRoot } from "./content/dom/Root.js";
import { createParagraph } from "./content/dom/Paragraph.js";
import { createEmptyTextSpan, createTextSpan } from "./content/dom/TextSpan.js";
import { isLineBreak } from "./content/dom/LineBreak.js";
import LayoutType from "./layout/LayoutType.js";
/**
* @typedef {Object} TextEditorOptions
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
* @property {SelectionControllerDebug} [debug]
* @property {boolean} [shouldUpdatePositionOnScroll=false]
* @property {boolean} [allowHTMLPaste=false]
*/
/**
* 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;
/**
* 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`.
*
* @type {boolean}
*/
#fixInsertCompositionText = false;
/**
* Canvas element that renders text.
*
* @type {HTMLCanvasElement}
*/
#canvas = null;
/**
* Text editor options.
*
* @type {TextEditorOptions}
*/
#options = {};
/**
* A boolean indicating that this instance was
* disposed or not.
*
* @type {boolean}
*/
#isDisposed = false;
/**
* Constructor.
*
* @param {HTMLElement} element
* @param {HTMLCanvasElement} canvas
* @param {TextEditorOptions} [options]
*/
constructor(element, canvas, options) {
super();
if (!(element instanceof HTMLElement)) {
throw new TypeError("Invalid text editor element");
}
this.#element = element;
this.#canvas = canvas;
this.#events = {
blur: this.#onBlur,
focus: this.#onFocus,
paste: this.#onPaste,
cut: this.#onCut,
copy: this.#onCopy,
beforeinput: this.#onBeforeInput,
input: this.#onInput,
keydown: this.#onKeyDown,
};
this.#styleDefaults = options?.styleDefaults;
this.#options = options;
this.#setup(options);
}
/**
* Setups editor properties.
*/
#setupElementProperties(options) {
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";
if (options.shouldUpdatePositionOnScroll) {
this.#updatePositionFromCanvas();
}
}
/**
* Setups the root element.
*
* @param {TextEditorOptions} options
*/
#setupRoot(options) {
this.#root = createEmptyRoot(this.#styleDefaults);
this.#element.appendChild(this.#root);
}
/**
* Setups event listeners.
*
* @param {TextEditorOptions} options
*/
#setupListeners(options) {
this.#changeController.addEventListener("change", this.#onChange);
this.#selectionController.addEventListener(
"stylechange",
this.#onStyleChange,
);
if (options.shouldUpdatePositionOnScroll) {
window.addEventListener("scroll", this.#onScroll);
}
addEventListeners(this.#element, this.#events, {
capture: true,
});
}
/**
* Disposes everything.
*/
dispose() {
if (this.#isDisposed) {
return this;
}
this.#isDisposed = true;
// Dispose change controller.
this.#changeController.removeEventListener("change", this.#onChange);
this.#changeController.dispose();
this.#changeController = null;
// Disposes selection controller.
this.#selectionController.removeEventListener(
"stylechange",
this.#onStyleChange,
);
this.#selectionController.dispose();
this.#selectionController = null;
// Disposes the rest of event listeners.
removeEventListeners(this.#element, this.#events);
if (this.#options.shouldUpdatePositionOnScroll) {
window.removeEventListener("scroll", this.#onScroll);
}
// Disposes references to DOM elements.
this.#element = null;
this.#root = null;
return this;
}
/**
* Setups controllers.
*
* @param {TextEditorOptions} options
*/
#setupControllers(options) {
this.#changeController = new ChangeController(this);
this.#selectionController = new SelectionController(
this,
document.getSelection(),
options,
);
}
/**
* Setups the elements, the properties and the
* initial content.
*/
#setup(options) {
this.#setupElementProperties(options);
this.#setupRoot(options);
this.#setupControllers(options);
this.#setupListeners(options);
}
/**
* Updates position from canvas.
*/
#updatePositionFromCanvas() {
const boundingClientRect = this.#canvas.getBoundingClientRect();
this.#element.parentElement.style.top = boundingClientRect.top + "px";
this.#element.parentElement.style.left = boundingClientRect.left + "px";
}
/**
* Updates caret position using a transform object.
*
* @param {*} transform
*/
updatePositionWithTransform(transform) {
const x = transform?.x ?? 0.0;
const y = transform?.y ?? 0.0;
const rotation = transform?.rotation ?? 0.0;
const scale = transform?.scale ?? 1.0;
this.#updatePositionFromCanvas();
this.#element.style.transformOrigin = "top left";
this.#element.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${rotation}deg)`;
}
/**
* Updates caret position using viewport and shape.
*
* @param {Viewport} viewport
* @param {Shape} shape
*/
updatePositionWithViewportAndShape(viewport, shape) {
this.updatePositionWithTransform({
x: viewport.x + shape.selrect.x,
y: viewport.y + shape.selrect.y,
rotation: shape.rotation,
scale: viewport.zoom,
});
}
/**
* Updates editor position when the page dispatches
* a scroll event.
*
* @returns
*/
#onScroll = () => this.#updatePositionFromCanvas();
/**
* 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) => {
this.dispatchEvent(new e.constructor(e.type, e));
};
/**
* On blur we create a new FakeSelection if there's any.
*
* @param {FocusEvent} e
*/
#onBlur = (e) => {
this.#changeController.notifyImmediately();
this.#selectionController.saveSelection();
this.dispatchEvent(new FocusEvent(e.type, e));
};
/**
* On focus we should restore the FakeSelection from the current
* selection.
*
* @param {FocusEvent} e
*/
#onFocus = (e) => {
if (!this.#selectionController.restoreSelection()) {
this.selectAll();
}
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) => {
this.dispatchEvent(
new CustomEvent("clipboardchange", {
detail: this.currentStyle,
}),
);
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);
}
};
/**
* Handles keydown events
*
* @param {KeyboardEvent} e
*/
#onKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
this.selectAll();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") {
e.preventDefault();
if (!this.#selectionController.startMutation()) {
return;
}
if (this.#selectionController.isCollapsed) {
this.#selectionController.removeWordBackward();
} else {
this.#selectionController.removeSelected();
}
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
}
};
/**
* 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,
},
}),
);
}
/**
* Indicates that the TextEditor was disposed.
*
* @type {boolean}
*/
get isDisposed() {
return this.#isDisposed;
}
/**
* 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 text span. From here we
* can infer root, paragraph and text span declarations.
*
* @type {CSSStyleDeclaration}
*/
get currentStyle() {
return this.#selectionController.currentStyle;
}
/**
* Text editor options
*
* @type {TextEditorOptions}
*/
get options() {
return this.#options;
}
/**
* 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 text span from a string.
*
* @param {string} text
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLSpanElement}
*/
createTextSpanFromString(text, styles) {
if (text === "") {
return createEmptyTextSpan(styles);
}
return createTextSpan(new Text(text), styles);
}
/**
* Creates a new text span.
*
* @param {...any} args
* @returns {HTMLSpanElement}
*/
createTextSpan(...args) {
return createTextSpan(...args);
}
/**
* Applies the current styles to the selection or
* the current DOM node at the caret.
*
* @param {Object.<string, *>} styles
* @returns {TextEditor}
*/
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.
*
* @returns {TextEditor}
*/
selectAll() {
this.#selectionController.selectAll();
return this;
}
/**
* Moves cursor to end.
*
* @returns {TextEditor}
*/
cursorToEnd() {
this.#selectionController.cursorToEnd();
return this;
}
}
/**
*
* @param {string} html
* @param {*} style
* @param {boolean} allowHTMLPaste
* @returns {Root}
*/
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) {
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined);
const root = createRoot([], style);
root.replaceChildren(fragment);
resetInertElement();
return root;
}
/**
*
* @param {string} string
* @returns {Root}
*/
export function createRootFromString(string) {
const fragment = mapContentFragmentFromString(string);
const root = createRoot([]);
root.replaceChild(fragment);
return root;
}
/**
* Returns true if the passed object is a TextEditor
* instance.
*
* @param {*} instance
* @returns {boolean}
*/
export function isTextEditor(instance) {
return instance instanceof TextEditor;
}
/**
* Returns the root element of a TextEditor
* instance.
*
* @param {TextEditor} instance
* @returns {HTMLDivElement}
*/
export function getRoot(instance) {
if (isTextEditor(instance)) {
return instance.root;
}
return null;
}
/**
* Sets the root of the text editor.
*
* @param {TextEditor} instance
* @param {HTMLDivElement} root
* @returns {TextEditor}
*/
export function setRoot(instance, root) {
if (isTextEditor(instance)) {
instance.root = root;
}
return instance;
}
/**
* Creates a new TextEditor instance.
*
* @param {HTMLDivElement} element
* @param {HTMLCanvasElement} canvas
* @param {TextEditorOptions} options
* @returns {TextEditor}
*/
export function create(element, canvas, options) {
return new TextEditor(element, canvas, { ...options });
}
/**
* Returns the current style of the TextEditor instance.
*
* @param {TextEditor} instance
* @returns {CSSStyleDeclaration|undefined}
*/
export function getCurrentStyle(instance) {
if (isTextEditor(instance)) {
return instance.currentStyle;
}
return null;
}
/**
* Applies the specified styles to the TextEditor
* passed.
*
* @param {TextEditor} instance
* @param {Object.<string, *>} styles
* @returns {TextEditor|null}
*/
export function applyStylesToSelection(instance, styles) {
if (isTextEditor(instance)) {
return instance.applyStylesToSelection(styles);
}
return null;
}
/**
* Disposes the current instance resources by nullifying
* every property.
*
* @param {TextEditor} instance
* @returns {TextEditor|null}
*/
export function dispose(instance) {
if (isTextEditor(instance)) {
return instance.dispose();
}
return null;
}
export default TextEditor;