mirror of
https://github.com/penpot/penpot.git
synced 2025-05-24 06:36:10 +02:00
📎 Fix some missing changes
This commit is contained in:
parent
b80ccbec0f
commit
c8c83c1e1d
58 changed files with 551 additions and 504 deletions
32
frontend/text-editor/src/editor/Event.js
Normal file
32
frontend/text-editor/src/editor/Event.js
Normal 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)
|
||||
);
|
||||
}
|
29
frontend/text-editor/src/editor/Event.test.js
Normal file
29
frontend/text-editor/src/editor/Event.test.js
Normal 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);
|
||||
});
|
||||
});
|
65
frontend/text-editor/src/editor/TextEditor.css
Normal file
65
frontend/text-editor/src/editor/TextEditor.css
Normal 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;
|
||||
}
|
545
frontend/text-editor/src/editor/TextEditor.js
Normal file
545
frontend/text-editor/src/editor/TextEditor.js
Normal 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;
|
100
frontend/text-editor/src/editor/TextEditor.test.js
Normal file
100
frontend/text-editor/src/editor/TextEditor.test.js
Normal 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));
|
||||
});
|
||||
});
|
19
frontend/text-editor/src/editor/clipboard/copy.js
Normal file
19
frontend/text-editor/src/editor/clipboard/copy.js
Normal 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) {}
|
19
frontend/text-editor/src/editor/clipboard/cut.js
Normal file
19
frontend/text-editor/src/editor/clipboard/cut.js
Normal 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) {}
|
17
frontend/text-editor/src/editor/clipboard/index.js
Normal file
17
frontend/text-editor/src/editor/clipboard/index.js
Normal 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,
|
||||
};
|
45
frontend/text-editor/src/editor/clipboard/paste.js
Normal file
45
frontend/text-editor/src/editor/clipboard/paste.js
Normal 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);
|
||||
}
|
||||
}
|
66
frontend/text-editor/src/editor/commands/CommandMutations.js
Normal file
66
frontend/text-editor/src/editor/commands/CommandMutations.js
Normal 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;
|
|
@ -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);
|
||||
});
|
||||
});
|
22
frontend/text-editor/src/editor/commands/deleteByCut.js
Normal file
22
frontend/text-editor/src/editor/commands/deleteByCut.js
Normal 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();
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
21
frontend/text-editor/src/editor/commands/index.js
Normal file
21
frontend/text-editor/src/editor/commands/index.js
Normal 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,
|
||||
};
|
23
frontend/text-editor/src/editor/commands/insertParagraph.js
Normal file
23
frontend/text-editor/src/editor/commands/insertParagraph.js
Normal 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();
|
||||
}
|
34
frontend/text-editor/src/editor/commands/insertText.js
Normal file
34
frontend/text-editor/src/editor/commands/insertText.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
104
frontend/text-editor/src/editor/content/Text.js
Normal file
104
frontend/text-editor/src/editor/content/Text.js
Normal 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);
|
||||
}
|
46
frontend/text-editor/src/editor/content/Text.test.js
Normal file
46
frontend/text-editor/src/editor/content/Text.test.js
Normal 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!");
|
||||
});
|
||||
});
|
78
frontend/text-editor/src/editor/content/dom/Color.js
Normal file
78
frontend/text-editor/src/editor/content/dom/Color.js
Normal 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}]]`;
|
||||
}
|
102
frontend/text-editor/src/editor/content/dom/Content.js
Normal file
102
frontend/text-editor/src/editor/content/dom/Content.js
Normal 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;
|
||||
}
|
98
frontend/text-editor/src/editor/content/dom/Content.test.js
Normal file
98
frontend/text-editor/src/editor/content/dom/Content.test.js
Normal 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!");
|
||||
});
|
||||
});
|
98
frontend/text-editor/src/editor/content/dom/Element.js
Normal file
98
frontend/text-editor/src/editor/content/dom/Element.js
Normal 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;
|
||||
}
|
116
frontend/text-editor/src/editor/content/dom/Element.test.js
Normal file
116
frontend/text-editor/src/editor/content/dom/Element.test.js
Normal 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);
|
||||
});
|
||||
});
|
272
frontend/text-editor/src/editor/content/dom/Inline.js
Normal file
272
frontend/text-editor/src/editor/content/dom/Inline.js
Normal 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;
|
||||
}
|
123
frontend/text-editor/src/editor/content/dom/Inline.test.js
Normal file
123
frontend/text-editor/src/editor/content/dom/Inline.test.js
Normal 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);
|
||||
});
|
||||
});
|
28
frontend/text-editor/src/editor/content/dom/LineBreak.js
Normal file
28
frontend/text-editor/src/editor/content/dom/LineBreak.js
Normal 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;
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
});
|
258
frontend/text-editor/src/editor/content/dom/Paragraph.js
Normal file
258
frontend/text-editor/src/editor/content/dom/Paragraph.js
Normal 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;
|
||||
}
|
171
frontend/text-editor/src/editor/content/dom/Paragraph.test.js
Normal file
171
frontend/text-editor/src/editor/content/dom/Paragraph.test.js
Normal 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);
|
||||
});
|
||||
});
|
70
frontend/text-editor/src/editor/content/dom/Root.js
Normal file
70
frontend/text-editor/src/editor/content/dom/Root.js
Normal 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);
|
||||
}
|
35
frontend/text-editor/src/editor/content/dom/Root.test.js
Normal file
35
frontend/text-editor/src/editor/content/dom/Root.test.js
Normal 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("");
|
||||
});
|
||||
});
|
329
frontend/text-editor/src/editor/content/dom/Style.js
Normal file
329
frontend/text-editor/src/editor/content/dom/Style.js
Normal 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";
|
||||
}
|
82
frontend/text-editor/src/editor/content/dom/Style.test.js
Normal file
82
frontend/text-editor/src/editor/content/dom/Style.test.js
Normal 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);
|
||||
});
|
||||
});
|
64
frontend/text-editor/src/editor/content/dom/TextNode.js
Normal file
64
frontend/text-editor/src/editor/content/dom/TextNode.js
Normal 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");
|
||||
}
|
28
frontend/text-editor/src/editor/content/dom/TextNode.test.js
Normal file
28
frontend/text-editor/src/editor/content/dom/TextNode.test.js
Normal 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");
|
||||
});
|
||||
});
|
250
frontend/text-editor/src/editor/content/dom/TextNodeIterator.js
Normal file
250
frontend/text-editor/src/editor/content/dom/TextNodeIterator.js
Normal 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;
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
34
frontend/text-editor/src/editor/controllers/SafeGuard.js
Normal file
34
frontend/text-editor/src/editor/controllers/SafeGuard.js
Normal 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,
|
||||
}
|
1735
frontend/text-editor/src/editor/controllers/SelectionController.js
Normal file
1735
frontend/text-editor/src/editor/controllers/SelectionController.js
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
19
frontend/text-editor/src/editor/layout/LayoutType.js
Normal file
19
frontend/text-editor/src/editor/layout/LayoutType.js
Normal 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;
|
31
frontend/text-editor/src/editor/selection/Imposter.js
Normal file
31
frontend/text-editor/src/editor/selection/Imposter.js
Normal 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;
|
||||
}
|
14
frontend/text-editor/src/editor/selection/Imposter.spec.js
Normal file
14
frontend/text-editor/src/editor/selection/Imposter.spec.js
Normal 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);
|
||||
});
|
226
frontend/text-editor/src/index.html
Normal file
226
frontend/text-editor/src/index.html
Normal 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>
|
216
frontend/text-editor/src/main.js
Normal file
216
frontend/text-editor/src/main.js
Normal 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";
|
||||
});
|
14
frontend/text-editor/src/style.css
Normal file
14
frontend/text-editor/src/style.css
Normal 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;
|
||||
}
|
127
frontend/text-editor/src/test/TextEditorMock.js
Normal file
127
frontend/text-editor/src/test/TextEditorMock.js
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue