Merge pull request #7950 from penpot/ladybenko-12851-fix-text-selection

🐛 Fix text selection when editor regains focus
This commit is contained in:
Aitor Moreno 2025-12-11 13:45:29 +01:00 committed by GitHub
commit c6aa9cc4b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 249 additions and 224 deletions

View File

@ -32,8 +32,8 @@
"e2e:server": "node ./scripts/e2e-server.js", "e2e:server": "node ./scripts/e2e-server.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj:check": "cljfmt check --parallel=false src/ test/", "fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w", "fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js", "fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js",
"lint:clj": "clj-kondo --parallel --lint src/", "lint:clj": "clj-kondo --parallel --lint src/",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss", "lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w", "lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",

View File

@ -1,12 +1,12 @@
export class Clipboard { export class Clipboard {
static Permission = { static Permission = {
ONLY_READ: ['clipboard-read'], ONLY_READ: ["clipboard-read"],
ONLY_WRITE: ['clipboard-write'], ONLY_WRITE: ["clipboard-write"],
ALL: ['clipboard-read', 'clipboard-write'] ALL: ["clipboard-read", "clipboard-write"],
} };
static enable(context, permissions) { static enable(context, permissions) {
return context.grantPermissions(permissions) return context.grantPermissions(permissions);
} }
static writeText(page, text) { static writeText(page, text) {
@ -18,8 +18,8 @@ export class Clipboard {
} }
constructor(page, context) { constructor(page, context) {
this.page = page this.page = page;
this.context = context this.context = context;
} }
enable(permissions) { enable(permissions) {

View File

@ -1,18 +1,16 @@
export class Transit { export class Transit {
static parse(value) { static parse(value) {
if (typeof value !== 'string') if (typeof value !== "string") return value;
return value
if (value.startsWith('~')) if (value.startsWith("~")) return value.slice(2);
return value.slice(2)
return value return value;
} }
static get(object, ...path) { static get(object, ...path) {
let aux = object; let aux = object;
for (const name of path) { for (const name of path) {
if (typeof name !== 'string') { if (typeof name !== "string") {
if (!(name in aux)) { if (!(name in aux)) {
return undefined; return undefined;
} }

View File

@ -9,7 +9,7 @@ export class BasePage {
*/ */
static async mockRPCs(page, paths, options) { static async mockRPCs(page, paths, options) {
for (const [path, jsonFilename] of Object.entries(paths)) { for (const [path, jsonFilename] of Object.entries(paths)) {
await this.mockRPC(page, path, jsonFilename, options) await this.mockRPC(page, path, jsonFilename, options);
} }
} }

View File

@ -1,7 +1,7 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { readFile } from 'node:fs/promises'; import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage"; import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from '../../helpers/Transit'; import { Transit } from "../../helpers/Transit";
export class WorkspacePage extends BaseWebSocketPage { export class WorkspacePage extends BaseWebSocketPage {
static TextEditor = class TextEditor { static TextEditor = class TextEditor {

View File

@ -51,7 +51,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d", pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
}); });
await workspacePage.page.waitForTimeout(1000) await workspacePage.page.waitForTimeout(1000);
await workspacePage.waitForFirstRender(); await workspacePage.waitForFirstRender();
await expect( await expect(

View File

@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { Clipboard } from '../../helpers/Clipboard'; import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage"; import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100; const timeToWait = 100;
@ -11,14 +11,14 @@ test.beforeEach(async ({ page, context }) => {
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
}); });
test.afterEach(async ({ context}) => { test.afterEach(async ({ context }) => {
context.clearPermissions(); context.clearPermissions();
}) });
test("Create a new text shape", async ({ page }) => { test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum"; const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.goToWorkspace(); await workspace.goToWorkspace();
@ -36,10 +36,7 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockRPC( await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace(); await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste); await Clipboard.writeText(page, textToPaste);
@ -55,10 +52,13 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
}); });
test("Create a new text shape from pasting text using context menu", async ({ page, context }) => { test("Create a new text shape from pasting text using context menu", async ({
page,
context,
}) => {
const textToPaste = "Lorem ipsum"; const textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.goToWorkspace(); await workspace.goToWorkspace();
@ -72,11 +72,13 @@ test("Create a new text shape from pasting text using context menu", async ({ pa
expect(textContent).toBe(textToPaste); expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
}) });
test("Update an already created text shape by appending text", async ({ page }) => { test("Update an already created text shape by appending text", async ({
page,
}) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -94,7 +96,7 @@ test("Update an already created text shape by prepending text", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -112,7 +114,7 @@ test("Update an already created text shape by inserting text in between", async
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -126,10 +128,13 @@ test("Update an already created text shape by inserting text in between", async
await workspace.textEditor.stopEditing(); await workspace.textEditor.stopEditing();
}); });
test("Update a new text shape appending text by pasting text", async ({ page, context }) => { test("Update a new text shape appending text by pasting text", async ({
page,
context,
}) => {
const textToPaste = " dolor sit amet"; const textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -147,11 +152,12 @@ test("Update a new text shape appending text by pasting text", async ({ page, co
}); });
test("Update a new text shape prepending text by pasting text", async ({ test("Update a new text shape prepending text by pasting text", async ({
page, context page,
context,
}) => { }) => {
const textToPaste = "Dolor sit amet "; const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -173,7 +179,7 @@ test("Update a new text shape replacing (starting) text with pasted text", async
}) => { }) => {
const textToPaste = "Dolor sit amet"; const textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -197,7 +203,7 @@ test("Update a new text shape replacing (ending) text with pasted text", async (
}) => { }) => {
const textToPaste = "dolor sit amet"; const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -221,7 +227,7 @@ test("Update a new text shape replacing (in between) text with pasted text", asy
}) => { }) => {
const textToPaste = "dolor sit amet"; const textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
@ -244,14 +250,11 @@ test("Update text font size selecting a part of it (starting)", async ({
page, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC( await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace(); await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum"); await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing(); await workspace.textEditor.startEditing();
@ -280,7 +283,10 @@ test.skip("Update text line height selecting a part of it (starting)", async ({
await workspace.textEditor.selectFromStart(5); await workspace.textEditor.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4); await workspace.textEditor.changeLineHeight(1.4);
const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height'); const lineHeight = await workspace.textEditor.waitForParagraphStyle(
1,
"line-height",
);
expect(lineHeight).toBe("1.4"); expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent(); const textContent = await workspace.textEditor.waitForTextSpanContent();

View File

@ -303,7 +303,7 @@ test.describe("Tokens: Tokens Tab", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name"); const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.pressSequentially(".changed"); await nameField.pressSequentially(".changed");
await tokensUpdateCreateModal.getByRole("button", {name: "Save"}).click(); await tokensUpdateCreateModal.getByRole("button", { name: "Save" }).click();
await expect(tokensUpdateCreateModal).not.toBeVisible(); await expect(tokensUpdateCreateModal).not.toBeVisible();

View File

@ -15,7 +15,7 @@
*/ */
export function addEventListeners(target, object, options) { export function addEventListeners(target, object, options) {
Object.entries(object).forEach(([type, listener]) => Object.entries(object).forEach(([type, listener]) =>
target.addEventListener(type, listener, options) target.addEventListener(type, listener, options),
); );
} }
@ -27,6 +27,6 @@ export function addEventListeners(target, object, options) {
*/ */
export function removeEventListeners(target, object) { export function removeEventListeners(target, object) {
Object.entries(object).forEach(([type, listener]) => Object.entries(object).forEach(([type, listener]) =>
target.removeEventListener(type, listener) target.removeEventListener(type, listener),
); );
} }

View File

@ -664,8 +664,16 @@ export class TextEditor extends EventTarget {
* @param {boolean} allowHTMLPaste * @param {boolean} allowHTMLPaste
* @returns {Root} * @returns {Root}
*/ */
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) { export function createRootFromHTML(
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined); html,
style = undefined,
allowHTMLPaste = undefined,
) {
const fragment = mapContentFragmentFromHTML(
html,
style || undefined,
allowHTMLPaste || undefined,
);
const root = createRoot([], style); const root = createRoot([], style);
root.replaceChildren(fragment); root.replaceChildren(fragment);
resetInertElement(); resetInertElement();

View File

@ -18,7 +18,10 @@ import { TextEditor } from "../TextEditor.js";
* @param {DataTransfer} clipboardData * @param {DataTransfer} clipboardData
* @returns {DocumentFragment} * @returns {DocumentFragment}
*/ */
function getFormattedFragmentFromClipboardData(selectionController, clipboardData) { function getFormattedFragmentFromClipboardData(
selectionController,
clipboardData,
) {
return mapContentFragmentFromHTML( return mapContentFragmentFromHTML(
clipboardData.getData("text/html"), clipboardData.getData("text/html"),
selectionController.currentStyle, selectionController.currentStyle,
@ -79,9 +82,14 @@ export function paste(event, editor, selectionController) {
let fragment = null; let fragment = null;
if (editor?.options?.allowHTMLPaste) { if (editor?.options?.allowHTMLPaste) {
fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData); fragment = getFormattedOrPlainFragmentFromClipboardData(
event.clipboardData,
);
} else { } else {
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData); fragment = getPlainFragmentFromClipboardData(
selectionController,
event.clipboardData,
);
} }
if (!fragment) { if (!fragment) {
@ -92,10 +100,9 @@ export function paste(event, editor, selectionController) {
if (selectionController.isCollapsed) { if (selectionController.isCollapsed) {
const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; const forceTextSpan =
if (hasOnlyOneParagraph fragment.firstElementChild.dataset.textSpan === "force";
&& hasOnlyOneTextSpan if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
&& forceTextSpan) {
selectionController.insertIntoFocus(fragment.textContent); selectionController.insertIntoFocus(fragment.textContent);
} else { } else {
selectionController.insertPaste(fragment); selectionController.insertPaste(fragment);
@ -103,10 +110,9 @@ export function paste(event, editor, selectionController) {
} else { } else {
const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; const forceTextSpan =
if (hasOnlyOneParagraph fragment.firstElementChild.dataset.textSpan === "force";
&& hasOnlyOneTextSpan if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
&& forceTextSpan) {
selectionController.replaceText(fragment.textContent); selectionController.replaceText(fragment.textContent);
} else { } else {
selectionController.replaceWithPaste(fragment); selectionController.replaceWithPaste(fragment);

View File

@ -23,7 +23,7 @@ export function deleteContentBackward(event, editor, selectionController) {
// If not is collapsed AKA is a selection, then // If not is collapsed AKA is a selection, then
// we removeSelected. // we removeSelected.
if (!selectionController.isCollapsed) { if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: 'backward' }); return selectionController.removeSelected({ direction: "backward" });
} }
// If we're in a text node and the offset is // If we're in a text node and the offset is
@ -32,18 +32,18 @@ export function deleteContentBackward(event, editor, selectionController) {
if (selectionController.isTextFocus && selectionController.focusOffset > 0) { if (selectionController.isTextFocus && selectionController.focusOffset > 0) {
return selectionController.removeBackwardText(); return selectionController.removeBackwardText();
// If we're in a text node but we're at the end of the // If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph // paragraph, we should merge the current paragraph
// with the following paragraph. // with the following paragraph.
} else if ( } else if (
selectionController.isTextFocus && selectionController.isTextFocus &&
selectionController.focusAtStart selectionController.focusAtStart
) { ) {
return selectionController.mergeBackwardParagraph(); return selectionController.mergeBackwardParagraph();
// If we're at an text span or a line break paragraph // If we're at an text span or a line break paragraph
// and there's more than one paragraph, then we should // and there's more than one paragraph, then we should
// remove the next paragraph. // remove the next paragraph.
} else if ( } else if (
selectionController.isTextSpanFocus || selectionController.isTextSpanFocus ||
selectionController.isLineBreakFocus selectionController.isLineBreakFocus

View File

@ -28,22 +28,21 @@ export function deleteContentForward(event, editor, selectionController) {
// If we're in a text node and the offset is // If we're in a text node and the offset is
// greater than 0 (not at the start of the text span) // greater than 0 (not at the start of the text span)
// we simple remove a character from the text. // we simple remove a character from the text.
if (selectionController.isTextFocus if (selectionController.isTextFocus && selectionController.focusAtEnd) {
&& selectionController.focusAtEnd) {
return selectionController.mergeForwardParagraph(); return selectionController.mergeForwardParagraph();
// If we're in a text node but we're at the end of the // If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph // paragraph, we should merge the current paragraph
// with the following paragraph. // with the following paragraph.
} else if ( } else if (
selectionController.isTextFocus && selectionController.isTextFocus &&
selectionController.focusOffset >= 0 selectionController.focusOffset >= 0
) { ) {
return selectionController.removeForwardText(); return selectionController.removeForwardText();
// If we're at a text span or a line break paragraph // If we're at a text span or a line break paragraph
// and there's more than one paragraph, then we should // and there's more than one paragraph, then we should
// remove the next paragraph. // remove the next paragraph.
} else if ( } else if (
(selectionController.isTextSpanFocus || (selectionController.isTextSpanFocus ||
selectionController.isLineBreakFocus) && selectionController.isLineBreakFocus) &&

View File

@ -1,11 +1,17 @@
import { describe, test, expect } from 'vitest' import { describe, test, expect } from "vitest";
import { insertInto, removeBackward, removeForward, replaceWith } from './Text'; import { insertInto, removeBackward, removeForward, replaceWith } from "./Text";
describe("Text", () => { describe("Text", () => {
test("* should throw when passed wrong parameters", () => { test("* should throw when passed wrong parameters", () => {
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string'); expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError(
expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset'); "Invalid string",
expect(() => insertInto('Hello', 0, 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", () => { test("`insertInto` should insert a string into an offset", () => {
@ -13,7 +19,9 @@ describe("Text", () => {
}); });
test("`replaceWith` should replace a string into a string", () => { test("`replaceWith` should replace a string into a string", () => {
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!"); expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe(
"Hello, World!",
);
}); });
test("`removeBackward` should remove string backward from start (offset 0)", () => { test("`removeBackward` should remove string backward from start (offset 0)", () => {
@ -26,13 +34,13 @@ describe("Text", () => {
test("`removeBackward` should remove string backward from end", () => { test("`removeBackward` should remove string backward from end", () => {
expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe( expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World" "Hello, World",
); );
}); });
test("`removeForward` should remove string forward from end", () => { test("`removeForward` should remove string forward from end", () => {
expect(removeForward("Hello, World!", "Hello, World!".length)).toBe( expect(removeForward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World!" "Hello, World!",
); );
}); });

View File

@ -24,7 +24,7 @@ function getContext() {
if (!context) { if (!context) {
context = canvas.getContext("2d"); context = canvas.getContext("2d");
} }
return context return context;
} }
/** /**

View File

@ -230,15 +230,10 @@ export function mapContentFragmentFromString(string, styleDefaults) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const line of lines) { for (const line of lines) {
if (line === "") { if (line === "") {
fragment.appendChild( fragment.appendChild(createEmptyParagraph(styleDefaults));
createEmptyParagraph(styleDefaults)
);
} else { } else {
const textSpan = createTextSpan(new Text(line), styleDefaults); const textSpan = createTextSpan(new Text(line), styleDefaults);
const paragraph = createParagraph( const paragraph = createParagraph([textSpan], styleDefaults);
[textSpan],
styleDefaults,
);
if (lines.length === 1) { if (lines.length === 1) {
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
} }

View File

@ -112,7 +112,11 @@ describe("Paragraph", () => {
const helloTextSpan = createTextSpan(new Text("Hello, ")); const helloTextSpan = createTextSpan(new Text("Hello, "));
const worldTextSpan = createTextSpan(new Text("World")); const worldTextSpan = createTextSpan(new Text("World"));
const exclTextSpan = createTextSpan(new Text("!")); const exclTextSpan = createTextSpan(new Text("!"));
const paragraph = createParagraph([helloTextSpan, worldTextSpan, exclTextSpan]); const paragraph = createParagraph([
helloTextSpan,
worldTextSpan,
exclTextSpan,
]);
const newParagraph = splitParagraphAtNode(paragraph, 1); const newParagraph = splitParagraphAtNode(paragraph, 1);
expect(newParagraph).toBeInstanceOf(HTMLDivElement); expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG); expect(newParagraph.nodeName).toBe(TAG);

View File

@ -1,5 +1,11 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from "./Root.js"; import {
createEmptyRoot,
createRoot,
setRootStyles,
TAG,
TYPE,
} from "./Root.js";
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
describe("Root", () => { describe("Root", () => {

View File

@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC * Copyright (c) KALEIDOS INC
*/ */
import StyleDeclaration from '../../controllers/StyleDeclaration.js'; import StyleDeclaration from "../../controllers/StyleDeclaration.js";
import { getFills } from "./Color.js"; import { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px"; const DEFAULT_FONT_SIZE = "16px";
@ -339,8 +339,7 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
continue; continue;
} }
let styleValue = styleObject[styleName]; let styleValue = styleObject[styleName];
if (!styleValue) if (!styleValue) continue;
continue;
if (styleName === "font-family") { if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue); styleValue = sanitizeFontFamily(styleValue);
@ -388,8 +387,10 @@ export function setStylesFromDeclaration(
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) { export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration if (
|| styleObjectOrDeclaration instanceof StyleDeclaration) { styleObjectOrDeclaration instanceof CSSStyleDeclaration ||
styleObjectOrDeclaration instanceof StyleDeclaration
) {
return setStylesFromDeclaration( return setStylesFromDeclaration(
element, element,
allowedStyles, allowedStyles,

View File

@ -22,8 +22,7 @@ import { isRoot } from "./Root.js";
*/ */
export function isTextNode(node) { export function isTextNode(node) {
if (!node) throw new TypeError("Invalid text node"); if (!node) throw new TypeError("Invalid text node");
return node.nodeType === Node.TEXT_NODE return node.nodeType === Node.TEXT_NODE || isLineBreak(node);
|| isLineBreak(node);
} }
/** /**
@ -33,8 +32,7 @@ export function isTextNode(node) {
* @returns {boolean} * @returns {boolean}
*/ */
export function isEmptyTextNode(node) { export function isEmptyTextNode(node) {
return node.nodeType === Node.TEXT_NODE return node.nodeType === Node.TEXT_NODE && node.nodeValue === "";
&& node.nodeValue === "";
} }
/** /**

View File

@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC * Copyright (c) KALEIDOS INC
*/ */
import SafeGuard from '../../controllers/SafeGuard.js'; import SafeGuard from "../../controllers/SafeGuard.js";
/** /**
* Iterator direction. * Iterator direction.
@ -58,7 +58,7 @@ export class TextNodeIterator {
startNode, startNode,
rootNode, rootNode,
skipNodes = new Set(), skipNodes = new Set(),
direction = TextNodeIteratorDirection.FORWARD direction = TextNodeIteratorDirection.FORWARD,
) { ) {
if (startNode === rootNode) { if (startNode === rootNode) {
return TextNodeIterator.findDown( return TextNodeIterator.findDown(
@ -67,7 +67,7 @@ export class TextNodeIterator {
: startNode.lastChild, : startNode.lastChild,
rootNode, rootNode,
skipNodes, skipNodes,
direction direction,
); );
} }
@ -95,7 +95,7 @@ export class TextNodeIterator {
: currentNode.lastChild, : currentNode.lastChild,
rootNode, rootNode,
skipNodes, skipNodes,
direction direction,
); );
} }
currentNode = currentNode =
@ -119,7 +119,7 @@ export class TextNodeIterator {
startNode, startNode,
rootNode, rootNode,
backTrack = new Set(), backTrack = new Set(),
direction = TextNodeIteratorDirection.FORWARD direction = TextNodeIteratorDirection.FORWARD,
) { ) {
backTrack.add(startNode); backTrack.add(startNode);
if (TextNodeIterator.isTextNode(startNode)) { if (TextNodeIterator.isTextNode(startNode)) {
@ -127,14 +127,14 @@ export class TextNodeIterator {
startNode.parentNode, startNode.parentNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
} else if (TextNodeIterator.isContainerNode(startNode)) { } else if (TextNodeIterator.isContainerNode(startNode)) {
const found = TextNodeIterator.findDown( const found = TextNodeIterator.findDown(
startNode, startNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
if (found) { if (found) {
return found; return found;
@ -144,7 +144,7 @@ export class TextNodeIterator {
startNode.parentNode, startNode.parentNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
} }
} }
@ -214,7 +214,7 @@ export class TextNodeIterator {
this.#currentNode, this.#currentNode,
this.#rootNode, this.#rootNode,
new Set(), new Set(),
TextNodeIteratorDirection.FORWARD TextNodeIteratorDirection.FORWARD,
); );
if (!nextNode) { if (!nextNode) {
@ -237,7 +237,7 @@ export class TextNodeIterator {
this.#currentNode, this.#currentNode,
this.#rootNode, this.#rootNode,
new Set(), new Set(),
TextNodeIteratorDirection.BACKWARD TextNodeIteratorDirection.BACKWARD,
); );
if (!previousNode) { if (!previousNode) {
@ -270,10 +270,8 @@ export class TextNodeIterator {
* @param {TextNode} endNode * @param {TextNode} endNode
* @yields {TextNode} * @yields {TextNode}
*/ */
* iterateFrom(startNode, endNode) { *iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition( const comparedPosition = startNode.compareDocumentPosition(endNode);
endNode
);
this.#currentNode = startNode; this.#currentNode = startNode;
SafeGuard.start(); SafeGuard.start();
while (this.#currentNode !== endNode) { while (this.#currentNode !== endNode) {

View File

@ -38,7 +38,7 @@ export class ChangeController extends EventTarget {
* @param {number} [time=500] * @param {number} [time=500]
*/ */
constructor(time = 500) { constructor(time = 500) {
super() super();
if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) { if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) {
throw new TypeError("Invalid time"); throw new TypeError("Invalid time");
} }

View File

@ -24,19 +24,19 @@ export function start() {
*/ */
export function update() { export function update() {
if (Date.now - startTime >= SAFE_GUARD_TIME) { if (Date.now - startTime >= SAFE_GUARD_TIME) {
throw new Error('Safe guard timeout'); throw new Error("Safe guard timeout");
} }
} }
let timeoutId = 0 let timeoutId = 0;
export function throwAfter(error, timeout = SAFE_GUARD_TIME) { export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
throw error throw error;
}, timeout) }, timeout);
} }
export function throwCancel() { export function throwCancel() {
clearTimeout(timeoutId) clearTimeout(timeoutId);
} }
export default { export default {

View File

@ -54,7 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js"; import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js"; import SafeGuard from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js"; import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from './StyleDeclaration.js'; import StyleDeclaration from "./StyleDeclaration.js";
/** /**
* Supported options for the SelectionController. * Supported options for the SelectionController.
@ -280,11 +280,17 @@ export class SelectionController extends EventTarget {
// FIXME: I don't like this approximation. Having to iterate nodes twice // FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing" // is bad for performance. I think we need another way of "computing"
// the cascade. // the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) { for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const paragraph = textNode.parentElement.parentElement; const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph); this.#applyStylesFromElementToCurrentStyle(paragraph);
} }
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) { for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement; const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan); this.#mergeStylesFromElementToCurrentStyle(textSpan);
} }
@ -498,19 +504,12 @@ export class SelectionController extends EventTarget {
if (!this.#savedSelection) return false; if (!this.#savedSelection) return false;
if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) { if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) {
if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) { this.#selection.setBaseAndExtent(
this.#selection.setPosition( this.#savedSelection.anchorNode,
this.#savedSelection.focusNode, this.#savedSelection.anchorOffset,
this.#savedSelection.focusOffset, this.#savedSelection.focusNode,
); this.#savedSelection.focusOffset,
} else { );
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
}
} }
this.#savedSelection = null; this.#savedSelection = null;
return true; return true;
@ -1132,10 +1131,7 @@ export class SelectionController extends EventTarget {
const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneParagraph = fragment.children.length === 1;
const forceTextSpan = const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force"; fragment.firstElementChild?.dataset?.textSpan === "force";
if ( if (hasOnlyOneParagraph && forceTextSpan) {
hasOnlyOneParagraph &&
forceTextSpan
) {
// first text span // first text span
const collapseNode = fragment.firstElementChild.firstElementChild; const collapseNode = fragment.firstElementChild.firstElementChild;
if (this.isTextSpanStart) { if (this.isTextSpanStart) {
@ -1403,7 +1399,7 @@ export class SelectionController extends EventTarget {
// the focus node is a <span>. // the focus node is a <span>.
if (isTextSpan(this.focusNode)) { if (isTextSpan(this.focusNode)) {
this.focusNode.firstElementChild.replaceWith(textNode); this.focusNode.firstElementChild.replaceWith(textNode);
// the focus node is a <br>. // the focus node is a <br>.
} else { } else {
this.focusNode.replaceWith(textNode); this.focusNode.replaceWith(textNode);
} }
@ -1981,8 +1977,7 @@ export class SelectionController extends EventTarget {
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0); this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
} }
// The styles are applied to the paragraph // The styles are applied to the paragraph
else else {
{
const paragraph = this.startParagraph; const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles); setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans. // Apply styles to child text spans.

View File

@ -278,9 +278,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello", "Hello",
); );
expect( expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, ", World!",
).toBe(", World!"); );
}); });
test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => { test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => {
@ -292,7 +292,12 @@ describe("SelectionController", () => {
textEditorMock, textEditorMock,
selection, selection,
); );
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length); focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]); const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
fragment.append(paragraph); fragment.append(paragraph);
@ -315,9 +320,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ", "Lorem ",
); );
expect(textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue).toBe( expect(
"ipsum ", textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue,
); ).toBe("ipsum ");
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
"dolor", "dolor",
); );
@ -359,25 +364,21 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello", "Hello",
); );
expect( expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, ", World!",
).toBe(", World!"); );
}); });
test("`insertPaste` should insert a text span from a pasted fragment (at start)", () => { test("`insertPaste` should insert a text span from a pasted fragment (at start)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText(", World!"); const textEditorMock =
TextEditorMock.createTextEditorMockWithText(", World!");
const root = textEditorMock.root; const root = textEditorMock.root;
const selection = document.getSelection(); const selection = document.getSelection();
const selectionController = new SelectionController( const selectionController = new SelectionController(
textEditorMock, textEditorMock,
selection, selection,
); );
focus( focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
const paragraph = createParagraph([createTextSpan(new Text("Hello"))]); const paragraph = createParagraph([createTextSpan(new Text("Hello"))]);
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@ -415,7 +416,12 @@ describe("SelectionController", () => {
textEditorMock, textEditorMock,
selection, selection,
); );
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length); focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]); const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@ -439,9 +445,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ", "Lorem ",
); );
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( expect(
"ipsum ", textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
); ).toBe("ipsum ");
expect( expect(
textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue, textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue,
).toBe("dolor"); ).toBe("dolor");
@ -461,9 +467,7 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild, root.firstChild.firstChild.firstChild,
"Hello".length, "Hello".length,
); );
const paragraph = createParagraph([ const paragraph = createParagraph([createTextSpan(new Text(", World!"))]);
createTextSpan(new Text(", World!"))
]);
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
fragment.append(paragraph); fragment.append(paragraph);
@ -486,9 +490,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello", "Hello",
); );
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( expect(
", World!", textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
); ).toBe(", World!");
}); });
test("`removeBackwardText` should remove text in backward direction (backspace)", () => { test("`removeBackwardText` should remove text in backward direction (backspace)", () => {

View File

@ -77,7 +77,10 @@ export class StyleDeclaration {
const currentValue = this.getPropertyValue(name); const currentValue = this.getPropertyValue(name);
if (this.#isQuotedValue(currentValue, value)) { if (this.#isQuotedValue(currentValue, value)) {
return this.setProperty(name, value); return this.setProperty(name, value);
} else if (currentValue === "" && value === StyleDeclaration.Property.NULL) { } else if (
currentValue === "" &&
value === StyleDeclaration.Property.NULL
) {
return this.setProperty(name, value); return this.setProperty(name, value);
} else if (currentValue === "" && ["initial", "none"].includes(value)) { } else if (currentValue === "" && ["initial", "none"].includes(value)) {
return this.setProperty(name, value); return this.setProperty(name, value);
@ -107,4 +110,4 @@ export class StyleDeclaration {
} }
} }
export default StyleDeclaration export default StyleDeclaration;

View File

@ -43,33 +43,38 @@ export class SelectionControllerDebug {
this.#elements.isParagraphStart.checked = this.#elements.isParagraphStart.checked =
selectionController.isParagraphStart; selectionController.isParagraphStart;
this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd; this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd;
this.#elements.isTextSpanStart.checked = selectionController.isTextSpanStart; this.#elements.isTextSpanStart.checked =
selectionController.isTextSpanStart;
this.#elements.isTextSpanEnd.checked = selectionController.isTextSpanEnd; this.#elements.isTextSpanEnd.checked = selectionController.isTextSpanEnd;
this.#elements.isTextAnchor.checked = selectionController.isTextAnchor; this.#elements.isTextAnchor.checked = selectionController.isTextAnchor;
this.#elements.isTextFocus.checked = selectionController.isTextFocus; this.#elements.isTextFocus.checked = selectionController.isTextFocus;
this.#elements.focusNode.value = this.getNodeDescription( this.#elements.focusNode.value = this.getNodeDescription(
selectionController.focusNode, selectionController.focusNode,
selectionController.focusOffset selectionController.focusOffset,
); );
this.#elements.focusOffset.value = selectionController.focusOffset; this.#elements.focusOffset.value = selectionController.focusOffset;
this.#elements.anchorNode.value = this.getNodeDescription( this.#elements.anchorNode.value = this.getNodeDescription(
selectionController.anchorNode, selectionController.anchorNode,
selectionController.anchorOffset selectionController.anchorOffset,
); );
this.#elements.anchorOffset.value = selectionController.anchorOffset; this.#elements.anchorOffset.value = selectionController.anchorOffset;
this.#elements.focusTextSpan.value = this.getNodeDescription( this.#elements.focusTextSpan.value = this.getNodeDescription(
selectionController.focusTextSpan selectionController.focusTextSpan,
); );
this.#elements.anchorTextSpan.value = this.getNodeDescription( this.#elements.anchorTextSpan.value = this.getNodeDescription(
selectionController.anchorTextSpan selectionController.anchorTextSpan,
); );
this.#elements.focusParagraph.value = this.getNodeDescription( this.#elements.focusParagraph.value = this.getNodeDescription(
selectionController.focusParagraph selectionController.focusParagraph,
); );
this.#elements.anchorParagraph.value = this.getNodeDescription( this.#elements.anchorParagraph.value = this.getNodeDescription(
selectionController.anchorParagraph selectionController.anchorParagraph,
);
this.#elements.startContainer.value = this.getNodeDescription(
selectionController.startContainer,
);
this.#elements.endContainer.value = this.getNodeDescription(
selectionController.endContainer,
); );
this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer);
this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer);
} }
} }

View File

@ -39,10 +39,7 @@ export class Point {
} }
polar(angle, length = 1.0) { polar(angle, length = 1.0) {
return this.set( return this.set(Math.cos(angle) * length, Math.sin(angle) * length);
Math.cos(angle) * length,
Math.sin(angle) * length
);
} }
add({ x, y }) { add({ x, y }) {
@ -119,10 +116,7 @@ export class Point {
export class Rect { export class Rect {
static create(x, y, width, height) { static create(x, y, width, height) {
return new Rect( return new Rect(new Point(width, height), new Point(x, y));
new Point(width, height),
new Point(x, y),
);
} }
#size; #size;
@ -228,10 +222,7 @@ export class Rect {
} }
clone() { clone() {
return new Rect( return new Rect(this.#size.clone(), this.#position.clone());
this.#size.clone(),
this.#position.clone(),
);
} }
toFixed(fractionDigits = 0) { toFixed(fractionDigits = 0) {

View File

@ -82,13 +82,13 @@ export class Shape {
} }
get rotation() { get rotation() {
return this.#rotation return this.#rotation;
} }
set rotation(newRotation) { set rotation(newRotation) {
if (!Number.isFinite(newRotation)) { if (!Number.isFinite(newRotation)) {
throw new TypeError('Invalid rotation') throw new TypeError("Invalid rotation");
} }
this.#rotation = newRotation this.#rotation = newRotation;
} }
} }

View File

@ -6,8 +6,7 @@ export function fromStyle(style) {
const entry = Object.entries(this).find(([name, value]) => const entry = Object.entries(this).find(([name, value]) =>
name === fromStyleValue(style) ? value : 0, name === fromStyleValue(style) ? value : 0,
); );
if (!entry) if (!entry) return;
return;
const [name] = entry; const [name] = entry;
return name; return name;

View File

@ -1,4 +1,4 @@
import { Point } from './geom'; import { Point } from "./geom";
export class Viewport { export class Viewport {
#zoom; #zoom;
@ -38,7 +38,7 @@ export class Viewport {
} }
pan(dx, dy) { pan(dx, dy) {
this.#position.x += dx / this.#zoom this.#position.x += dx / this.#zoom;
this.#position.y += dy / this.#zoom this.#position.y += dy / this.#zoom;
} }
} }

View File

@ -1,6 +1,9 @@
import { createRoot } from "../editor/content/dom/Root.js"; import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph } from "../editor/content/dom/Paragraph.js"; import { createParagraph } from "../editor/content/dom/Paragraph.js";
import { createEmptyTextSpan, createTextSpan } from "../editor/content/dom/TextSpan.js"; import {
createEmptyTextSpan,
createTextSpan,
} from "../editor/content/dom/TextSpan.js";
import { createLineBreak } from "../editor/content/dom/LineBreak.js"; import { createLineBreak } from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget { export class TextEditorMock extends EventTarget {
@ -38,14 +41,14 @@ export class TextEditorMock extends EventTarget {
static createTextEditorMockWithRoot(root) { static createTextEditorMockWithRoot(root) {
const container = TextEditorMock.getTemplate(); const container = TextEditorMock.getTemplate();
const selectionImposterElement = container.querySelector( const selectionImposterElement = container.querySelector(
".text-editor-selection-imposter" ".text-editor-selection-imposter",
); );
const textEditorMock = new TextEditorMock( const textEditorMock = new TextEditorMock(
container.querySelector(".text-editor-content"), container.querySelector(".text-editor-content"),
{ {
root, root,
selectionImposterElement, selectionImposterElement,
} },
); );
return textEditorMock; return textEditorMock;
} }
@ -86,8 +89,8 @@ export class TextEditorMock extends EventTarget {
return this.createTextEditorMockWithParagraphs([ return this.createTextEditorMockWithParagraphs([
createParagraph([ createParagraph([
text.length === 0 text.length === 0
? createEmptyTextSpan() ? createEmptyTextSpan()
: createTextSpan(new Text(text)) : createTextSpan(new Text(text)),
]), ]),
]); ]);
} }
@ -100,7 +103,9 @@ export class TextEditorMock extends EventTarget {
* @returns * @returns
*/ */
static createTextEditorMockWithParagraph(textSpans) { static createTextEditorMockWithParagraph(textSpans) {
return this.createTextEditorMockWithParagraphs([createParagraph(textSpans)]); return this.createTextEditorMockWithParagraphs([
createParagraph(textSpans),
]);
} }
#element = null; #element = null;

View File

@ -1,30 +1,28 @@
import path from "node:path"; import path from "node:path";
import fs from 'node:fs/promises'; import fs from "node:fs/promises";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { coverageConfigDefaults } from "vitest/config"; import { coverageConfigDefaults } from "vitest/config";
async function waitFor(timeInMillis) { async function waitFor(timeInMillis) {
return new Promise(resolve => return new Promise((resolve) => setTimeout((_) => resolve(), timeInMillis));
setTimeout(_ => resolve(), timeInMillis)
);
} }
const wasmWatcherPlugin = (options = {}) => { const wasmWatcherPlugin = (options = {}) => {
return { return {
name: "vite-wasm-watcher-plugin", name: "vite-wasm-watcher-plugin",
configureServer(server) { configureServer(server) {
server.watcher.add("../resources/public/js/render_wasm.wasm") server.watcher.add("../resources/public/js/render_wasm.wasm");
server.watcher.add("../resources/public/js/render_wasm.js") server.watcher.add("../resources/public/js/render_wasm.js");
server.watcher.on("change", async (file) => { server.watcher.on("change", async (file) => {
if (file.includes("../resources/")) { if (file.includes("../resources/")) {
// If we copy the files immediately, we end // If we copy the files immediately, we end
// up with an empty .js file (I don't know why). // up with an empty .js file (I don't know why).
await waitFor(100) await waitFor(100);
// copy files. // copy files.
await fs.copyFile( await fs.copyFile(
path.resolve(file), path.resolve(file),
path.resolve('./src/wasm/', path.basename(file)) path.resolve("./src/wasm/", path.basename(file)),
) );
console.log(`${file} changed`); console.log(`${file} changed`);
} }
}); });
@ -49,9 +47,7 @@ const wasmWatcherPlugin = (options = {}) => {
}; };
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [wasmWatcherPlugin()],
wasmWatcherPlugin()
],
root: "./src", root: "./src",
resolve: { resolve: {
alias: { alias: {