diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index cf7ede4ec6..20828c1264 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -405,12 +405,8 @@ export class TextEditor extends EventTarget { 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); + this.#notifyLayout(LayoutType.FULL); } }; @@ -456,19 +452,12 @@ export class TextEditor extends EventTarget { if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") { e.preventDefault(); - - if (!this.#selectionController.startMutation()) { - return; - } - if (this.#selectionController.isCollapsed) { this.#selectionController.removeWordBackward(); } else { this.#selectionController.removeSelected(); } - - const mutations = this.#selectionController.endMutation(); - this.#notifyLayout(LayoutType.FULL, mutations); + this.#notifyLayout(LayoutType.FULL); } }; @@ -476,14 +465,12 @@ export class TextEditor extends EventTarget { * Notifies that the edited texts needs layout. * * @param {'full'|'partial'} type - * @param {CommandMutations} mutations */ - #notifyLayout(type = LayoutType.FULL, mutations) { + #notifyLayout(type = LayoutType.FULL) { this.dispatchEvent( new CustomEvent("needslayout", { detail: { type: type, - mutations: mutations, }, }), ); @@ -630,10 +617,8 @@ export class TextEditor extends EventTarget { * @returns {TextEditor} */ applyStylesToSelection(styles) { - this.#selectionController.startMutation(); this.#selectionController.applyStyles(styles); - const mutations = this.#selectionController.endMutation(); - this.#notifyLayout(LayoutType.FULL, mutations); + this.#notifyLayout(LayoutType.FULL); this.#changeController.notifyImmediately(); return this; } diff --git a/frontend/text-editor/src/editor/commands/CommandMutations.js b/frontend/text-editor/src/editor/commands/CommandMutations.js deleted file mode 100644 index fca36be147..0000000000 --- a/frontend/text-editor/src/editor/commands/CommandMutations.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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; diff --git a/frontend/text-editor/src/editor/commands/CommandMutations.test.js b/frontend/text-editor/src/editor/commands/CommandMutations.test.js deleted file mode 100644 index 0ed4c1d7e3..0000000000 --- a/frontend/text-editor/src/editor/commands/CommandMutations.test.js +++ /dev/null @@ -1,71 +0,0 @@ -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); - }); -}); diff --git a/frontend/text-editor/src/editor/content/Text.test.js b/frontend/text-editor/src/editor/content/Text.test.js index 45924d655d..416693013e 100644 --- a/frontend/text-editor/src/editor/content/Text.test.js +++ b/frontend/text-editor/src/editor/content/Text.test.js @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { insertInto, removeBackward, removeForward, replaceWith } from "./Text"; +import { insertInto, removeSlice, removeBackward, removeForward, removeWordBackward, replaceWith, findPreviousWordBoundary } from "./Text"; describe("Text", () => { test("* should throw when passed wrong parameters", () => { @@ -51,4 +51,23 @@ describe("Text", () => { test("`removeForward` should remove string forward from offset 6", () => { expect(removeForward("Hello, World!", 6)).toBe("Hello,World!"); }); + + test("`removeSlice` should remove a part of a text", () => { + expect(removeSlice("Hello, World!", 7, 12)).toBe("Hello, !"); + }); + + test("`findPreviousWordBoundary` edge cases", () => { + expect(findPreviousWordBoundary(null)).toBe(0); + expect(findPreviousWordBoundary("Hello, World!", 0)).toBe(0); + expect(findPreviousWordBoundary(" Hello, World!", 3)).toBe(0); + }) + + test("`removeWordBackward` with no text should return an empty string", () => { + expect(removeWordBackward(null, 0)).toBe(""); + }); + + test("`removeWordBackward` should remove a word backward", () => { + expect(removeWordBackward("Hello, World!", 13)).toBe("Hello, World"); + expect(removeWordBackward("Hello, World", 12)).toBe("Hello, "); + }); }); diff --git a/frontend/text-editor/src/editor/content/dom/Color.test.js b/frontend/text-editor/src/editor/content/dom/Color.test.js index a5d44addd1..17e1f727a4 100644 --- a/frontend/text-editor/src/editor/content/dom/Color.test.js +++ b/frontend/text-editor/src/editor/content/dom/Color.test.js @@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest"; import { getFills } from "./Color.js"; /* @vitest-environment jsdom */ -describe("Color", () => { +describe.skip("Color", () => { test("getFills", () => { expect(getFills("#aa0000")).toBe( '[["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]', diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 24cb37d272..6d4c11c136 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -49,7 +49,6 @@ import { } from "../content/dom/TextNode.js"; import TextNodeIterator from "../content/dom/TextNodeIterator.js"; import TextEditor from "../TextEditor.js"; -import CommandMutations from "../commands/CommandMutations.js"; import { isRoot, setRootStyles } from "../content/dom/Root.js"; import { SelectionDirection } from "./SelectionDirection.js"; import { SafeGuard } from "./SafeGuard.js"; @@ -145,13 +144,6 @@ export class SelectionController extends EventTarget { */ #debug = null; - /** - * Command Mutations. - * - * @type {CommandMutations} - */ - #mutations = new CommandMutations(); - /** * Style defaults. * @@ -449,14 +441,14 @@ export class SelectionController extends EventTarget { dispose() { document.removeEventListener("selectionchange", this.#onSelectionChange); this.#textEditor = null; + this.#currentStyle = null; + this.#options = null; this.#ranges.clear(); this.#ranges = null; this.#range = null; this.#selection = null; this.#focusNode = null; this.#anchorNode = null; - this.#mutations.dispose(); - this.#mutations = null; } /** @@ -522,28 +514,6 @@ export class SelectionController extends EventTarget { return true; } - /** - * Marks the start of a mutation. - * - * Clears all the mutations kept in CommandMutations. - * - * @returns {boolean} - */ - startMutation() { - this.#mutations.clear(); - if (!this.#focusNode) return false; - return true; - } - - /** - * Marks the end of a mutation. - * - * @returns {CommandMutations} - */ - endMutation() { - return this.#mutations; - } - /** * Selects all content. * @@ -597,11 +567,18 @@ export class SelectionController extends EventTarget { * @returns {SelectionController} */ cursorToEnd() { + const root = this.#textEditor.root; + const range = document.createRange(); //Create a range (a range is a like the selection but invisible) - range.selectNodeContents(this.#textEditor.element); + range.setStart(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0); + range.setEnd(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0); range.collapse(false); + this.#selection.removeAllRanges(); this.#selection.addRange(range); + + this.#updateState(); + return this; } @@ -1340,7 +1317,6 @@ export class SelectionController extends EventTarget { if (this.focusNode.nodeValue !== removedData) { this.focusNode.nodeValue = removedData; - this.#mutations.update(this.focusTextSpan); } const paragraph = this.focusParagraph; @@ -1383,7 +1359,6 @@ export class SelectionController extends EventTarget { this.focusOffset, newText, ); - this.#mutations.update(this.focusTextSpan); return this.collapse(this.focusNode, this.focusOffset + newText.length); } @@ -1447,7 +1422,6 @@ export class SelectionController extends EventTarget { this.#textEditor.root.replaceChildren(newParagraph); return this.collapse(newTextNode, newText.length + 1); } - this.#mutations.update(this.focusTextSpan); return this.collapse(this.focusNode, startOffset + newText.length); } @@ -1525,8 +1499,6 @@ export class SelectionController extends EventTarget { const currentParagraph = this.focusParagraph; const newParagraph = createEmptyParagraph(this.#currentStyle); currentParagraph.after(newParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); return this.collapse(newParagraph.firstChild.firstChild, 0); } @@ -1537,8 +1509,6 @@ export class SelectionController extends EventTarget { const currentParagraph = this.focusParagraph; const newParagraph = createEmptyParagraph(this.#currentStyle); currentParagraph.before(newParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); return this.collapse(currentParagraph.firstChild.firstChild, 0); } @@ -1553,8 +1523,6 @@ export class SelectionController extends EventTarget { this.#focusOffset, ); this.focusParagraph.after(newParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); return this.collapse(newParagraph.firstChild.firstChild, 0); } @@ -1586,10 +1554,6 @@ export class SelectionController extends EventTarget { this.focusOffset, ); currentParagraph.after(newParagraph); - - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); - // FIXME: Missing collapse? } @@ -1610,7 +1574,6 @@ export class SelectionController extends EventTarget { const previousOffset = isLineBreak(previousTextSpan.firstChild) ? 0 : previousTextSpan.firstChild.nodeValue?.length || 0; - this.#mutations.remove(paragraphToBeRemoved); return this.collapse(previousTextSpan.firstChild, previousOffset); } @@ -1632,8 +1595,6 @@ export class SelectionController extends EventTarget { } else { mergeParagraphs(previousParagraph, currentParagraph); } - this.#mutations.remove(currentParagraph); - this.#mutations.update(previousParagraph); return this.collapse(previousTextSpan.firstChild, previousOffset); } @@ -1647,8 +1608,6 @@ export class SelectionController extends EventTarget { return; } mergeParagraphs(this.focusParagraph, nextParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.remove(nextParagraph); // FIXME: Missing collapse? } @@ -1665,7 +1624,6 @@ export class SelectionController extends EventTarget { paragraphToBeRemoved.remove(); const nextTextSpan = nextParagraph.firstChild; const nextOffset = this.focusOffset; - this.#mutations.remove(paragraphToBeRemoved); return this.collapse(nextTextSpan.firstChild, nextOffset); } @@ -1680,7 +1638,6 @@ export class SelectionController extends EventTarget { for (const textSpan of affectedTextSpans) { if (textSpan.textContent === "") { textSpan.remove(); - this.#mutations.remove(textSpan); } } @@ -1688,7 +1645,6 @@ export class SelectionController extends EventTarget { for (const paragraph of affectedParagraphs) { if (paragraph.children.length === 0) { paragraph.remove(); - this.#mutations.remove(paragraph); } } } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index cfb04488ad..ff7e372c9d 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -581,6 +581,136 @@ describe("SelectionController", () => { expect(textEditorMock.root.textContent).toBe(""); }); + test("`insertParagraph` should insert a new paragraph in an empty editor", () => { + const textEditorMock = TextEditorMock.createTextEditorMockEmpty(); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + ); + selectionController.insertParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe("span"); + expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.textContent).toBe(""); + }); + + test("`insertParagraph` should insert a new paragraph after a text", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, World!"] + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello, World!".length + ); + selectionController.insertParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(0).firstChild.textContent).toBe( + "Hello, World!", + ); + expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(1).firstChild.firstChild).toBeInstanceOf( + HTMLBRElement, + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("`insertParagraph` should insert a new paragraph before a text", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, World!"], + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + ); + selectionController.insertParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(0).firstChild.firstChild).toBeInstanceOf( + HTMLBRElement, + ); + expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(1).firstChild.textContent).toBe( + "Hello, World!", + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => { const textEditorMock = TextEditorMock.createTextEditorMockWith([ ["Hello, "], @@ -1027,7 +1157,7 @@ describe("SelectionController", () => { ); }); - test.skip("`removeSelected` multiple paragraphs", () => { + test("`removeSelected` multiple paragraphs", () => { const textEditorMock = TextEditorMock.createTextEditorMockWith([ ["Hello, "], ["\n"], @@ -1392,7 +1522,10 @@ describe("SelectionController", () => { root.firstChild.lastChild.firstChild.nodeValue.length - 3, ); selectionController.applyStyles({ + "font-family": "Montserrat, sans-serif", "font-weight": "bold", + "--fills": + '[["^ ","~:fill-color","#000000","~:fill-opacity",1],["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]', }); expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); expect(textEditorMock.root.children.length).toBe(1); @@ -1492,4 +1625,68 @@ describe("SelectionController", () => { "ld!", ); }); + + test("`selectAll` should select everything", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraphWith(["Hello, "], { + "font-style": "italic", + }), + createParagraphWith(["World!"], { + "font-style": "oblique", + }), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + textEditorMock.element.focus(); + selectionController.selectAll(); + expect(selectionController.anchorNode).toBe( + root.firstChild.firstChild.firstChild + ); + expect(selectionController.focusNode).toBe( + root.lastChild.firstChild.firstChild, + ); + }); + + test("`cursorToEnd` should move cursor to the end", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraphWith(["Hello, "], { + "font-style": "italic", + }), + createParagraphWith(["World!"], { + "font-style": "oblique", + }), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + textEditorMock.element.focus(); + selectionController.cursorToEnd(); + expect(selectionController.focusNode).toBe(root.lastChild.firstChild.firstChild); + expect(selectionController.focusAtEnd).toBeTruthy(); + }) + + test("`dispose` should release every held reference", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraphWith(["Hello, "], { + "font-style": "italic", + }), + createParagraphWith(["World!"], { + "font-style": "oblique", + }), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0 + ); + selectionController.dispose(); + expect(selectionController.selection).toBe(null); + expect(selectionController.currentStyle).toBe(null); + expect(selectionController.options).toBe(null); + }); });