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
33 changed files with 249 additions and 224 deletions

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export class BasePage {
*/
static async mockRPCs(page, paths, options) {
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 { readFile } from 'node:fs/promises';
import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from '../../helpers/Transit';
import { Transit } from "../../helpers/Transit";
export class WorkspacePage extends BaseWebSocketPage {
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",
});
await workspacePage.page.waitForTimeout(1000)
await workspacePage.page.waitForTimeout(1000);
await workspacePage.waitForFirstRender();
await expect(

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { Clipboard } from '../../helpers/Clipboard';
import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
@@ -11,14 +11,14 @@ test.beforeEach(async ({ page, context }) => {
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
});
test.afterEach(async ({ context}) => {
test.afterEach(async ({ context }) => {
context.clearPermissions();
})
});
test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
@@ -36,10 +36,7 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
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();
});
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 workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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);
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, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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();
});
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 workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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 ({
page, context
page,
context,
}) => {
const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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 workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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 workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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 workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
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,
}) => {
const workspace = new WorkspacePage(page, {
textEditor: true
textEditor: true,
});
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC(
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum");
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.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");
const textContent = await workspace.textEditor.waitForTextSpanContent();

View File

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

View File

@@ -15,7 +15,7 @@
*/
export function addEventListeners(target, object, options) {
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) {
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
* @returns {Root}
*/
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) {
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined);
export function createRootFromHTML(
html,
style = undefined,
allowHTMLPaste = undefined,
) {
const fragment = mapContentFragmentFromHTML(
html,
style || undefined,
allowHTMLPaste || undefined,
);
const root = createRoot([], style);
root.replaceChildren(fragment);
resetInertElement();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
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 */
describe("Root", () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,19 +24,19 @@ export function start() {
*/
export function update() {
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) {
timeoutId = setTimeout(() => {
throw error
}, timeout)
throw error;
}, timeout);
}
export function throwCancel() {
clearTimeout(timeoutId)
clearTimeout(timeoutId);
}
export default {

View File

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

View File

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

View File

@@ -77,7 +77,10 @@ export class StyleDeclaration {
const currentValue = this.getPropertyValue(name);
if (this.#isQuotedValue(currentValue, 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);
} else if (currentValue === "" && ["initial", "none"].includes(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 =
selectionController.isParagraphStart;
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.isTextAnchor.checked = selectionController.isTextAnchor;
this.#elements.isTextFocus.checked = selectionController.isTextFocus;
this.#elements.focusNode.value = this.getNodeDescription(
selectionController.focusNode,
selectionController.focusOffset
selectionController.focusOffset,
);
this.#elements.focusOffset.value = selectionController.focusOffset;
this.#elements.anchorNode.value = this.getNodeDescription(
selectionController.anchorNode,
selectionController.anchorOffset
selectionController.anchorOffset,
);
this.#elements.anchorOffset.value = selectionController.anchorOffset;
this.#elements.focusTextSpan.value = this.getNodeDescription(
selectionController.focusTextSpan
selectionController.focusTextSpan,
);
this.#elements.anchorTextSpan.value = this.getNodeDescription(
selectionController.anchorTextSpan
selectionController.anchorTextSpan,
);
this.#elements.focusParagraph.value = this.getNodeDescription(
selectionController.focusParagraph
selectionController.focusParagraph,
);
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) {
return this.set(
Math.cos(angle) * length,
Math.sin(angle) * length
);
return this.set(Math.cos(angle) * length, Math.sin(angle) * length);
}
add({ x, y }) {
@@ -119,10 +116,7 @@ export class Point {
export class Rect {
static create(x, y, width, height) {
return new Rect(
new Point(width, height),
new Point(x, y),
);
return new Rect(new Point(width, height), new Point(x, y));
}
#size;
@@ -228,10 +222,7 @@ export class Rect {
}
clone() {
return new Rect(
this.#size.clone(),
this.#position.clone(),
);
return new Rect(this.#size.clone(), this.#position.clone());
}
toFixed(fractionDigits = 0) {

View File

@@ -82,13 +82,13 @@ export class Shape {
}
get rotation() {
return this.#rotation
return this.#rotation;
}
set rotation(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]) =>
name === fromStyleValue(style) ? value : 0,
);
if (!entry)
return;
if (!entry) return;
const [name] = entry;
return name;

View File

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

View File

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

View File

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