📎 Fix some missing changes

This commit is contained in:
AzazelN28 2024-11-20 12:18:35 +01:00
parent b80ccbec0f
commit c8c83c1e1d
58 changed files with 551 additions and 504 deletions

View file

@ -0,0 +1,32 @@
/**
* 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
*/
/**
* Adds a series of listeners.
*
* @param {EventTarget} target
* @param {Object.<string, Function>} object
* @param {EventListenerOptions} [options]
*/
export function addEventListeners(target, object, options) {
Object.entries(object).forEach(([type, listener]) =>
target.addEventListener(type, listener, options)
);
}
/**
* Removes a series of listeners.
*
* @param {EventTarget} target
* @param {Object.<string, Function>} object
*/
export function removeEventListeners(target, object) {
Object.entries(object).forEach(([type, listener]) =>
target.removeEventListener(type, listener)
);
}

View file

@ -0,0 +1,29 @@
import { describe, test, expect, vi } from "vitest";
import { addEventListeners, removeEventListeners } from "./Event.js";
/* @vitest-environment jsdom */
describe("Event", () => {
test("addEventListeners should add event listeners to an element using an object", () => {
const clickSpy = vi.fn();
const events = {
click: clickSpy,
};
const element = document.createElement("div");
addEventListeners(element, events);
element.dispatchEvent(new Event("click"));
expect(clickSpy).toBeCalled();
});
test("removeEventListeners should remove event listeners to an element using an object", () => {
const clickSpy = vi.fn();
const events = {
click: clickSpy,
};
const element = document.createElement("div");
addEventListeners(element, events);
element.dispatchEvent(new Event("click"));
removeEventListeners(element, events);
element.dispatchEvent(new Event("click"));
expect(clickSpy).toBeCalledTimes(1);
});
});

View file

@ -0,0 +1,65 @@
::selection {
background-color: red;
}
.selection-imposter-rect {
background-color: red;
position: absolute;
}
.text-editor-selection-imposter {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.text-editor-container {
height: 100%;
display: flex;
flex-direction: column;
}
.text-editor-content {
height: 100%;
font-family: sourcesanspro;
outline: none;
user-select: text;
white-space: pre-wrap;
overflow-wrap: break-word;
caret-color: black;
/* color: transparent; */
color: black;
div {
line-height: inherit;
user-select: text;
white-space: pre;
margin: 0px;
/* font-size: 0px; */
}
span {
line-break: auto;
line-height: inherit;
}
}
.align-top[data-itype="root"] {
justify-content: flex-start;
}
.align-center[data-itype="root"] {
justify-content: center;
}
.align-bottom[data-itype="root"] {
justify-content: flex-end;
}

View file

@ -0,0 +1,545 @@
/**
* 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 } 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;

View file

@ -0,0 +1,100 @@
import { describe, test, expect } from "vitest";
import { TextEditor } from "./TextEditor.js";
/* @vitest-environment jsdom */
describe("TextEditor", () => {
test("Creating TextEditor without element should throw", () => {
expect(() => new TextEditor()).toThrowError("Invalid text editor element");
});
test("Creating TextEditor with element should success", () => {
expect(new TextEditor(document.createElement("div"))).toBeInstanceOf(
TextEditor,
);
});
test("isEmpty should return true when editor is empty", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);
expect(textEditor.isEmpty).toBe(true);
});
test("Num paragraphs should return 1 when empty", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);
expect(textEditor.numParagraphs).toBe(1);
});
test("Num paragraphs should return the number of paragraphs", () => {
const textEditor = new TextEditor(document.createElement("div"));
textEditor.root = textEditor.createRoot([
textEditor.createParagraph([
textEditor.createInlineFromString("Hello, World!"),
]),
textEditor.createParagraph([textEditor.createInlineFromString("")]),
textEditor.createParagraph([
textEditor.createInlineFromString("¡Hola, Mundo!"),
]),
textEditor.createParagraph([
textEditor.createInlineFromString("Hallo, Welt!"),
]),
]);
expect(textEditor).toBeInstanceOf(TextEditor);
expect(textEditor.numParagraphs).toBe(4);
});
test("Disposing a TextEditor nullifies everything", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.dispose();
expect(textEditor.root).toBe(null);
expect(textEditor.element).toBe(null);
});
test("TextEditor focus should focus the contenteditable element", () => {
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
expect(document.activeElement).toBe(textEditor.element);
});
test("TextEditor blur should blur the contenteditable element", () => {
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
textEditor.blur();
expect(document.activeElement).not.toBe(textEditor.element);
});
test("TextEditor focus -> blur -> focus should restore old selection", () => {
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
textEditor.root = textEditor.createRoot([
textEditor.createParagraph([
textEditor.createInlineFromString("Hello, World!"),
]),
]);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
textEditor.blur();
textEditor.focus();
expect(document.activeElement).toBe(textEditor.element);
});
test("TextEditor selectAll should select all the contenteditable", () => {
const selection = document.getSelection();
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
textEditor.selectAll();
expect(document.activeElement).toBe(textEditor.element);
expect(selection.containsNode(textEditor.root));
});
});

View file

@ -0,0 +1,19 @@
/**
* 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
*/
/**
* This event is called when the user copies a text from the
* editor.
*
* TODO: We could transform `--fills` in here to CSS `color`, `background-image`,
* etc. to be more compatible with other applications.
*
* @param {ClipboardEvent} event
* @param {TextEditor} editor
*/
export function copy(event, editor) {}

View file

@ -0,0 +1,19 @@
/**
* 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
*/
/**
* This event is called when the user copies a text from the
* editor.
*
* TODO: We could transform `--fills` in here to CSS `color`, `background-image`,
* etc. to be more compatible with other applications.
*
* @param {ClipboardEvent} event
* @param {TextEditor} editor
*/
export function cut(event, editor) {}

View file

@ -0,0 +1,17 @@
/**
* 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 { copy } from "./copy.js";
import { cut } from "./cut.js";
import { paste } from "./paste.js";
export default {
copy,
cut,
paste,
};

View file

@ -0,0 +1,45 @@
/**
* 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 { mapContentFragmentFromHTML, mapContentFragmentFromString } from "../content/dom/Content.js";
/**
* When the user pastes some HTML, what we do is generate
* a new DOM based on what the user pasted and then we
* insert it in the appropiate part (see `insertFromPaste` command).
*
* @param {ClipboardEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
* @returns {void}
*/
export function paste(event, editor, selectionController) {
// We need to prevent default behavior
// because we don't allow any HTML to
// be pasted.
event.preventDefault();
let fragment = null;
if (event.clipboardData.types.includes("text/html")) {
const html = event.clipboardData.getData("text/html");
fragment = mapContentFragmentFromHTML(html, selectionController.currentStyle);
} else if (event.clipboardData.types.includes("text/plain")) {
const plain = event.clipboardData.getData("text/plain");
fragment = mapContentFragmentFromString(plain, selectionController.currentStyle);
}
if (!fragment) {
return;
}
if (selectionController.isCollapsed) {
selectionController.insertPaste(fragment);
} else {
selectionController.replaceWithPaste(fragment);
}
}

View file

@ -0,0 +1,66 @@
/**
* 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
*/
/**
* Command mutations
*/
export class CommandMutations {
#added = new Set();
#removed = new Set();
#updated = new Set();
constructor(added, updated, removed) {
if (added && Array.isArray(added)) this.#added = new Set(added);
if (updated && Array.isArray(updated)) this.#updated = new Set(updated);
if (removed && Array.isArray(removed)) this.#removed = new Set(removed);
}
get added() {
return this.#added;
}
get removed() {
return this.#removed;
}
get updated() {
return this.#updated;
}
clear() {
this.#added.clear();
this.#removed.clear();
this.#updated.clear();
}
dispose() {
this.#added.clear();
this.#added = null;
this.#removed.clear();
this.#removed = null;
this.#updated.clear();
this.#updated = null;
}
add(node) {
this.#added.add(node);
return this;
}
remove(node) {
this.#removed.add(node);
return this;
}
update(node) {
this.#updated.add(node);
return this;
}
}
export default CommandMutations;

View file

@ -0,0 +1,71 @@
import { describe, test, expect } from "vitest";
import CommandMutations from "./CommandMutations.js";
describe("CommandMutations", () => {
test("should create a new CommandMutations", () => {
const mutations = new CommandMutations();
expect(mutations).toHaveProperty("added");
expect(mutations).toHaveProperty("updated");
expect(mutations).toHaveProperty("removed");
});
test("should create an initialized new CommandMutations", () => {
const mutations = new CommandMutations([1], [2], [3]);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.size).toBe(1);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.removed.has(3)).toBe(true);
});
test("should add an added node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
expect(mutations.added.has(1)).toBe(true);
});
test("should add an updated node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.update(1);
expect(mutations.updated.has(1)).toBe(true);
});
test("should add an removed node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.remove(1);
expect(mutations.removed.has(1)).toBe(true);
});
test("should clear a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.has(3)).toBe(true);
expect(mutations.removed.size).toBe(1);
mutations.clear();
expect(mutations.added.size).toBe(0);
expect(mutations.added.has(1)).toBe(false);
expect(mutations.updated.size).toBe(0);
expect(mutations.updated.has(1)).toBe(false);
expect(mutations.removed.size).toBe(0);
expect(mutations.removed.has(1)).toBe(false);
});
test("should dispose a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
mutations.dispose();
expect(mutations.added).toBe(null);
expect(mutations.updated).toBe(null);
expect(mutations.removed).toBe(null);
});
});

View file

@ -0,0 +1,22 @@
/**
* 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
*/
/**
* Remove the current selection as part of a cut.
*
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function deleteByCut(event, editor, selectionController) {
event.preventDefault();
if (selectionController.isCollapsed) {
throw new Error("This should be impossible");
}
return selectionController.removeSelected();
}

View file

@ -0,0 +1,53 @@
/**
* 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
*/
/**
* delete the content directly before the caret position and this intention is
* not covered by another `inputType` or delete the selection with the
* selection collapsing to its start after the deletion.
*
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function deleteContentBackward(event, editor, selectionController) {
event.preventDefault();
// If the editor is empty this is a no op.
if (editor.isEmpty) return;
// If not is collapsed AKA is a selection, then
// we removeSelected.
if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: 'backward' });
}
// If we're in a text node and the offset is
// greater than 0 (not at the start of the inline)
// we simple remove a character from the text.
if (selectionController.isTextFocus && selectionController.focusOffset > 0) {
return selectionController.removeBackwardText();
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
} else if (
selectionController.isTextFocus &&
selectionController.focusAtStart
) {
return selectionController.mergeBackwardParagraph();
// If we're at an inline or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
} else if (
selectionController.isInlineFocus ||
selectionController.isLineBreakFocus
) {
return selectionController.removeBackwardParagraph();
}
}

View file

@ -0,0 +1,54 @@
/**
* 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
*/
/**
* delete the content directly after the caret position and this intention is not covered by
* another inputType or delete the selection with the selection collapsing to its end after the deletion
*
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function deleteContentForward(event, editor, selectionController) {
event.preventDefault();
// If the editor is empty this is a no op.
if (editor.isEmpty) return;
// If not is collapsed AKA is a selection, then
// we removeSelected.
if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: "forward" });
}
// If we're in a text node and the offset is
// greater than 0 (not at the start of the inline)
// we simple remove a character from the text.
if (selectionController.isTextFocus
&& selectionController.focusAtEnd) {
return selectionController.mergeForwardParagraph();
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
} else if (
selectionController.isTextFocus &&
selectionController.focusOffset >= 0
) {
return selectionController.removeForwardText();
// If we're at an inline or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
} else if (
(selectionController.isInlineFocus ||
selectionController.isLineBreakFocus) &&
editor.numParagraphs > 1
) {
return selectionController.removeForwardParagraph();
}
}

View file

@ -0,0 +1,21 @@
/**
* 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 { insertText } from "./insertText.js";
import { insertParagraph } from "./insertParagraph.js";
import { deleteByCut } from "./deleteByCut.js";
import { deleteContentBackward } from "./deleteContentBackward.js";
import { deleteContentForward } from "./deleteContentForward.js";
export default {
insertText,
insertParagraph,
deleteByCut,
deleteContentBackward,
deleteContentForward,
};

View file

@ -0,0 +1,23 @@
/**
* 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
*/
/**
* Insert a paragraph
*
* @see https://w3c.github.io/input-events/#interface-InputEvent
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function insertParagraph(event, editor, selectionController) {
event.preventDefault();
if (selectionController.isCollapsed) {
return selectionController.insertParagraph();
}
return selectionController.replaceWithParagraph();
}

View file

@ -0,0 +1,34 @@
/**
* 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
*/
/**
* Insert typed plain text
*
* @see https://w3c.github.io/input-events/#interface-InputEvent
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function insertText(event, editor, selectionController) {
event.preventDefault();
if (selectionController.isCollapsed) {
if (selectionController.isTextFocus) {
return selectionController.insertText(event.data);
} else if (selectionController.isLineBreakFocus) {
return selectionController.replaceLineBreak(event.data);
}
} else {
if (selectionController.isMultiParagraph) {
return selectionController.replaceParagraphs(event.data);
} else if (selectionController.isMultiInline) {
return selectionController.replaceInlines(event.data);
} else if (selectionController.isTextSame) {
return selectionController.replaceText(event.data);
}
}
}

View file

@ -0,0 +1,104 @@
/**
* 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
*/
/**
* Throws if the passed value is not a valid offset value.
*
* @param {*} offset
* @throws {TypeError}
*/
function tryOffset(offset) {
if (!Number.isInteger(offset) || offset < 0)
throw new TypeError("Invalid offset");
}
/**
* Throws if the passed value is not a valid string.
*
* @param {*} str
* @throws {TypeError}
*/
function tryString(str) {
if (typeof str !== "string") throw new TypeError("Invalid string");
}
/**
* Inserts string into a string.
*
* @param {string} str
* @param {number} offset
* @param {string} text
* @returns {string}
*/
export function insertInto(str, offset, text) {
tryString(str);
tryOffset(offset);
tryString(text);
return str.slice(0, offset) + text + str.slice(offset);
}
/**
* Replaces a part of a string with a string.
*
* @param {string} str
* @param {number} startOffset
* @param {number} endOffset
* @param {string} text
* @returns {string}
*/
export function replaceWith(str, startOffset, endOffset, text) {
tryString(str);
tryOffset(startOffset);
tryOffset(endOffset);
tryString(text);
return str.slice(0, startOffset) + text + str.slice(endOffset);
}
/**
* Removes text backward from specified offset.
*
* @param {string} str
* @param {number} offset
* @returns {string}
*/
export function removeBackward(str, offset) {
tryString(str);
tryOffset(offset);
if (offset === 0) {
return str;
}
return str.slice(0, offset - 1) + str.slice(offset);
}
/**
* Removes text forward from specified offset.
*
* @param {string} str
* @param {number} offset
* @returns {string}
*/
export function removeForward(str, offset) {
tryString(str);
tryOffset(offset);
return str.slice(0, offset) + str.slice(offset + 1);
}
/**
* Removes a slice of text.
*
* @param {string} str
* @param {number} start
* @param {number} end
* @returns {string}
*/
export function removeSlice(str, start, end) {
tryString(str);
tryOffset(start);
tryOffset(end);
return str.slice(0, start) + str.slice(end);
}

View file

@ -0,0 +1,46 @@
import { describe, test, expect } from 'vitest'
import { insertInto, removeBackward, removeForward, replaceWith } from './Text';
describe("Text", () => {
test("* should throw when passed wrong parameters", () => {
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string');
expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset');
expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string');
});
test("`insertInto` should insert a string into an offset", () => {
expect(insertInto("Hell, World!", 4, "o")).toBe("Hello, World!");
});
test("`replaceWith` should replace a string into a string", () => {
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!");
});
test("`removeBackward` should remove string backward from start (offset 0)", () => {
expect(removeBackward("Hello, World!", 0)).toBe("Hello, World!");
});
test("`removeForward` should remove string forward from start (offset 0)", () => {
expect(removeForward("Hello, World!", 0)).toBe("ello, World!");
});
test("`removeBackward` should remove string backward from end", () => {
expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World"
);
});
test("`removeForward` should remove string forward from end", () => {
expect(removeForward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World!"
);
});
test("`removeBackward` should remove string backward from offset 6", () => {
expect(removeBackward("Hello, World!", 6)).toBe("Hello World!");
});
test("`removeForward` should remove string forward from offset 6", () => {
expect(removeForward("Hello, World!", 6)).toBe("Hello,World!");
});
});

View file

@ -0,0 +1,78 @@
/**
* Canvas used to retrieve colors as CSS hexadecimals.
*
* @type {OffscreenCanvas|HTMLCanvasElement}
*/
let canvas = null; // createCanvas(1, 1);
/**
* Context used to retrieve colors as CSS hexadecimals.
*
* @type {CanvasRenderingContext2D}
*/
let context = null; // canvas.getContext("2d");
/**
* Returns the canvas context.
*
* @returns {CanvasRenderingContext2D}
*/
function getContext() {
if (!canvas) {
canvas = createCanvas(1, 1);
}
if (!context) {
context = canvas.getContext("2d");
}
return context
}
/**
* Creates a new canvas element.
*
* @param {number} width
* @param {number} height
* @returns {OffscreenCanvas|HTMLCanvasElement}
*/
function createCanvas(width, height) {
if ("OffscreenCanvas" in globalThis) {
return new OffscreenCanvas(width, height);
}
return document.createElement("canvas");
}
/**
* Returns a byte representation as an hex.
*
* @param {number} byte
* @returns {string}
*/
export function getByteAsHex(byte) {
return byte.toString(16).padStart(2, "0");
}
/**
* Returns a color definition from a fillStyle color.
*
* @param {string} fillStyle
* @returns {[string, number]}
*/
export function getColor(fillStyle) {
const context = getContext();
context.fillStyle = fillStyle;
context.fillRect(0, 0, 1, 1);
const imageData = context.getImageData(0, 0, 1, 1);
const [r, g, b, a] = imageData.data;
return [`#${getByteAsHex(r)}${getByteAsHex(g)}${getByteAsHex(b)}`, a / 255.0];
}
/**
* Returns a fill from a fillStyle color.
*
* @param {string} fillStyle
* @returns {string}
*/
export function getFills(fillStyle) {
const [color, opacity] = getColor(fillStyle);
return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`;
}

View file

@ -0,0 +1,102 @@
/**
* 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 { createInline } from "./Inline.js";
import {
createEmptyParagraph,
createParagraph,
isLikeParagraph,
} from "./Paragraph.js";
import { isDisplayBlock, normalizeStyles } from "./Style.js";
/**
* Maps any HTML into a valid content DOM element.
*
* @param {Document} document
* @param {HTMLElement} root
* @param {CSSStyleDeclaration} [styleDefaults]
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromDocument(document, root, styleDefaults) {
const nodeIterator = document.createNodeIterator(
root,
NodeFilter.SHOW_TEXT
);
const fragment = document.createDocumentFragment();
let currentParagraph = null;
let currentNode = nodeIterator.nextNode();
while (currentNode) {
// We cannot call document.defaultView because it is `null`.
const currentStyle = normalizeStyles(currentNode, styleDefaults);
if (
isDisplayBlock(currentNode.parentElement.style) ||
isDisplayBlock(currentStyle) ||
isLikeParagraph(currentNode.parentElement)
) {
if (currentParagraph) {
fragment.appendChild(currentParagraph);
}
currentParagraph = createParagraph(undefined, currentStyle);
} else {
if (currentParagraph === null) {
currentParagraph = createParagraph(undefined, currentStyle);
}
}
currentParagraph.appendChild(
createInline(new Text(currentNode.nodeValue), currentStyle)
);
currentNode = nodeIterator.nextNode();
}
fragment.appendChild(currentParagraph);
return fragment;
}
/**
* Maps any HTML into a valid content DOM element.
*
* @param {string} html
* @param {CSSStyleDeclaration} [styleDefaults]
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromHTML(html, styleDefaults) {
const parser = new DOMParser();
const htmlDocument = parser.parseFromString(html, "text/html");
return mapContentFragmentFromDocument(
htmlDocument,
htmlDocument.documentElement,
styleDefaults
);
}
/**
* Maps a plain text into a valid content DOM element.
*
* @param {string} string
* @param {CSSStyleDeclaration} [styleDefaults]
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromString(string, styleDefaults) {
const lines = string.replace(/\r/g, "").split("\n");
const fragment = document.createDocumentFragment();
for (const line of lines) {
if (line === "") {
fragment.appendChild(createEmptyParagraph(styleDefaults));
} else {
fragment.appendChild(
createParagraph([
createInline(new Text(line), styleDefaults)
], styleDefaults)
);
}
}
return fragment;
}

View file

@ -0,0 +1,98 @@
import { describe, test, expect } from "vitest";
import {
mapContentFragmentFromHTML,
mapContentFragmentFromString,
} from "./Content.js";
/* @vitest-environment jsdom */
describe("Content", () => {
test("mapContentFragmentFromHTML should return a valid content for the editor", () => {
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Hello, World!</div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(1);
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.firstElementChild.firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.textContent).toBe("Hello, World!");
});
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple inlines)", () => {
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Hello,<br/><span> World!</span><br/></div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(1);
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.firstElementChild.children).toHaveLength(2);
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.firstElementChild.firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.textContent).toBe("Hello, World!");
});
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple paragraphs)", () => {
const paragraphs = [
"Lorem ipsum",
"Dolor sit amet",
"Sed iaculis blandit odio ornare sagittis.",
];
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Lorem ipsum</div><div>Dolor sit amet</div><div><br/></div><div>Sed iaculis blandit odio ornare sagittis.</div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(3);
for (let index = 0; index < contentFragment.children.length; index++) {
expect(contentFragment.children.item(index)).toBeInstanceOf(
HTMLDivElement,
);
expect(
contentFragment.children.item(index).firstElementChild,
).toBeInstanceOf(HTMLSpanElement);
expect(
contentFragment.children.item(index).firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.children.item(index).textContent).toBe(
paragraphs[index],
);
}
expect(contentFragment.textContent).toBe(
"Lorem ipsumDolor sit ametSed iaculis blandit odio ornare sagittis.",
);
});
test("mapContentFragmentFromString should return a valid content for the editor", () => {
const contentFragment = mapContentFragmentFromString("Hello, \nWorld!");
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(2);
expect(contentFragment.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.children.item(0).firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.children.item(0).firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.children.item(1).firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.children.item(1).firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.textContent).toBe("Hello, World!");
});
});

View file

@ -0,0 +1,98 @@
/**
* 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 { setStyles } from "./Style.js";
/**
* @typedef {Object} CreateElementOptions
* @property {Object.<string,*>} [attributes]
* @property {Object.<string,*>} [data]
* @property {Object.<string,*>|CSSStyleDeclaration} [styles]
* @property {Array<[string,?string]>} [allowedStyles]
* @property {Array|Node} [children]
*/
/**
* Creates a new random id to identify content nodes.
*
* @returns {string}
*/
export function createRandomId() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
}
/**
* Creates a new HTML element.
*
* @param {string} tag
* @param {*} options
* @returns {HTMLElement}
*/
export function createElement(tag, options) {
const element = document.createElement(tag);
if (options?.attributes) {
Object.entries(options.attributes).forEach(([name, value]) =>
element.setAttribute(name, value)
);
}
if (options?.data) {
Object.entries(options.data).forEach(
([name, value]) => (element.dataset[name] = value)
);
}
if (options?.styles && options?.allowedStyles) {
setStyles(element, options.allowedStyles, options.styles);
}
if (options?.children) {
if (Array.isArray(options.children)) {
element.append(...options.children);
} else {
element.appendChild(options.children);
}
}
return element;
}
/**
* Returns true if passed node is an element.
*
* @param {Node} element
* @param {string} nodeName
* @returns {boolean}
*/
export function isElement(element, nodeName) {
return (
element.nodeType === Node.ELEMENT_NODE &&
element.nodeName === nodeName.toUpperCase()
);
}
/**
* Returns true if the specified offset is at the start of the element.
*
* @param {Node} node
* @param {number} offset
* @returns {boolean}
*/
export function isOffsetAtStart(node, offset) {
return offset === 0;
}
/**
* Returns true if the specified offset is at the end of the element.
*
* @param {Node} node
* @param {number} offset
* @returns {boolean}
*/
export function isOffsetAtEnd(node, offset) {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue.length === offset;
}
return true;
}

View file

@ -0,0 +1,116 @@
import { describe, test, expect } from "vitest";
import {
createElement,
isElement,
createRandomId,
isOffsetAtStart,
isOffsetAtEnd,
} from "./Element.js";
/* @vitest-environment jsdom */
describe("Element", () => {
test("createRandomId should create a new random id", () => {
const randomId = createRandomId();
expect(typeof randomId).toBe("string");
expect(randomId.length).toBeGreaterThan(0);
expect(randomId.length).toBeLessThan(12);
});
test("createElement should create a new element", () => {
const element = createElement("div");
expect(element.nodeType).toBe(Node.ELEMENT_NODE);
expect(element.nodeName).toBe("DIV");
});
test("createElement should create a new element with attributes", () => {
const element = createElement("div", {
attributes: {
"aria-multiline": true,
role: "textbox",
},
});
expect(element.ariaMultiLine).toBe("true");
expect(element.role).toBe("textbox");
});
test("createElement should create a new element with data- properties", () => {
const element = createElement("div", {
data: {
itype: "root",
},
});
expect(element.dataset.itype).toBe("root");
});
test("createElement should create a new element with styles from an object", () => {
const element = createElement("div", {
styles: {
"text-decoration": "underline",
},
allowedStyles: [["text-decoration"]],
});
expect(element.style.textDecoration).toBe("underline");
});
test("createElement should create a new element with a child", () => {
const element = createElement("div", {
children: new Text("Hello, World!"),
});
expect(element.textContent).toBe("Hello, World!");
});
test("createElement should create a new element with children", () => {
const element = createElement("div", {
children: [
createElement("div", {
children: [
createElement("div", {
children: new Text("Hello, World!"),
}),
],
}),
],
});
expect(element.textContent).toBe("Hello, World!");
expect(element.firstChild.nodeType).toBe(Node.ELEMENT_NODE);
expect(element.firstChild.firstChild.nodeType).toBe(Node.ELEMENT_NODE);
expect(element.firstChild.firstChild.firstChild.nodeType).toBe(
Node.TEXT_NODE,
);
});
test("isElement returns true if the passed element is the expected element", () => {
const br = createElement("br");
expect(isElement(br, "br")).toBe(true);
const div = createElement("div");
expect(isElement(div, "div")).toBe(true);
const text = new Text("Hello, World!");
expect(isElement(text, "text")).toBe(false);
});
test("isOffsetAtStart should return true when offset is 0", () => {
const element = createElement("span", {
children: new Text("Hello"),
});
expect(isOffsetAtStart(element, 0)).toBe(true);
});
test("isOffsetAtEnd should return true when offset is the length of the text content", () => {
const element = createElement("span", {
children: new Text("Hello"),
});
expect(isOffsetAtEnd(element, 5)).toBe(true);
});
test("isOffsetAtEnd should return true when the node is a Text and offset is the length of the node", () => {
const element = new Text("Hello");
expect(isOffsetAtEnd(element, 5)).toBe(true);
});
test("isOffsetAtEnd should return true when node is an element", () => {
const element = createElement("span", {
children: createElement("br"),
});
expect(isOffsetAtEnd(element, 5)).toBe(true);
});
});

View file

@ -0,0 +1,272 @@
/**
* 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 {
createElement,
isElement,
isOffsetAtStart,
isOffsetAtEnd,
} from "./Element.js";
import { createLineBreak, isLineBreak } from "./LineBreak.js";
import { setStyles, mergeStyles } from "./Style.js";
import { createRandomId } from "./Element.js";
export const TAG = "SPAN";
export const TYPE = "inline";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [
["--typography-ref-id"],
["--typography-ref-file"],
["--font-id"],
["--font-variant-id"],
["--fills"],
["font-variant"],
["font-family"],
["font-size", "px"],
["font-weight"],
["font-style"],
["line-height"],
["letter-spacing", "px"],
["text-decoration"],
["text-transform"],
];
/**
* Returns true if passed node is an inline.
*
* @param {Node} node
* @returns {boolean}
*/
export function isInline(node) {
if (!node) return false;
if (!isElement(node, TAG)) return false;
if (node.dataset.itype !== TYPE) return false;
return true;
}
/**
* Returns true if the passed node "behaves" like an
* inline.
*
* @param {Node} element
* @returns {boolean}
*/
export function isLikeInline(element) {
return element
? [
"A",
"ABBR",
"ACRONYM",
"B",
"BDO",
"BIG",
"BR",
"BUTTON",
"CITE",
"CODE",
"DFN",
"EM",
"I",
"IMG",
"INPUT",
"KBD",
"LABEL",
"MAP",
"OBJECT",
"OUTPUT",
"Q",
"SAMP",
"SCRIPT",
"SELECT",
"SMALL",
"SPAN",
"STRONG",
"SUB",
"SUP",
"TEXTAREA",
"TIME",
"TT",
"VAR",
].includes(element.nodeName)
: false;
}
/**
* Creates a new Inline
*
* @param {Text|HTMLBRElement} text
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} [attrs]
* @returns {HTMLSpanElement}
*/
export function createInline(textOrLineBreak, styles, attrs) {
if (
!(textOrLineBreak instanceof HTMLBRElement) &&
!(textOrLineBreak instanceof Text)
) {
throw new TypeError("Invalid inline child");
}
if (textOrLineBreak instanceof Text
&& textOrLineBreak.nodeValue.length === 0) {
console.trace("nodeValue", textOrLineBreak.nodeValue)
throw new TypeError("Invalid inline child, cannot be an empty text");
}
return createElement(TAG, {
attributes: { id: createRandomId(), ...attrs },
data: { itype: TYPE },
styles: styles,
allowedStyles: STYLES,
children: textOrLineBreak,
});
}
/**
* Creates a new inline from an older inline. This only
* merges styles from the older inline to the new inline.
*
* @param {HTMLSpanElement} inline
* @param {Object.<string, *>} textOrLineBreak
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} [attrs]
* @returns {HTMLSpanElement}
*/
export function createInlineFrom(inline, textOrLineBreak, styles, attrs) {
return createInline(
textOrLineBreak,
mergeStyles(STYLES, inline.style, styles),
attrs
);
}
/**
* Creates a new empty inline.
*
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLSpanElement}
*/
export function createEmptyInline(styles) {
return createInline(createLineBreak(), styles);
}
/**
* Sets the inline styles.
*
* @param {HTMLSpanElement} element
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLSpanElement}
*/
export function setInlineStyles(element, styles) {
return setStyles(element, STYLES, styles);
}
/**
* Gets the closest inline from a node.
*
* @param {Node} node
* @returns {HTMLElement|null}
*/
export function getInline(node) {
if (!node) return null; // FIXME: Should throw?
if (isInline(node)) return node;
if (node.nodeType === Node.TEXT_NODE) {
const inline = node?.parentElement;
if (!inline) return null;
if (!isInline(inline)) return null;
return inline;
}
return node.closest(QUERY);
}
/**
* Returns true if we are at the start offset
* of an inline.
*
* NOTE: Only the first inline returns this as true
*
* @param {TextNode|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isInlineStart(node, offset) {
const inline = getInline(node);
if (!inline) return false;
return isOffsetAtStart(inline, offset);
}
/**
* Returns true if we are at the end offset
* of an inline.
*
* @param {TextNode|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isInlineEnd(node, offset) {
const inline = getInline(node);
if (!inline) return false;
return isOffsetAtEnd(inline.firstChild, offset);
}
/**
* Splits an inline.
*
* @param {HTMLSpanElement} inline
* @param {number} offset
*/
export function splitInline(inline, offset) {
const textNode = inline.firstChild;
const style = inline.style;
const newTextNode = textNode.splitText(offset);
return createInline(newTextNode, style);
}
/**
* Returns all the inlines of a paragraph starting at
* the specified inline.
*
* @param {HTMLSpanElement} startInline
* @returns {Array<HTMLSpanElement>}
*/
export function getInlinesFrom(startInline) {
const inlines = [];
let currentInline = startInline;
let index = 0;
while (currentInline) {
if (index > 0) inlines.push(currentInline);
currentInline = currentInline.nextElementSibling;
index++;
}
return inlines;
}
/**
* Returns the length of an inline.
*
* @param {HTMLElement} inline
* @returns {number}
*/
export function getInlineLength(inline) {
if (!isInline(inline)) throw new Error("Invalid inline");
if (isLineBreak(inline.firstChild)) return 0;
return inline.firstChild.nodeValue.length;
}
/**
* Merges two inlines.
*
* @param {HTMLSpanElement} a
* @param {HTMLSpanElement} b
* @returns {HTMLSpanElement}
*/
export function mergeInlines(a, b) {
a.append(...b.childNodes);
b.remove();
// We need to normalize Text nodes.
a.normalize();
return a;
}

View file

@ -0,0 +1,123 @@
import { describe, test, expect } from "vitest";
import {
createEmptyInline,
createInline,
getInline,
getInlineLength,
isInline,
isInlineEnd,
isInlineStart,
isLikeInline,
splitInline,
TAG,
TYPE,
} from "./Inline.js";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("Inline", () => {
test("createInline should throw when passed an invalid child", () => {
expect(() => createInline("Hello, World!")).toThrowError(
"Invalid inline child",
);
});
test("createInline creates a new inline element with a <br> inside", () => {
const inline = createInline(createLineBreak());
expect(inline).toBeInstanceOf(HTMLSpanElement);
expect(inline.dataset.itype).toBe(TYPE);
expect(inline.nodeName).toBe(TAG);
expect(inline.textContent).toBe("");
expect(inline.firstChild).toBeInstanceOf(HTMLBRElement);
});
test("createInline creates a new inline element with a text inside", () => {
const inline = createInline(new Text("Hello, World!"));
expect(inline).toBeInstanceOf(HTMLSpanElement);
expect(inline.dataset.itype).toBe(TYPE);
expect(inline.nodeName).toBe(TAG);
expect(inline.textContent).toBe("Hello, World!");
expect(inline.firstChild).toBeInstanceOf(Text);
});
test("createEmptyInline creates a new empty inline element with a <br> inside", () => {
const emptyInline = createEmptyInline();
expect(emptyInline).toBeInstanceOf(HTMLSpanElement);
expect(emptyInline.dataset.itype).toBe(TYPE);
expect(emptyInline.nodeName).toBe(TAG);
expect(emptyInline.textContent).toBe("");
expect(emptyInline.firstChild).toBeInstanceOf(HTMLBRElement);
});
test("isInline should return true on elements that are inlines", () => {
const inline = createInline(new Text("Hello, World!"));
expect(isInline(inline)).toBe(true);
const a = document.createElement("a");
expect(isInline(a)).toBe(false);
const b = null;
expect(isInline(b)).toBe(false);
const c = document.createElement("span");
expect(isInline(c)).toBe(false);
});
test("isLikeInline should return true on elements that have inline behavior by default", () => {
expect(isLikeInline(Infinity)).toBe(false);
expect(isLikeInline(null)).toBe(false);
expect(isLikeInline(document.createElement("A"))).toBe(true);
});
// FIXME: Should throw?
test("isInlineStart returns false when passed node is not an inline", () => {
const inline = document.createElement("div");
expect(isInlineStart(inline, 0)).toBe(false);
expect(isInlineStart(inline, "Hello, World!".length)).toBe(false);
});
test("isInlineStart returns if we're at the start of an inline", () => {
const inline = createInline(new Text("Hello, World!"));
expect(isInlineStart(inline, 0)).toBe(true);
expect(isInlineStart(inline, "Hello, World!".length)).toBe(false);
});
// FIXME: Should throw?
test("isInlineEnd returns false when passed node is not an inline", () => {
const inline = document.createElement("div");
expect(isInlineEnd(inline, 0)).toBe(false);
expect(isInlineEnd(inline, "Hello, World!".length)).toBe(false);
});
test("isInlineEnd returns if we're in the end of an inline", () => {
const inline = createInline(new Text("Hello, World!"));
expect(isInlineEnd(inline, 0)).toBe(false);
expect(isInlineEnd(inline, "Hello, World!".length)).toBe(true);
});
test("getInline ", () => {
expect(getInline(null)).toBe(null);
});
test("getInlineLength throws when the passed node is not an inline", () => {
const inline = document.createElement("div");
expect(() => getInlineLength(inline)).toThrowError("Invalid inline");
});
test("getInlineLength returns the length of the inline content", () => {
const inline = createInline(new Text("Hello, World!"));
expect(getInlineLength(inline)).toBe(13);
});
test("getInlineLength should return 0 when the inline content is a <br>", () => {
const emptyInline = createEmptyInline();
expect(getInlineLength(emptyInline)).toBe(0);
});
test("splitInline returns a new inline from the splitted inline", () => {
const inline = createInline(new Text("Hello, World!"));
const newInline = splitInline(inline, 5);
expect(newInline).toBeInstanceOf(HTMLSpanElement);
expect(newInline.firstChild).toBeInstanceOf(Text);
expect(newInline.textContent).toBe(", World!");
expect(newInline.dataset.itype).toBe(TYPE);
expect(newInline.nodeName).toBe(TAG);
});
});

View file

@ -0,0 +1,28 @@
/**
* 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
*/
export const TAG = "BR";
/**
* Creates a new line break.
*
* @returns {HTMLBRElement}
*/
export function createLineBreak() {
return document.createElement(TAG);
}
/**
* Returns true if the passed node is a line break.
*
* @param {Node} node
* @returns {boolean}
*/
export function isLineBreak(node) {
return node && node.nodeType === Node.ELEMENT_NODE && node.nodeName === TAG;
}

View file

@ -0,0 +1,11 @@
import { describe, expect, test } from "vitest";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("LineBreak", () => {
test("createLineBreak should return a <br> element", () => {
const br = createLineBreak();
expect(br.nodeType).toBe(Node.ELEMENT_NODE);
expect(br.nodeName).toBe("BR");
});
});

View file

@ -0,0 +1,258 @@
/**
* 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 {
createRandomId,
createElement,
isElement,
isOffsetAtStart,
isOffsetAtEnd,
} from "./Element.js";
import {
isInline,
isLikeInline,
getInline,
getInlinesFrom,
createInline,
createEmptyInline,
isInlineEnd,
splitInline,
} from "./Inline.js";
import { createLineBreak, isLineBreak } from "./LineBreak.js";
import { setStyles } from "./Style.js";
export const TAG = "DIV";
export const TYPE = "paragraph";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [
["--typography-ref-id"],
["--typography-ref-file"],
["--font-id"],
["--font-variant-id"],
["--fills"],
["font-variant"],
["font-family"],
["font-size", "px"],
["font-weight"],
["font-style"],
["line-height"],
["letter-spacing", "px"],
["text-decoration"],
["text-transform"],
["text-align"],
["direction"]
];
/**
* FIXME: This is a fix for Chrome that removes the
* current inline when the last character is deleted
* in `insertCompositionText`.
*
* @param {*} node
*/
export function fixParagraph(node) {
if (!isParagraph(node) || !isLineBreak(node.firstChild)) {
return;
}
const br = createLineBreak();
node.replaceChildren(
createInline(br)
);
return br;
}
/**
* Returns true if the passed node behaves like a paragraph.
*
* NOTE: This is mainly used in paste operations. Every element node
* it's going to be treated as paragraph it
*
* @param {Node} element
* @returns {boolean}
*/
export function isLikeParagraph(element) {
return !isLikeInline(element);
}
/**
* Returns true if we have an empty paragraph.
*
* @param {Node} element
* @returns {boolean}
*/
export function isEmptyParagraph(element) {
if (!isParagraph(element)) throw new TypeError("Invalid paragraph");
const inline = element.firstChild;
if (!isInline(inline)) throw new TypeError("Invalid inline");
return isLineBreak(inline.firstChild);
}
/**
* Returns true if passed node is a paragraph.
*
* @param {Node} node
* @returns {boolean}
*/
export function isParagraph(node) {
if (!node) return false;
if (!isElement(node, TAG)) return false;
if (node.dataset.itype !== TYPE) return false;
return true;
}
/**
* Creates a new paragraph.
*
* @param {Array<HTMLDivElement>} inlines
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} [attrs]
* @returns {HTMLDivElement}
*/
export function createParagraph(inlines, styles, attrs) {
if (inlines && (!Array.isArray(inlines) || !inlines.every(isInline)))
throw new TypeError("Invalid paragraph children");
return createElement(TAG, {
attributes: { id: createRandomId(), ...attrs },
data: { itype: TYPE },
styles: styles,
allowedStyles: STYLES,
children: inlines,
});
}
/**
* Returns a new empty paragraph
*
* @param {Object.<string, *>} styles
* @returns {HTMLDivElement}
*/
export function createEmptyParagraph(styles) {
return createParagraph([
createEmptyInline(styles)
], styles);
}
/**
* Sets the paragraph styles.
*
* @param {HTMLDivElement} element
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLDivElement}
*/
export function setParagraphStyles(element, styles) {
return setStyles(element, STYLES, styles);
}
/**
* Gets the closest paragraph from a node.
*
* @param {Text|HTMLBRElement} node
* @returns {HTMLElement|null}
*/
export function getParagraph(node) {
if (!node) return null;
if (isParagraph(node)) return node;
if (node.nodeType === Node.TEXT_NODE
|| isLineBreak(node)) {
const paragraph = node?.parentElement?.parentElement;
if (!paragraph) {
return null;
}
if (!isParagraph(paragraph)) {
return null;
}
return paragraph;
}
return node.closest(QUERY);
}
/**
* Returns if the specified node and offset represents
* the start of the paragraph.
*
* @param {Text|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isParagraphStart(node, offset) {
const paragraph = getParagraph(node);
if (!paragraph) throw new Error("Can't find the paragraph");
const inline = getInline(node);
if (!inline) throw new Error("Can't find the inline");
return (
paragraph.firstElementChild === inline &&
isOffsetAtStart(inline.firstChild, offset)
);
}
/**
* Returns if the specified node and offset represents
* the end of the paragraph.
*
* @param {Text|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isParagraphEnd(node, offset) {
const paragraph = getParagraph(node);
if (!paragraph) throw new Error("Cannot find the paragraph");
const inline = getInline(node);
if (!inline) throw new Error("Cannot find the inline");
return (
paragraph.lastElementChild === inline &&
isOffsetAtEnd(inline.firstChild, offset)
);
}
/**
* Splits a paragraph.
*
* @param {HTMLDivElement} paragraph
* @param {HTMLSpanElement} inline
* @param {number} offset
*/
export function splitParagraph(paragraph, inline, offset) {
const style = paragraph.style;
if (isInlineEnd(inline, offset)) {
const newParagraph = createParagraph(getInlinesFrom(inline), style);
return newParagraph;
}
const newInline = splitInline(inline, offset);
const newParagraph = createParagraph([newInline], style);
return newParagraph;
}
/**
* Splits a paragraph at a specified child node index
*
* @param {HTMLDivElement} paragraph
* @param {number} startIndex
*/
export function splitParagraphAtNode(paragraph, startIndex) {
const style = paragraph.style;
const newParagraph = createParagraph(null, style);
const newInlines = [];
for (let index = startIndex; index < paragraph.children.length; index++) {
newInlines.push(paragraph.children.item(index));
}
newParagraph.append(...newInlines);
return newParagraph;
}
/**
* Merges two paragraphs.
*
* @param {HTMLDivElement} a
* @param {HTMLDivElement} b
* @returns {HTMLDivElement}
*/
export function mergeParagraphs(a, b) {
a.append(...b.children);
b.remove();
return a;
}

View file

@ -0,0 +1,171 @@
import { describe, test, expect } from "vitest";
import {
createEmptyParagraph,
createParagraph,
getParagraph,
isLikeParagraph,
isParagraph,
isParagraphStart,
isParagraphEnd,
TAG,
TYPE,
splitParagraph,
splitParagraphAtNode,
isEmptyParagraph,
} from "./Paragraph.js";
import { createInline, isInline } from "./Inline.js";
/* @vitest-environment jsdom */
describe("Paragraph", () => {
test("createParagraph should throw when passed invalid children", () => {
expect(() => createParagraph(["Whatever"])).toThrowError(
"Invalid paragraph children",
);
});
test("createEmptyParagraph should create a new empty paragraph", () => {
const emptyParagraph = createEmptyParagraph();
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isInline(emptyParagraph.firstChild)).toBe(true);
});
test("isParagraph should return true when the passed node is a paragraph", () => {
expect(isParagraph(null)).toBe(false);
expect(isParagraph(document.createElement("div"))).toBe(false);
expect(isParagraph(document.createElement("h1"))).toBe(false);
expect(isParagraph(createEmptyParagraph())).toBe(true);
expect(
isParagraph(createParagraph([createInline(new Text("Hello, World!"))])),
).toBe(true);
});
test("isLikeParagraph should return true when node looks like a paragraph", () => {
const p = document.createElement("p");
expect(isLikeParagraph(p)).toBe(true);
const div = document.createElement("div");
expect(isLikeParagraph(div)).toBe(true);
const h1 = document.createElement("h1");
expect(isLikeParagraph(h1)).toBe(true);
const h2 = document.createElement("h2");
expect(isLikeParagraph(h2)).toBe(true);
const h3 = document.createElement("h3");
expect(isLikeParagraph(h3)).toBe(true);
const h4 = document.createElement("h4");
expect(isLikeParagraph(h4)).toBe(true);
const h5 = document.createElement("h5");
expect(isLikeParagraph(h5)).toBe(true);
const h6 = document.createElement("h6");
expect(isLikeParagraph(h6)).toBe(true);
});
test("getParagraph should return the closest paragraph of the passed node", () => {
const text = new Text("Hello, World!");
const inline = createInline(text);
const paragraph = createParagraph([inline]);
expect(getParagraph(text)).toBe(paragraph);
});
test("getParagraph should return null if there aren't closer paragraph nodes", () => {
const text = new Text("Hello, World!");
const whatever = document.createElement("div");
whatever.appendChild(text);
expect(getParagraph(text)).toBe(null);
});
test("isParagraphStart should return true on an empty paragraph", () => {
const paragraph = createEmptyParagraph();
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
});
test("isParagraphStart should return true on a paragraph", () => {
const paragraph = createParagraph([
createInline(new Text("Hello, World!")),
]);
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
});
test("isParagraphEnd should return true on an empty paragraph", () => {
const paragraph = createEmptyParagraph();
expect(isParagraphEnd(paragraph.firstChild.firstChild, 0)).toBe(true);
});
test("isParagraphEnd should return true on a paragraph", () => {
const paragraph = createParagraph([
createInline(new Text("Hello, World!")),
]);
expect(isParagraphEnd(paragraph.firstChild.firstChild, 13)).toBe(true);
});
test("splitParagraph should split a paragraph", () => {
const inline = createInline(new Text("Hello, World!"));
const paragraph = createParagraph([inline]);
const newParagraph = splitParagraph(paragraph, inline, 6);
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG);
expect(newParagraph.dataset.itype).toBe(TYPE);
expect(newParagraph.firstElementChild.textContent).toBe(" World!");
});
test("splitParagraphAtNode should split a paragraph at a specified node", () => {
const helloInline = createInline(new Text("Hello, "));
const worldInline = createInline(new Text("World"));
const exclInline = createInline(new Text("!"));
const paragraph = createParagraph([helloInline, worldInline, exclInline]);
const newParagraph = splitParagraphAtNode(paragraph, 1);
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG);
expect(newParagraph.dataset.itype).toBe(TYPE);
expect(newParagraph.children.length).toBe(2);
expect(newParagraph.textContent).toBe("World!");
});
test("isLikeParagraph should return true if the element it's not an inline element", () => {
const span = document.createElement("span");
const a = document.createElement("a");
const br = document.createElement("br");
const i = document.createElement("span");
const u = document.createElement("span");
const div = document.createElement("div");
const blockquote = document.createElement("blockquote");
const table = document.createElement("table");
expect(isLikeParagraph(span)).toBe(false);
expect(isLikeParagraph(a)).toBe(false);
expect(isLikeParagraph(br)).toBe(false);
expect(isLikeParagraph(i)).toBe(false);
expect(isLikeParagraph(u)).toBe(false);
expect(isLikeParagraph(div)).toBe(true);
expect(isLikeParagraph(blockquote)).toBe(true);
expect(isLikeParagraph(table)).toBe(true);
});
test("isEmptyParagraph should return true if the paragraph is empty", () => {
expect(() => {
isEmptyParagraph(document.createElement("svg"));
}).toThrowError("Invalid paragraph");
expect(() => {
const paragraph = document.createElement("div");
paragraph.dataset.itype = "paragraph";
paragraph.appendChild(document.createElement("svg"));
isEmptyParagraph(paragraph);
}).toThrowError("Invalid inline");
const lineBreak = document.createElement("br");
const emptyInline = document.createElement("span");
emptyInline.dataset.itype = "inline";
emptyInline.appendChild(lineBreak);
const emptyParagraph = document.createElement("div");
emptyParagraph.dataset.itype = "paragraph";
emptyParagraph.appendChild(emptyInline);
expect(isEmptyParagraph(emptyParagraph)).toBe(true);
const nonEmptyInline = document.createElement("span");
nonEmptyInline.dataset.itype = "inline";
nonEmptyInline.appendChild(new Text("Not empty!"));
const nonEmptyParagraph = document.createElement("div");
nonEmptyParagraph.dataset.itype = "paragraph";
nonEmptyParagraph.appendChild(nonEmptyInline);
expect(isEmptyParagraph(nonEmptyParagraph)).toBe(false);
});
});

View file

@ -0,0 +1,70 @@
/**
* 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 { createRandomId, createElement, isElement } from "./Element.js";
import { createEmptyParagraph, isParagraph } from "./Paragraph.js";
import { setStyles } from "./Style.js";
export const TAG = "DIV";
export const TYPE = "root";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [["--vertical-align"]];
/**
* Returns true if passed node is a root.
*
* @param {Node} node
* @returns {boolean}
*/
export function isRoot(node) {
if (!node) return false;
if (!isElement(node, TAG)) return false;
if (node.dataset.itype !== TYPE) return false;
return true;
}
/**
* Create a new root element
*
* @param {Array<HTMLDivElement>} paragraphs
* @param {Object.<string, *>|CSSStyleDeclaration} styles,
* @param {Object.<string, *>} [attrs]
* @returns {HTMLDivElement}
*/
export function createRoot(paragraphs, styles, attrs) {
if (!Array.isArray(paragraphs) || !paragraphs.every(isParagraph))
throw new TypeError("Invalid root children");
return createElement(TAG, {
attributes: { id: createRandomId(), ...attrs },
data: { itype: TYPE },
styles: styles,
allowedStyles: STYLES,
children: paragraphs,
});
}
/**
* Creates a new empty root element
*
* @param {Object.<string,*>|CSSStyleDeclaration} styles
*/
export function createEmptyRoot(styles) {
return createRoot([createEmptyParagraph(styles)], styles);
}
/**
* Sets the root styles.
*
* @param {HTMLDivElement} element
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLDivElement}
*/
export function setRootStyles(element, styles) {
return setStyles(element, STYLES, styles);
}

View file

@ -0,0 +1,35 @@
import { describe, test, expect } from "vitest";
import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from "./Root.js";
/* @vitest-environment jsdom */
describe("Root", () => {
test("createRoot should throw when passed invalid children", () => {
expect(() => createRoot(["Whatever"])).toThrowError(
"Invalid root children",
);
});
test("createEmptyRoot should create a new root with an empty paragraph", () => {
const emptyRoot = createEmptyRoot();
expect(emptyRoot).toBeInstanceOf(HTMLDivElement);
expect(emptyRoot.nodeName).toBe(TAG);
expect(emptyRoot.dataset.itype).toBe(TYPE);
expect(emptyRoot.firstChild).toBeInstanceOf(HTMLDivElement);
expect(emptyRoot.firstChild.firstChild).toBeInstanceOf(HTMLSpanElement);
expect(emptyRoot.firstChild.firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
});
test("setRootStyles should apply only the styles of root to the root", () => {
const emptyRoot = createEmptyRoot();
setRootStyles(emptyRoot, {
["--vertical-align"]: "top",
["font-size"]: "25px",
});
expect(emptyRoot.style.getPropertyValue("--vertical-align")).toBe("top");
// We expect this style to be empty because we don't apply it
// to the root.
expect(emptyRoot.style.getPropertyValue("font-size")).toBe("");
});
});

View file

@ -0,0 +1,329 @@
/**
* 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 { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px";
const DEFAULT_LINE_HEIGHT = "1.2";
/**
* Merges two style declarations. `source` -> `target`.
*
* @param {CSSStyleDeclaration} target
* @param {CSSStyleDeclaration} source
* @returns {CSSStyleDeclaration}
*/
export function mergeStyleDeclarations(target, source) {
// This is better but it doesn't work in JSDOM
// for (const styleName of source) {
for (let index = 0; index < source.length; index++) {
const styleName = source.item(index);
target.setProperty(styleName, source.getPropertyValue(styleName));
}
return target
}
/**
* Resets the properties of a style declaration.
*
* @param {CSSStyleDeclaration} styleDeclaration
* @returns {CSSStyleDeclaration}
*/
function resetStyleDeclaration(styleDeclaration) {
for (let index = 0; index < styleDeclaration.length; index++) {
const styleName = styleDeclaration.item(index);
styleDeclaration.removeProperty(styleName);
}
return styleDeclaration
}
/**
* An inert element that only keeps the style
* declaration used for merging other styleDeclarations.
*
* @type {HTMLDivElement|null}
*/
let inertElement = null
/**
* Resets the style declaration of the inert
* element.
*/
function resetInertElement() {
if (!inertElement) throw new Error('Invalid inert element');
resetStyleDeclaration(inertElement.style);
return inertElement;
}
/**
* Returns an instance of a <div> element used
* to keep style declarations.
*
* @returns {HTMLDivElement}
*/
function getInertElement() {
if (!inertElement) {
inertElement = document.createElement("div");
return inertElement;
}
resetInertElement();
return inertElement;
}
/**
* Computes the styles of an element the same way `window.getComputedStyle` does.
*
* @param {Element} element
* @returns {CSSStyleDeclaration}
*/
export function getComputedStyle(element) {
const inertElement = getInertElement();
let currentElement = element;
while (currentElement) {
// This is better but it doesn't work in JSDOM.
// for (const styleName of currentElement.style) {
for (let index = 0; index < currentElement.style.length; index++) {
const styleName = currentElement.style.item(index);
const currentValue = inertElement.style.getPropertyValue(styleName);
if (currentValue) {
const priority = currentElement.style.getPropertyPriority(styleName);
if (priority === "important") {
const newValue = currentElement.style.getPropertyValue(styleName);
inertElement.style.setProperty(styleName, newValue);
}
} else {
inertElement.style.setProperty(
styleName,
currentElement.style.getPropertyValue(styleName)
);
}
}
currentElement = currentElement.parentElement;
}
return inertElement.style;
}
/**
* Normalizes style declaration.
*
* TODO: I think that this also needs to remove some "conflicting"
* CSS properties like `font-family` or some CSS variables.
*
* @param {Node} node
* @param {CSSStyleDeclaration} styleDefaults
* @returns {CSSStyleDeclaration}
*/
export function normalizeStyles(node, styleDefaults) {
const styleDeclaration = mergeStyleDeclarations(
styleDefaults,
getComputedStyle(node.parentElement)
);
// If there's a color property, we should convert it to
// a --fills CSS variable property.
const fills = styleDeclaration.getPropertyValue("--fills");
const color = styleDeclaration.getPropertyValue("color");
if (color && !fills) {
styleDeclaration.removeProperty("color");
styleDeclaration.setProperty("--fills", getFills(color));
}
// If there's a font-family property and not a --font-id, then
// we remove the font-family because it will not work.
const fontFamily = styleDeclaration.getPropertyValue("font-family");
const fontId = styleDeclaration.getPropertyPriority("--font-id");
if (fontFamily && !fontId) {
styleDeclaration.removeProperty("font-family");
}
const fontSize = styleDeclaration.getPropertyValue("font-size");
if (!fontSize || fontSize === "0px") {
styleDeclaration.setProperty("font-size", DEFAULT_FONT_SIZE);
}
const lineHeight = styleDeclaration.getPropertyValue("line-height");
if (!lineHeight || lineHeight === "") {
styleDeclaration.setProperty("line-height", DEFAULT_LINE_HEIGHT);
}
return styleDeclaration
}
/**
* Sets a single style property value of an element.
*
* @param {HTMLElement} element
* @param {string} styleName
* @param {*} styleValue
* @param {string} [styleUnit]
* @returns {HTMLElement}
*/
export function setStyle(element, styleName, styleValue, styleUnit) {
if (
styleName.startsWith("--") &&
typeof styleValue !== "string" &&
typeof styleValue !== "number"
) {
if (styleName === "--fills" && styleValue === null) debugger;
element.style.setProperty(styleName, JSON.stringify(styleValue));
} else {
element.style.setProperty(styleName, styleValue + (styleUnit ?? ""));
}
return element;
}
/**
* Returns the value of a style from a declaration.
*
* @param {CSSStyleDeclaration} style
* @param {string} styleName
* @param {string|undefined} [styleUnit]
* @returns {*}
*/
export function getStyleFromDeclaration(style, styleName, styleUnit) {
if (styleName.startsWith("--")) {
return style.getPropertyValue(styleName);
}
const styleValue = style.getPropertyValue(styleName);
if (styleValue.endsWith(styleUnit)) {
return styleValue.slice(0, -styleUnit.length);
}
return styleValue;
}
/**
* Returns the value of a style.
*
* @param {HTMLElement} element
* @param {string} styleName
* @param {string|undefined} [styleUnit]
* @returns {*}
*/
export function getStyle(element, styleName, styleUnit) {
return getStyleFromDeclaration(element.style, styleName, styleUnit);
}
/**
* Sets the styles of an element using an object and a list of
* allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]>} allowedStyles
* @param {Object.<string, *>} styleObject
* @returns {HTMLElement}
*/
export function setStylesFromObject(element, allowedStyles, styleObject) {
for (const [styleName, styleUnit] of allowedStyles) {
if (!(styleName in styleObject)) {
continue;
}
const styleValue = styleObject[styleName];
if (styleValue) {
setStyle(element, styleName, styleValue, styleUnit);
}
}
return element;
}
/**
* Sets the styles of an element using a CSS Style Declaration and a list
* of allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]>} allowedStyles
* @param {CSSStyleDeclaration} styleDeclaration
* @returns {HTMLElement}
*/
export function setStylesFromDeclaration(
element,
allowedStyles,
styleDeclaration
) {
for (const [styleName, styleUnit] of allowedStyles) {
const styleValue = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit);
if (styleValue) {
setStyle(element, styleName, styleValue, styleUnit);
}
}
return element;
}
/**
* Sets the styles of an element using an Object or a CSS Style Declaration and
* a list of allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]} allowedStyles
* @param {Object.<string,*>|CSSStyleDeclaration} styleObjectOrDeclaration
* @returns {HTMLElement}
*/
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
return setStylesFromDeclaration(
element,
allowedStyles,
styleObjectOrDeclaration
);
}
return setStylesFromObject(element, allowedStyles, styleObjectOrDeclaration);
}
/**
* Gets the styles of an element using a list of allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]} allowedStyles
* @returns {Object.<string, *>}
*/
export function getStyles(element, allowedStyles) {
const styleObject = {};
for (const [styleName, styleUnit] of allowedStyles) {
const styleValue = getStyle(element, styleName, styleUnit);
if (styleValue) {
styleObject[styleName] = styleValue;
}
}
return styleObject;
}
/**
* Returns a series of merged styles.
*
* @param {Array<[string,?string]} allowedStyles
* @param {CSSStyleDeclaration} styleDeclaration
* @param {Object.<string,*>} newStyles
* @returns {Object.<string,*>}
*/
export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
const mergedStyles = {};
for (const [styleName, styleUnit] of allowedStyles) {
if (styleName in newStyles) {
mergedStyles[styleName] = newStyles[styleName];
} else {
mergedStyles[styleName] = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit);
}
}
return mergedStyles;
}
/**
* Returns true if the specified style declaration has a display block.
*
* @param {CSSStyleDeclaration} style
* @returns {boolean}
*/
export function isDisplayBlock(style) {
return style.display === "block";
}
/**
* Returns true if the specified style declaration has a display inline
* or inline-block.
*
* @param {CSSStyleDeclaration} style
* @returns {boolean}
*/
export function isDisplayInline(style) {
return style.display === "inline" || style.display === "inline-block";
}

View file

@ -0,0 +1,82 @@
import { describe, test, expect, vi } from "vitest";
import {
getStyles,
isDisplayBlock,
isDisplayInline,
setStyle,
setStyles,
} from "./Style.js";
/* @vitest-environment jsdom */
describe("Style", () => {
test("setStyle should apply a style to an element", () => {
const element = document.createElement("div");
setStyle(element, "display", "none");
expect(element.style.display).toBe("none");
});
test("setStyles should apply multiple styles to an element using an Object", () => {
const element = document.createElement("div");
setStyles(element, [["display"]], {
"text-decoration": "none",
"font-size": "32px",
display: "none",
});
expect(element.style.display).toBe("none");
expect(element.style.fontSize).toBe("");
expect(element.style.textDecoration).toBe("");
});
test("setStyles should apply multiple styles to an element using a CSSStyleDeclaration", () => {
const a = document.createElement("div");
setStyles(a, [["display"]], {
display: "none",
});
expect(a.style.display).toBe("none");
expect(a.style.fontSize).toBe("");
expect(a.style.textDecoration).toBe("");
const b = document.createElement("div");
setStyles(b, [["display"]], a.style);
expect(b.style.display).toBe("none");
expect(b.style.fontSize).toBe("");
expect(b.style.textDecoration).toBe("");
});
test("getStyles should retrieve a list of allowed styles", () => {
const element = document.createElement("div");
element.style.display = "block";
element.style.textDecoration = "underline";
element.style.fontSize = "32px";
const textDecorationStyles = getStyles(element, [["text-decoration"]]);
expect(textDecorationStyles).toStrictEqual({
"text-decoration": "underline",
});
const displayStyles = getStyles(element, [["display"]]);
expect(displayStyles).toStrictEqual({
display: "block",
});
const fontSizeStyles = getStyles(element, [["font-size", "px"]]);
expect(fontSizeStyles).toStrictEqual({
"font-size": "32",
});
});
test("isDisplayBlock should return true if display is 'block'", () => {
const div = document.createElement("div");
div.style.display = "block";
expect(isDisplayBlock(div.style)).toBe(true);
const span = document.createElement("span");
span.style.display = "inline";
expect(isDisplayBlock(span)).toBe(false);
});
test("isDisplayInline should return true if display is 'inline'", () => {
const span = document.createElement("span");
span.style.display = "inline";
expect(isDisplayInline(span.style)).toBe(true);
const div = document.createElement("div");
div.style.display = "block";
expect(isDisplayInline(div)).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
/**
* 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 { isInline } from "./Inline.js";
import { isLineBreak } from "./LineBreak.js";
import { isParagraph } from "./Paragraph.js";
import { isRoot } from "./Root.js";
/**
* Returns true if the node is "like"
* text, this means that it is a Text
* node or a <br> element.
*
* @param {Node} node
* @returns {boolean}
*/
export function isTextNode(node) {
if (!node) throw new TypeError("Invalid text node");
return node.nodeType === Node.TEXT_NODE
|| isLineBreak(node);
}
/**
* Returns true if the text node is empty.
*
* @param {Node} node
* @returns {boolean}
*/
export function isEmptyTextNode(node) {
return node.nodeType === Node.TEXT_NODE
&& node.nodeValue === "";
}
/**
* Returns the content length of the
* node.
*
* @param {Node} node
* @returns {number}
*/
export function getTextNodeLength(node) {
if (!node) throw new TypeError("Invalid text node");
if (isLineBreak(node)) return 0;
return node.nodeValue.length;
}
/**
* Gets the closest text node.
*
* @param {Node} node
* @returns {Node}
*/
export function getClosestTextNode(node) {
if (isTextNode(node)) return node;
if (isInline(node)) return node.firstChild;
if (isParagraph(node)) return node.firstChild.firstChild;
if (isRoot(node)) return node.firstChild.firstChild.firstChild;
throw new Error("Cannot find a text node");
}

View file

@ -0,0 +1,28 @@
import { describe, test, expect } from "vitest";
import { isTextNode, getTextNodeLength } from "./TextNode.js";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("TextNode", () => {
test("isTextNode should return true when the passed node is a Text", () => {
expect(isTextNode(new Text("Hello, World!"))).toBe(true);
expect(isTextNode(Infinity)).toBe(false);
expect(isTextNode(true)).toBe(false);
expect(isTextNode("hola")).toBe(false);
expect(isTextNode({})).toBe(false);
expect(isTextNode([])).toBe(false);
expect(() => isTextNode(undefined)).toThrowError("Invalid text node");
expect(() => isTextNode(null)).toThrowError("Invalid text node");
expect(() => isTextNode(0)).toThrowError("Invalid text node");
});
test("getTextNodeLength should return the length of the text node or 0 if it is a <br>", () => {
expect(getTextNodeLength(new Text("Hello, World!"))).toBe(13);
expect(getTextNodeLength(createLineBreak())).toBe(0);
expect(() => getTextNodeLength(undefined)).toThrowError(
"Invalid text node",
);
expect(() => getTextNodeLength(null)).toThrowError("Invalid text node");
expect(() => getTextNodeLength(0)).toThrowError("Invalid text node");
});
});

View file

@ -0,0 +1,250 @@
/**
* 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
*/
/**
* Iterator direction.
*
* @enum {number}
*/
export const TextNodeIteratorDirection = {
FORWARD: 1,
BACKWARD: 0,
};
/**
* TextNodeIterator
*/
export class TextNodeIterator {
/**
* Returns if a specific node is a text node.
*
* @param {Node} node
* @returns {boolean}
*/
static isTextNode(node) {
return (
node.nodeType === Node.TEXT_NODE ||
(node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR")
);
}
/**
* Returns if a specific node is a container node.
*
* @param {Node} node
* @returns {boolean}
*/
static isContainerNode(node) {
return node.nodeType === Node.ELEMENT_NODE && node.nodeName !== "BR";
}
/**
* Finds a node from an initial node and down the tree.
*
* @param {Node} startNode
* @param {Node} rootNode
* @param {Set<Node>} skipNodes
* @param {number} direction
* @returns {Node}
*/
static findDown(
startNode,
rootNode,
skipNodes = new Set(),
direction = TextNodeIteratorDirection.FORWARD
) {
if (startNode === rootNode) {
return TextNodeIterator.findDown(
direction === TextNodeIteratorDirection.FORWARD
? startNode.firstChild
: startNode.lastChild,
rootNode,
skipNodes,
direction
);
}
// NOTE: This should not use the SafeGuard
// module.
let safeGuard = Date.now();
let currentNode = startNode;
while (currentNode) {
if (Date.now() - safeGuard >= 1000) {
throw new Error("Iteration timeout");
}
if (skipNodes.has(currentNode)) {
currentNode =
direction === TextNodeIteratorDirection.FORWARD
? currentNode.nextSibling
: currentNode.previousSibling;
continue;
}
if (TextNodeIterator.isTextNode(currentNode)) {
return currentNode;
} else if (TextNodeIterator.isContainerNode(currentNode)) {
return TextNodeIterator.findDown(
direction === TextNodeIteratorDirection.FORWARD
? currentNode.firstChild
: currentNode.lastChild,
rootNode,
skipNodes,
direction
);
}
currentNode =
direction === TextNodeIteratorDirection.FORWARD
? currentNode.nextSibling
: currentNode.previousSibling;
}
return null;
}
/**
* Finds a node from an initial node and up the tree.
*
* @param {Node} startNode
* @param {Node} rootNode
* @param {Set} backTrack
* @param {number} direction
* @returns {Node}
*/
static findUp(
startNode,
rootNode,
backTrack = new Set(),
direction = TextNodeIteratorDirection.FORWARD
) {
backTrack.add(startNode);
if (TextNodeIterator.isTextNode(startNode)) {
return TextNodeIterator.findUp(
startNode.parentNode,
rootNode,
backTrack,
direction
);
} else if (TextNodeIterator.isContainerNode(startNode)) {
const found = TextNodeIterator.findDown(
startNode,
rootNode,
backTrack,
direction
);
if (found) {
return found;
}
if (startNode !== rootNode) {
return TextNodeIterator.findUp(
startNode.parentNode,
rootNode,
backTrack,
direction
);
}
}
return null;
}
/**
* This is the root text node.
*
* @type {HTMLElement}
*/
#rootNode = null;
/**
* This is the current text node.
*
* @type {Text|null}
*/
#currentNode = null;
/**
* Constructor
*
* @param {HTMLElement} rootNode
*/
constructor(rootNode) {
if (!(rootNode instanceof HTMLElement)) {
throw new TypeError("Invalid root node");
}
this.#rootNode = rootNode;
this.#currentNode = TextNodeIterator.findDown(rootNode, rootNode);
}
/**
* Current node we're into.
*
* @type {TextNode|HTMLBRElement}
*/
get currentNode() {
return this.#currentNode;
}
set currentNode(newCurrentNode) {
const isContained =
(newCurrentNode.compareDocumentPosition(this.#rootNode) &
Node.DOCUMENT_POSITION_CONTAINS) ===
Node.DOCUMENT_POSITION_CONTAINS;
if (
!(newCurrentNode instanceof Node) ||
!TextNodeIterator.isTextNode(newCurrentNode) ||
!isContained
) {
throw new TypeError("Invalid new current node");
}
this.#currentNode = newCurrentNode;
}
/**
* Returns the next Text node or <br> element or null if there are.
*
* @returns {Text|HTMLBRElement}
*/
nextNode() {
if (!this.#currentNode) return null;
const nextNode = TextNodeIterator.findUp(
this.#currentNode,
this.#rootNode,
new Set(),
TextNodeIteratorDirection.FORWARD
);
if (!nextNode) {
return null;
}
this.#currentNode = nextNode;
return this.#currentNode;
}
/**
* Returns the previous Text node or <br> element or null.
*
* @returns {Text|HTMLBRElement}
*/
previousNode() {
if (!this.#currentNode) return null;
const previousNode = TextNodeIterator.findUp(
this.#currentNode,
this.#rootNode,
new Set(),
TextNodeIteratorDirection.BACKWARD
);
if (!previousNode) {
return null;
}
this.#currentNode = previousNode;
return this.#currentNode;
}
}
export default TextNodeIterator;

View file

@ -0,0 +1,73 @@
import { describe, test, expect } from "vitest";
import TextNodeIterator from "./TextNodeIterator.js";
import { createInline } from "./Inline.js";
import { createParagraph } from "./Paragraph.js";
import { createRoot } from "./Root.js";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("TextNodeIterator", () => {
test("Create a new TextNodeIterator with an invalid root should throw", () => {
expect(() => new TextNodeIterator(null)).toThrowError("Invalid root node");
expect(() => new TextNodeIterator(Infinity)).toThrowError(
"Invalid root node",
);
expect(() => new TextNodeIterator(1)).toThrowError("Invalid root node");
expect(() => new TextNodeIterator("hola")).toThrowError(
"Invalid root node",
);
});
test("Create a new TextNodeIterator and iterate only over text nodes", () => {
const rootNode = createRoot([
createParagraph([
createInline(new Text("Hello, ")),
createInline(new Text("World!")),
createInline(new Text("Whatever")),
]),
createParagraph([createInline(createLineBreak())]),
createParagraph([
createInline(new Text("This is a ")),
createInline(new Text("test")),
]),
createParagraph([createInline(new Text("Hi!"))]),
]);
const textNodeIterator = new TextNodeIterator(rootNode);
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE);
expect(textNodeIterator.currentNode.nodeName).toBe("BR");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("This is a ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("test");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Hi!");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("test");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("This is a ");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE);
expect(textNodeIterator.currentNode.nodeName).toBe("BR");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
});
});

View file

@ -0,0 +1,92 @@
/**
* 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
*/
/**
* Change controller is responsible of notifying when a change happens.
*/
export class ChangeController extends EventTarget {
/**
* Keeps the timeout id.
*
* @type {number}
*/
#timeout = null;
/**
* Keeps the time at which we're going to
* call the debounced change calls.
*
* @type {number}
*/
#time = 1000;
/**
* Keeps if we have some pending changes or not.
*
* @type {boolean}
*/
#hasPendingChanges = false;
/**
* Constructor
*
* @param {number} [time=500]
*/
constructor(time = 500) {
super()
if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) {
throw new TypeError("Invalid time");
}
this.#time = time ?? 500;
}
/**
* Indicates that there are some pending changes.
*
* @type {boolean}
*/
get hasPendingChanges() {
return this.#hasPendingChanges;
}
#onTimeout = () => {
this.dispatchEvent(new Event("change"));
};
/**
* Tells the ChangeController that a change has been made
* but that you need to delay the notification (and debounce)
* for sometime.
*/
notifyDebounced() {
this.#hasPendingChanges = true;
clearTimeout(this.#timeout);
this.#timeout = setTimeout(this.#onTimeout, this.#time);
}
/**
* Tells the ChangeController that a change should be notified
* immediately.
*/
notifyImmediately() {
clearTimeout(this.#timeout);
this.#onTimeout();
}
/**
* Disposes the referenced resources.
*/
dispose() {
if (this.hasPendingChanges) {
this.notifyImmediately();
}
clearTimeout(this.#timeout);
}
}
export default ChangeController;

View file

@ -0,0 +1,36 @@
import { expect, describe, test, vi } from "vitest";
import ChangeController from "./ChangeController.js";
describe("ChangeController", () => {
test("Creating a ChangeController without a valid time should throw", () => {
expect(() => new ChangeController(Infinity)).toThrowError("Invalid time");
});
test("A ChangeController should dispatch an event when `notifyImmediately` is called", () => {
const changeListener = vi.fn();
const changeController = new ChangeController(10);
changeController.addEventListener("change", changeListener);
changeController.notifyImmediately();
expect(changeController.hasPendingChanges).toBe(false);
expect(changeListener).toBeCalled(1);
});
test("A ChangeController should dispatch an event when `notifyDebounced` is called", async () => {
return new Promise((resolve) => {
const changeController = new ChangeController(10);
changeController.addEventListener("change", () => resolve());
changeController.notifyDebounced();
expect(changeController.hasPendingChanges).toBe(true);
});
});
test("A ChangeController should dispatch an event when `notifyDebounced` is called and disposed is called right after", async () => {
return new Promise((resolve) => {
const changeController = new ChangeController(10);
changeController.addEventListener("change", () => resolve());
changeController.notifyDebounced();
expect(changeController.hasPendingChanges).toBe(true);
changeController.dispose();
});
});
});

View file

@ -0,0 +1,34 @@
/**
* Max. amount of time we should allow.
*
* @type {number}
*/
const SAFE_GUARD_TIME = 1000;
/**
* Time at which the safeguard started.
*
* @type {number}
*/
let startTime = Date.now();
/**
* Marks the start of the safeguard.
*/
export function start() {
startTime = Date.now();
}
/**
* Checks if the safeguard should throw.
*/
export function update() {
if (Date.now - startTime >= SAFE_GUARD_TIME) {
throw new Error('Safe guard timeout');
}
}
export default {
start,
update,
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
/**
* Indicates the direction of the selection.
*
* @readonly
* @enum {number}
*/
export const SelectionDirection = {
/** The anchorNode is behind the focusNode */
FORWARD: 1,
/** The focusNode and the anchorNode are collapsed */
NONE: 0,
/** The focusNode is behind the anchorNode */
BACKWARD: -1,
};
export default SelectionDirection;

View file

@ -0,0 +1,75 @@
/**
* 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
*/
/**
* Used for handling debugging.
*/
export class SelectionControllerDebug {
/**
* @type {Object.<string, HTMLElement>}
*/
#elements = null;
/**
* Constructor
*
* @param {Object.<string, HTMLElement>} elements List of elements used to debug the SelectionController
*/
constructor(elements) {
this.#elements = elements;
}
getNodeDescription(node, offset) {
if (!node) return "null";
return `${node.nodeName} ${
node.nodeType === Node.TEXT_NODE
? node.nodeValue + (typeof offset === "number" ? `(${offset})` : "")
: node.dataset.itype
}`;
}
update(selectionController) {
this.#elements.direction.value = selectionController.direction;
this.#elements.multiElement.checked = selectionController.isMulti;
this.#elements.multiInlineElement.checked =
selectionController.isMultiInline;
this.#elements.multiParagraphElement.checked =
selectionController.isMultiParagraph;
this.#elements.isParagraphStart.checked =
selectionController.isParagraphStart;
this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd;
this.#elements.isInlineStart.checked = selectionController.isInlineStart;
this.#elements.isInlineEnd.checked = selectionController.isInlineEnd;
this.#elements.isTextAnchor.checked = selectionController.isTextAnchor;
this.#elements.isTextFocus.checked = selectionController.isTextFocus;
this.#elements.focusNode.value = this.getNodeDescription(
selectionController.focusNode,
selectionController.focusOffset
);
this.#elements.focusOffset.value = selectionController.focusOffset;
this.#elements.anchorNode.value = this.getNodeDescription(
selectionController.anchorNode,
selectionController.anchorOffset
);
this.#elements.anchorOffset.value = selectionController.anchorOffset;
this.#elements.focusInline.value = this.getNodeDescription(
selectionController.focusInline
);
this.#elements.anchorInline.value = this.getNodeDescription(
selectionController.anchorInline
);
this.#elements.focusParagraph.value = this.getNodeDescription(
selectionController.focusParagraph
);
this.#elements.anchorParagraph.value = this.getNodeDescription(
selectionController.anchorParagraph
);
this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer);
this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer);
}
}

View file

@ -0,0 +1,19 @@
/**
* 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
*/
/**
* Enumeration of types of layout.
*
* @enum {string}
*/
export const LayoutType = {
FULL: "full",
PARTIAL: "partial",
};
export default LayoutType;

View file

@ -0,0 +1,31 @@
/**
* 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
*/
/**
* Creates a new selection imposter from a list of client rects.
*
* @param {DOMRect} referenceRect
* @param {DOMRectList} clientRects
* @returns {DocumentFragment}
*/
export function createSelectionImposterFromClientRects(
referenceRect,
clientRects
) {
const fragment = document.createDocumentFragment();
for (const rect of clientRects) {
const rectElement = document.createElement("div");
rectElement.className = "selection-imposter-rect";
rectElement.style.left = `${rect.x - referenceRect.x}px`;
rectElement.style.top = `${rect.y - referenceRect.y}px`;
rectElement.style.width = `${rect.width}px`;
rectElement.style.height = `${rect.height}px`;
fragment.appendChild(rectElement);
}
return fragment;
}

View file

@ -0,0 +1,14 @@
import { expect, test } from "vitest";
import { createSelectionImposterFromClientRects } from "./Imposter.js";
/* @vitest-environment jsdom */
test("Create selection DOM rects from client rects", () => {
const rect = new DOMRect(20, 20, 100, 50);
const clientRects = [
new DOMRect(20, 20, 100, 20),
new DOMRect(20, 50, 50, 20),
];
const fragment = createSelectionImposterFromClientRects(rect, clientRects);
expect(fragment).toBeInstanceOf(DocumentFragment);
expect(fragment.childNodes).toHaveLength(2);
});

View file

@ -0,0 +1,226 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Playwrite+ES:wght@100..400&family=Playwrite+NZ:wght@100..400&family=Playwrite+US+Trad:wght@100..400&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot - Text Editor Playground</title>
<style>
#output {
white-space: pre-wrap;
}
</style>
</head>
<body>
<form>
<fieldset>
<legend>Styles</legend>
<!-- Font -->
<div class="form-group">
<label for="font-family">Font family</label>
<select id="font-family">
<option value="Open+Sans">Open Sans</option>
<option value="sourcesanspro">Source Sans Pro</option>
<option value="whatever">Whatever</option>
</select>
</div>
<div class="form-group">
<label for="font-size">Font size</label>
<input id="font-size" type="number" value="14" />
</div>
<div class="form-group">
<label for="font-weight">Font weight</label>
<select id="font-weight">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400 (normal)</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700 (bold)</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
<div class="form-group">
<label for="font-style">Font style</label>
<select id="font-style">
<option value="normal">normal</option>
<option value="italic">italic</option>
<option value="oblique">oblique</option>
</select>
</div>
<!-- Text attributes -->
<div class="form-group">
<label for="line-height">Line height</label>
<input id="line-height" type="number" value="1.0" />
</div>
<div class="form-group">
<label for="letter-spacing">Letter spacing</label>
<input id="letter-spacing" type="number" value="0.0" />
</div>
<div class="form-group">
<label for="direction-ltr">LTR</label>
<input id="direction-ltr" type="radio" name="direction" value="ltr" checked />
</div>
<div class="form-group">
<label for="direction-rtl">RTL</label>
<input id="direction-rtl" type="radio" name="direction" value="rtl" />
</div>
<!-- Text Align -->
<div class="form-group">
<label for="text-align-left">Align left</label>
<input id="text-align-left" type="radio" name="text-align" value="left" checked />
</div>
<div class="form-group">
<label for="text-align-center">Align center</label>
<input id="text-align-center" type="radio" name="text-align" value="center" />
</div>
<div class="form-group">
<label for="text-align-right">Align right</label>
<input id="text-align-right" type="radio" name="text-align" value="right" />
</div>
<div class="form-group">
<label for="text-align-justify">Align justify</label>
<input id="text-align-justify" type="radio" name="text-align" value="justify" />
</div>
<!-- Text Transform -->
<div class="form-group">
<label for="text-transform-none">None</label>
<input id="text-transform-none" type="radio" name="text-transform" value="none" checked />
</div>
<div class="form-group">
<label for="text-transform-uppercase">Uppercase</label>
<input id="text-transform-uppercase" type="radio" name="text-transform" value="uppercase" checked />
</div>
<div class="form-group">
<label for="text-transform-capitalize">Capitalize</label>
<input id="text-transform-capitalize" type="radio" name="text-transform" value="capitalize" />
</div>
<div class="form-group">
<label for="text-transform-lowercase">Lowercase</label>
<input id="text-transform-lowercase" type="radio" name="text-transform" value="lowercase" />
</div>
</fieldset>
<fieldset>
<legend>Debug</legend>
<div class="form-group">
<label for="direction">Direction</label>
<input id="direction" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-node">Focus Node</label>
<input id="focus-node" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-offset">Focus offset</label>
<input id="focus-offset" readonly type="number">
</div>
<div class="form-group">
<label for="focus-inline">Focus Inline</label>
<input id="focus-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-paragraph">Focus Paragraph</label>
<input id="focus-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-node">Anchor Node</label>
<input id="anchor-node" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-offset">Anchor offset</label>
<input id="anchor-offset" readonly type="number">
</div>
<div class="form-group">
<label for="anchor-inline">Anchor Inline</label>
<input id="anchor-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-paragraph">Anchor Paragraph</label>
<input id="anchor-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="start-container">Start container</label>
<input id="start-container" readonly type="text" />
</div>
<div class="form-group">
<label for="start-offset">Start offset</label>
<input id="start-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="end-container">End container</label>
<input id="end-container" readonly type="text" />
</div>
<div class="form-group">
<label for="end-offset">End offset</label>
<input id="end-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="multi">Multi?</label>
<input id="multi" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-inline">Multi inline?</label>
<input id="multi-inline" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-paragraph">Multi paragraph?</label>
<input id="multi-paragraph" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-focus">Is text focus?</label>
<input id="is-text-focus" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-anchor">Is text anchor?</label>
<input id="is-text-anchor" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-start">Is paragraph start?</label>
<input id="is-paragraph-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-end">Is paragraph end?</label>
<input id="is-paragraph-end" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-start">Is inline start?</label>
<input id="is-inline-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-end">Is inline end?</label>
<input id="is-inline-end" readonly type="checkbox">
</div>
</fieldset>
</form>
<!--
Editor
-->
<div class="text-editor-container align-top">
<div
id="text-editor-selection-imposter"
class="text-editor-selection-imposter"></div>
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
</div>
<!--
Text output
-->
<div id="output"></div>
<script type="module" src="/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,216 @@
/**
* 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 "./style.css";
import "./editor/TextEditor.css";
import { TextEditor } from "./editor/TextEditor";
import { SelectionControllerDebug } from "./editor/debug/SelectionControllerDebug";
const searchParams = new URLSearchParams(location.search);
const debug = searchParams.has("debug")
? searchParams.get("debug").split(",")
: [];
const textEditorSelectionImposterElement = document.getElementById(
"text-editor-selection-imposter"
);
const textEditorElement = document.querySelector(".text-editor-content");
const textEditor = new TextEditor(textEditorElement, {
styleDefaults: {
"font-family": "sourcesanspro",
"font-size": "14",
"font-weight": "400",
"font-style": "normal",
"line-height": "1.2",
"letter-spacing": "0",
"direction": "ltr",
"text-align": "left",
"text-transform": "none",
"text-decoration": "none",
"--typography-ref-id": '["~#\'",null]',
"--typography-ref-file": '["~#\'",null]',
"--font-id": '["~#\'","sourcesanspro"]',
"--fills": '[["^ ","~:fill-color","#000000","~:fill-opacity",1]]'
},
selectionImposterElement: textEditorSelectionImposterElement,
debug: new SelectionControllerDebug({
direction: document.getElementById("direction"),
multiElement: document.getElementById("multi"),
multiInlineElement: document.getElementById("multi-inline"),
multiParagraphElement: document.getElementById("multi-paragraph"),
isParagraphStart: document.getElementById("is-paragraph-start"),
isParagraphEnd: document.getElementById("is-paragraph-end"),
isInlineStart: document.getElementById("is-inline-start"),
isInlineEnd: document.getElementById("is-inline-end"),
isTextAnchor: document.getElementById("is-text-anchor"),
isTextFocus: document.getElementById("is-text-focus"),
focusNode: document.getElementById("focus-node"),
focusOffset: document.getElementById("focus-offset"),
focusInline: document.getElementById("focus-inline"),
focusParagraph: document.getElementById("focus-paragraph"),
anchorNode: document.getElementById("anchor-node"),
anchorOffset: document.getElementById("anchor-offset"),
anchorInline: document.getElementById("anchor-inline"),
anchorParagraph: document.getElementById("anchor-paragraph"),
startContainer: document.getElementById("start-container"),
startOffset: document.getElementById("start-offset"),
endContainer: document.getElementById("end-container"),
endOffset: document.getElementById("end-offset"),
}),
});
const fontFamilyElement = document.getElementById("font-family");
const fontSizeElement = document.getElementById("font-size");
const fontWeightElement = document.getElementById("font-weight");
const fontStyleElement = document.getElementById("font-style");
const directionLTRElement = document.getElementById("direction-ltr");
const directionRTLElement = document.getElementById("direction-rtl");
const lineHeightElement = document.getElementById("line-height");
const letterSpacingElement = document.getElementById("letter-spacing");
const textAlignLeftElement = document.getElementById("text-align-left");
const textAlignCenterElement = document.getElementById("text-align-center");
const textAlignRightElement = document.getElementById("text-align-right");
const textAlignJustifyElement = document.getElementById("text-align-justify");
function onDirectionChange(e) {
if (debug.includes("events")) {
console.log(e);
}
if (e.target.checked) {
textEditor.applyStylesToSelection({
"direction": e.target.value
});
}
}
directionLTRElement.addEventListener("change", onDirectionChange);
directionRTLElement.addEventListener("change", onDirectionChange);
function onTextAlignChange(e) {
if (debug.includes("events")) {
console.log(e);
}
if (e.target.checked) {
textEditor.applyStylesToSelection({
"text-align": e.target.value
});
}
}
textAlignLeftElement.addEventListener("change", onTextAlignChange);
textAlignCenterElement.addEventListener("change", onTextAlignChange);
textAlignRightElement.addEventListener("change", onTextAlignChange);
textAlignJustifyElement.addEventListener("change", onTextAlignChange);
fontFamilyElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-family": e.target.value,
});
});
fontWeightElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-weight": e.target.value,
});
});
fontSizeElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-size": e.target.value,
});
});
lineHeightElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"line-height": e.target.value
})
})
letterSpacingElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"letter-spacing": e.target.value
})
})
fontStyleElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-style": e.target.value,
});
});
function formatHTML(html, options) {
const spaces = options?.spaces ?? 4;
let indent = 0;
return html.replace(/<\/?(.*?)>/g, (fullMatch) => {
let str = fullMatch + "\n";
if (fullMatch.startsWith("</")) {
--indent;
str = " ".repeat(indent * spaces) + str;
} else {
str = " ".repeat(indent * spaces) + str;
++indent;
if (fullMatch === "<br>") --indent;
}
return str;
});
}
const outputElement = document.getElementById("output");
textEditorElement.addEventListener("input", (e) => {
if (debug.includes("events")) {
console.log(e);
}
outputElement.textContent = formatHTML(textEditor.element.innerHTML);
});
textEditor.addEventListener("stylechange", (e) => {
if (debug.includes("events")) {
console.log(e);
}
const fontSize = parseInt(e.detail.getPropertyValue("font-size"), 10);
const fontWeight = e.detail.getPropertyValue("font-weight");
const fontStyle = e.detail.getPropertyValue("font-style");
const fontFamily = e.detail.getPropertyValue("font-family");
fontFamilyElement.value = fontFamily;
fontSizeElement.value = fontSize;
fontStyleElement.value = fontStyle;
fontWeightElement.value = fontWeight;
const textAlign = e.detail.getPropertyValue("text-align");
textAlignLeftElement.checked = textAlign === "left";
textAlignCenterElement.checked = textAlign === "center";
textAlignRightElement.checked = textAlign === "right";
textAlignJustifyElement.checked = textAlign === "justify";
const direction = e.detail.getPropertyValue("direction");
directionLTRElement.checked = direction === "ltr";
directionRTLElement.checked = direction === "rtl";
});

View file

@ -0,0 +1,14 @@
:root {
background-color: #333;
color: #eee;
}
.text-editor-container {
background-color: white;
}
#output {
font-family: monospace;
padding: 1rem;
border: 1px solid #333;
}

View file

@ -0,0 +1,127 @@
import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph } from "../editor/content/dom/Paragraph.js";
import { createEmptyInline, createInline } from "../editor/content/dom/Inline.js";
import { createLineBreak } from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget {
/**
* Returns the template used for the text editor mock.
*
* @returns {HTMLDivElement}
*/
static getTemplate() {
const container = document.createElement("div");
container.id = "test";
container.innerHTML = `<div class="text-editor-container align-top">
<div
id="text-editor-selection-imposter"
class="text-editor-selection-imposter"></div>
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
</div>`;
document.body.appendChild(container);
return container;
}
/**
* Creates an editor with a custom root.
*
* @param {HTMLDivElement} root
* @returns {HTMLDivElement}
*/
static createTextEditorMockWithRoot(root) {
const container = TextEditorMock.getTemplate();
const selectionImposterElement = container.querySelector(
".text-editor-selection-imposter"
);
const textEditorMock = new TextEditorMock(
container.querySelector(".text-editor-content"),
{
root,
selectionImposterElement,
}
);
return textEditorMock;
}
/**
* Creates a TextEditor mock with paragraphs.
*
* @param {Array<HTMLDivElement>} paragraphs
* @returns
*/
static createTextEditorMockWithParagraphs(paragraphs) {
const root = createRoot(paragraphs);
return this.createTextEditorMockWithRoot(root);
}
/**
* Creates an empty TextEditor mock.
*
* @returns
*/
static createTextEditorMockEmpty() {
const root = createRoot([
createParagraph([createInline(createLineBreak())]),
]);
return this.createTextEditorMockWithRoot(root);
}
/**
* Creates a TextEditor mock with some text.
*
* NOTE: If the text is empty an empty inline will be
* created.
*
* @param {string} text
* @returns
*/
static createTextEditorMockWithText(text) {
return this.createTextEditorMockWithParagraphs([
createParagraph([
text.length === 0
? createEmptyInline()
: createInline(new Text(text))
]),
]);
}
/**
* Creates a TextEditor mock with some inlines and
* only one paragraph.
*
* @param {Array<HTMLSpanElement>} inlines
* @returns
*/
static createTextEditorMockWithParagraph(inlines) {
return this.createTextEditorMockWithParagraphs([createParagraph(inlines)]);
}
#element = null;
#root = null;
#selectionImposterElement = null;
constructor(element, options) {
super();
this.#element = element;
this.#root = options?.root;
this.#selectionImposterElement = options?.selectionImposterElement;
this.#element.appendChild(options?.root);
}
get element() {
return this.#element;
}
get root() {
return this.#root;
}
}
export default TextEditorMock;