Compare commits

...

1 Commits

Author SHA1 Message Date
Aitor Moreno
021aeb50dd WIP 2026-01-29 17:55:03 +01:00
7 changed files with 233 additions and 213 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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, ");
});
});

View File

@@ -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]]',

View File

@@ -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);
}
}
}

View File

@@ -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);
});
});