0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-02-12 18:18:24 -05:00
penpot/frontend/text-editor/editor/TextEditor.js

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;