diff --git a/frontend/playwright/data/text-editor/get-file-blank.json b/frontend/playwright/data/text-editor/get-file-blank.json new file mode 100644 index 0000000000..160137844c --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-blank.json @@ -0,0 +1,58 @@ +{ + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 1", + "~:revn": 11, + "~:modified-at": "~m1713873823633", + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:is-shared": false, + "~:version": 46, + "~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1713536343369", + "~:data": { + "~:pages": [ + "~u66697432-c33d-8055-8006-2c62cc084cad" + ], + "~:pages-index": { + "~u66697432-c33d-8055-8006-2c62cc084cad": { + "~#penpot/pointer": [ + "~ude58c8f6-c5c2-8196-8004-3df9e2e52d88", + { + "~:created-at": "~m1713873823636" + } + ] + } + }, + "~:id": "~uc7ce0794-0992-8105-8004-38f280443849", + "~:options": { + "~:components-v2": true + }, + "~:recent-colors": [ + { + "~:color": "#0000ff", + "~:opacity": 1, + "~:id": null, + "~:file-id": null, + "~:image": null + } + ] + } +} diff --git a/frontend/playwright/data/text-editor/get-file-lorem-ipsum.json b/frontend/playwright/data/text-editor/get-file-lorem-ipsum.json new file mode 100644 index 0000000000..abac9d8452 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-lorem-ipsum.json @@ -0,0 +1,345 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Bug 11552", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": ["~ucc6f0580-449c-8019-8006-9345db077fa0"] + } + }, + "~ucc6f0580-449c-8019-8006-9345db077fa0": { + "~#shape": { + "~:y": 438, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "1s4am1jl24s", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "13p0zwl2yhc", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Lorem ipsum" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "20hf3kmyoub", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Lorem ipsum", + "~:width": 77, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 404, + "~:y": 438 + } + }, + { + "~#point": { + "~:x": 481, + "~:y": 438 + } + }, + { + "~#point": { + "~:x": 481, + "~:y": 455 + } + }, + { + "~#point": { + "~:x": 404, + "~:y": 455 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 404, + "~:selrect": { + "~#rect": { + "~:x": 404, + "~:y": 438, + "~:width": 77, + "~:height": 17, + "~:x1": 404, + "~:y1": 438, + "~:x2": 481, + "~:y2": 455 + } + }, + "~:flip-x": null, + "~:height": 17, + "~:flip-y": null + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/data/text-editor/update-file-11552.json b/frontend/playwright/data/text-editor/update-file-11552.json index 9999baf9a2..e556b830cf 100644 --- a/frontend/playwright/data/text-editor/update-file-11552.json +++ b/frontend/playwright/data/text-editor/update-file-11552.json @@ -1,5 +1 @@ -{ - "~:revn": 2, - "~:lagged": [] - -} \ No newline at end of file +w diff --git a/frontend/playwright/data/text-editor/update-file.json b/frontend/playwright/data/text-editor/update-file.json new file mode 100644 index 0000000000..da650d368c --- /dev/null +++ b/frontend/playwright/data/text-editor/update-file.json @@ -0,0 +1,4 @@ +{ + "~:revn": 2, + "~:lagged": [] +} diff --git a/frontend/playwright/helpers/Clipboard.js b/frontend/playwright/helpers/Clipboard.js new file mode 100644 index 0000000000..046b298632 --- /dev/null +++ b/frontend/playwright/helpers/Clipboard.js @@ -0,0 +1,36 @@ +export class Clipboard { + static Permission = { + ONLY_READ: ['clipboard-read'], + ONLY_WRITE: ['clipboard-write'], + ALL: ['clipboard-read', 'clipboard-write'] + } + + static enable(context, permissions) { + return context.grantPermissions(permissions) + } + + static writeText(page, text) { + return page.evaluate((text) => navigator.clipboard.writeText(text), text); + } + + static readText(page) { + return page.evaluate(() => navigator.clipboard.readText()); + } + + constructor(page, context) { + this.page = page + this.context = context + } + + enable(permissions) { + return Clipboard.enable(this.context, permissions); + } + + writeText(text) { + return Clipboard.writeText(this.page, text); + } + + readText() { + return Clipboard.readText(this.page); + } +} diff --git a/frontend/playwright/helpers/Transit.js b/frontend/playwright/helpers/Transit.js new file mode 100644 index 0000000000..f56292ed6a --- /dev/null +++ b/frontend/playwright/helpers/Transit.js @@ -0,0 +1,30 @@ +export class Transit { + static parse(value) { + if (typeof value !== 'string') + return value + + if (value.startsWith('~')) + return value.slice(2) + + return value + } + + static get(object, ...path) { + let aux = object; + for (const name of path) { + if (typeof name !== 'string') { + if (!(name in aux)) { + return undefined; + } + aux = aux[name]; + } else { + const transitName = `~:${name}`; + if (!(transitName in aux)) { + return undefined; + } + aux = aux[transitName]; + } + } + return this.parse(aux); + } +} diff --git a/frontend/playwright/ui/pages/BasePage.js b/frontend/playwright/ui/pages/BasePage.js index 628415b97b..e2d91bdc7e 100644 --- a/frontend/playwright/ui/pages/BasePage.js +++ b/frontend/playwright/ui/pages/BasePage.js @@ -1,4 +1,27 @@ export class BasePage { + /** + * Mocks multiple RPC calls in a single call. + * + * @param {Page} page + * @param {object} paths + * @param {*} options + * @returns {Promise} + */ + static async mockRPCs(page, paths, options) { + for (const [path, jsonFilename] of Object.entries(paths)) { + await this.mockRPC(page, path, jsonFilename, options) + } + } + + /** + * Mocks an RPC call using a file. + * + * @param {Page} page + * @param {string} path + * @param {string} jsonFilename + * @param {*} options + * @returns {Promise} + */ static async mockRPC(page, path, jsonFilename, options) { if (!page) { throw new TypeError("Invalid page argument. Must be a Playwright page."); @@ -93,6 +116,10 @@ export class BasePage { return this.#page; } + async mockRPCs(paths, options) { + return BasePage.mockRPCs(this.page, paths, options); + } + async mockRPC(path, jsonFilename, options) { return BasePage.mockRPC(this.page, path, jsonFilename, options); } diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index a9bb555c15..c91a431325 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -1,7 +1,146 @@ import { expect } from "@playwright/test"; +import { readFile } from 'node:fs/promises'; import { BaseWebSocketPage } from "./BaseWebSocketPage"; +import { Transit } from '../../helpers/Transit'; export class WorkspacePage extends BaseWebSocketPage { + static TextEditor = class TextEditor { + constructor(workspacePage) { + this.workspacePage = workspacePage; + + // locators. + this.fontSize = this.workspacePage.rightSidebar.getByRole("textbox", { + name: "Font Size", + }); + this.lineHeight = this.workspacePage.rightSidebar.getByRole("textbox", { + name: "Line Height", + }); + this.letterSpacing = this.workspacePage.rightSidebar.getByRole( + "textbox", + { + name: "Letter Spacing", + }, + ); + } + + get page() { + return this.workspacePage.page; + } + + async waitForStyle(locator, styleName) { + return locator.evaluate( + (element, styleName) => element.style.getPropertyValue(styleName), + styleName, + ); + } + + async waitForEditor() { + return this.page.waitForSelector('[data-itype="editor"]'); + } + + async waitForRoot() { + return this.page.waitForSelector('[data-itype="root"]'); + } + + async waitForParagraph(nth) { + if (!nth) { + return this.page.waitForSelector('[data-itype="paragraph"]'); + } + return this.page.waitForSelector( + `[data-itype="paragraph"]:nth-child(${nth})`, + ); + } + + async waitForParagraphStyle(nth, styleName) { + const paragraph = await this.waitForParagraph(nth); + return this.waitForStyle(paragraph, styleName); + } + + async waitForTextSpan(nth = 0) { + if (!nth) { + return this.page.waitForSelector('[data-itype="inline"]'); + } + return this.page.waitForSelector( + `[data-itype="inline"]:nth-child(${nth})`, + ); + } + + async waitForTextSpanContent(nth = 0) { + const textSpan = await this.waitForTextSpan(nth); + const textContent = await textSpan.textContent(); + return textContent; + } + + async waitForTextSpanStyle(nth, styleName) { + const textSpan = await this.waitForTextSpan(nth); + return this.waitForStyle(textSpan, styleName); + } + + async startEditing() { + await this.page.keyboard.press("Enter"); + return this.waitForEditor(); + } + + stopEditing() { + return this.page.keyboard.press("Escape"); + } + + async moveToLeft(amount = 0) { + for (let i = 0; i < amount; i++) { + await this.page.keyboard.press("ArrowLeft"); + } + } + + async moveToRight(amount = 0) { + for (let i = 0; i < amount; i++) { + await this.page.keyboard.press("ArrowRight"); + } + } + + async moveFromStart(offset = 0) { + await this.page.keyboard.press("ArrowLeft"); + await this.moveToRight(offset); + } + + async moveFromEnd(offset = 0) { + await this.page.keyboard.press("ArrowRight"); + await this.moveToLeft(offset); + } + + async selectFromStart(length, offset = 0) { + await this.moveFromStart(offset); + await this.page.keyboard.down("Shift"); + await this.moveToRight(length); + await this.page.keyboard.up("Shift"); + } + + async selectFromEnd(length, offset = 0) { + await this.moveFromEnd(offset); + await this.page.keyboard.down("Shift"); + await this.moveToLeft(length); + await this.page.keyboard.up("Shift"); + } + + async changeNumericInput(locator, newValue) { + await expect(locator).toBeVisible(); + await locator.focus(); + await locator.fill(`${newValue}`); + await locator.blur(); + } + + changeFontSize(newValue) { + return this.changeNumericInput(this.fontSize, newValue); + } + + changeLineHeight(newValue) { + return this.changeNumericInput(this.lineHeight, newValue); + } + + changeLetterSpacing(newValue) { + return this.changeNumericInput(this.letterSpacing, newValue); + } + }; + /** * This should be called on `test.beforeEach`. * @@ -11,50 +150,21 @@ export class WorkspacePage extends BaseWebSocketPage { static async init(page) { await BaseWebSocketPage.initWebSockets(page); - await BaseWebSocketPage.mockRPC( - page, - "get-profile", - "logged-in-user/get-profile-logged-in.json", - ); - await BaseWebSocketPage.mockRPC( - page, - "get-team-users?file-id=*", - "logged-in-user/get-team-users-single-user.json", - ); - await BaseWebSocketPage.mockRPC( - page, - "get-comment-threads?file-id=*", - "workspace/get-comment-threads-empty.json", - ); - await BaseWebSocketPage.mockRPC( - page, - "get-project?id=*", - "workspace/get-project-default.json", - ); - await BaseWebSocketPage.mockRPC( - page, - "get-team?id=*", - "workspace/get-team-default.json", - ); - await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json"); - - await BaseWebSocketPage.mockRPC( - page, - "get-team-members?team-id=*", - "logged-in-user/get-team-members-your-penpot.json", - ); - - await BaseWebSocketPage.mockRPC( - page, - "get-profiles-for-file-comments?file-id=*", - "workspace/get-profile-for-file-comments.json", - ); - - await BaseWebSocketPage.mockRPC( - page, - "update-profile-props", - "workspace/update-profile-empty.json", - ); + await BaseWebSocketPage.mockRPCs(page, { + "get-profile": "logged-in-user/get-profile-logged-in.json", + "get-team-users?file-id=*": + "logged-in-user/get-team-users-single-user.json", + "get-comment-threads?file-id=*": + "workspace/get-comment-threads-empty.json", + "get-project?id=*": "workspace/get-project-default.json", + "get-team?id=*": "workspace/get-team-default.json", + "get-teams": "get-teams.json", + "get-team-members?team-id=*": + "logged-in-user/get-team-members-your-penpot.json", + "get-profiles-for-file-comments?file-id=*": + "workspace/get-profile-for-file-comments.json", + "update-profile-props": "workspace/update-profile-empty.json", + }); } static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a"; @@ -62,9 +172,20 @@ export class WorkspacePage extends BaseWebSocketPage { static anyFileId = "c7ce0794-0992-8105-8004-38f280443849"; static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a"; + /** + * WebSocket mock + * + * @type {MockWebSocketHelper} + */ #ws = null; - constructor(page) { + /** + * Constructor + * + * @param {Page} page + * @param {} [options] + */ + constructor(page, options) { super(page); this.pageName = page.getByTestId("page-name"); @@ -112,11 +233,14 @@ export class WorkspacePage extends BaseWebSocketPage { "tokens-context-menu-for-set", ); this.contextMenuForShape = page.getByTestId("context-menu"); + if (options?.textEditor) { + this.textEditor = new WorkspacePage.TextEditor(this); + } } async goToWorkspace({ - fileId = WorkspacePage.anyFileId, - pageId = WorkspacePage.anyPageId, + fileId = this.fileId ?? WorkspacePage.anyFileId, + pageId = this.pageId ?? WorkspacePage.anyPageId, } = {}) { await this.page.goto( `/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`, @@ -141,48 +265,59 @@ export class WorkspacePage extends BaseWebSocketPage { } async setupEmptyFile() { - await this.mockRPC( - "get-profile", - "logged-in-user/get-profile-logged-in.json", - ); - await this.mockRPC( - "get-team-users?file-id=*", - "logged-in-user/get-team-users-single-user.json", - ); - await this.mockRPC( - "get-comment-threads?file-id=*", - "workspace/get-comment-threads-empty.json", - ); - await this.mockRPC( - "get-project?id=*", - "workspace/get-project-default.json", - ); - await this.mockRPC("get-team?id=*", "workspace/get-team-default.json"); - await this.mockRPC( - "get-profiles-for-file-comments?file-id=*", - "workspace/get-profile-for-file-comments.json", - ); - await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json"); - await this.mockRPC( - "get-file-object-thumbnails?file-id=*", - "workspace/get-file-object-thumbnails-blank.json", - ); - await this.mockRPC( - "get-font-variants?team-id=*", - "workspace/get-font-variants-empty.json", - ); - await this.mockRPC( - "get-file-fragment?file-id=*", - "workspace/get-file-fragment-blank.json", - ); - await this.mockRPC( - "get-file-libraries?file-id=*", - "workspace/get-file-libraries-empty.json", - ); + await this.mockRPCs({ + "get-profile": "logged-in-user/get-profile-logged-in.json", + "get-team-users?file-id=*": + "logged-in-user/get-team-users-single-user.json ", + "get-comment-threads?file-id=*": + "workspace/get-comment-threads-empty.json", + "get-project?id=*": "workspace/get-project-default.json", + "get-team?id=*": "workspace/get-team-default.json", + "get-profiles-for-file-comments?file-id=*": + "workspace/get-profile-for-file-comments.json", + "get-file-object-thumbnails?file-id=*": + "workspace/get-file-object-thumbnails-blank.json", + "get-font-variants?team-id=*": "workspace/get-font-variants-empty.json", + "get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json", + "get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json", + }); + + if (this.textEditor) { + await this.mockRPC("update-file?id=*", "text-editor/update-file.json"); + } + + // by default we mock the blank file. + await this.mockGetFile("workspace/get-file-blank.json"); } - async mockGetFile(jsonFile) { - await this.mockRPC(/get\-file\?/, jsonFile); + async mockGetFile(jsonFilename, options) { + const page = this.page; + const jsonPath = `playwright/data/${jsonFilename}`; + const body = await readFile(jsonPath, "utf-8"); + const payload = JSON.parse(body); + + const fileId = Transit.get(payload, "id"); + const pageId = Transit.get(payload, "data", "pages", 0); + const teamId = Transit.get(payload, "team-id"); + + this.fileId = fileId ?? this.anyFileId; + this.pageId = pageId ?? this.anyPageId; + this.teamId = teamId ?? this.anyTeamId; + + const path = /get\-file\?/; + const url = typeof path === "string" ? `**/api/main/methods/${path}` : path; + const interceptConfig = { + status: 200, + contentType: "application/transit+json", + ...options, + }; + return page.route(url, (route) => + route.fulfill({ + ...interceptConfig, + body, + }), + ); + // await this.mockRPC(/get\-file\?/, jsonFile); } async mockGetAsset(regex, asset) { @@ -190,22 +325,15 @@ export class WorkspacePage extends BaseWebSocketPage { } async setupFileWithComments() { - await this.mockRPC( - "get-comment-threads?file-id=*", - "workspace/get-comment-threads-unread.json", - ); - await this.mockRPC( - "get-file-fragment?file-id=*&fragment-id=*", - "viewer/get-file-fragment-single-board.json", - ); - await this.mockRPC( - "get-comments?thread-id=*", - "workspace/get-thread-comments.json", - ); - await this.mockRPC( - "update-comment-thread-status", - "workspace/update-comment-thread-status.json", - ); + await this.mockRPCs({ + "get-comment-threads?file-id=*": + "workspace/get-comment-threads-unread.json", + "get-file-fragment?file-id=*&fragment-id=*": + "viewer/get-file-fragment-single-board.json", + "get-comments?thread-id=*": "workspace/get-thread-comments.json", + "update-comment-thread-status": + "workspace/update-comment-thread-status.json", + }); } async clickWithDragViewportAt(x, y, width, height) { @@ -223,6 +351,67 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.mouse.up(); } + /** + * Clicks and moves from the coordinates x1,y1 to x2,y2 + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + */ + async clickAndMove(x1, y1, x2, y2) { + await this.page.waitForTimeout(100); + await this.viewport.hover({ position: { x: x1, y: y1 } }); + await this.page.mouse.down(); + await this.viewport.hover({ position: { x: x2, y: y2 } }); + await this.page.mouse.up(); + } + + /** + * Creates a new Text Shape in the specified coordinates + * with an initial text. + * + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {string} initialText + * @param {*} [options] + */ + async createTextShape(x1, y1, x2, y2, initialText, options) { + const timeToWait = options?.timeToWait ?? 100; + await this.page.keyboard.press("T"); + await this.page.waitForTimeout(timeToWait); + await this.clickAndMove(x1, y1, x2, y2); + await this.page.waitForTimeout(timeToWait); + if (initialText) { + await this.page.keyboard.type(initialText); + } + } + + /** + * Copies the selected element into the clipboard. + * + * @returns {Promise} + */ + async copy() { + return this.page.keyboard.press("Control+C"); + } + + /** + * Pastes something from the clipboard. + * + * @param {"keyboard"|"context-menu"} [kind="keyboard"] + * @returns {Promise} + */ + async paste(kind = "keyboard") { + if (kind === "context-menu") { + await this.viewport.click({ button: "right" }); + return this.page.getByText("PasteCtrlV").click(); + } + return this.page.keyboard.press("Control+V"); + } + async panOnViewportAt(x, y, width, height) { await this.page.waitForTimeout(100); await this.viewport.hover({ position: { x, y } }); @@ -250,10 +439,15 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.waitForTimeout(500); } + async doubleClickLeafLayer(name, clickOptions = {}) { + await this.clickLeafLayer(name, clickOptions); + await this.clickLeafLayer(name, clickOptions); + } + async clickToggableLayer(name, clickOptions = {}) { const layer = this.layers - .getByTestId("layer-row") - .filter({ hasText: name }); + .getByTestId("layer-row") + .filter({ hasText: name }); const button = layer.getByRole("button"); await button.waitFor(); diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index 5b268d2f82..dfef62049f 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -1,12 +1,317 @@ import { test, expect } from "@playwright/test"; +import { Clipboard } from '../../helpers/Clipboard'; import { WorkspacePage } from "../pages/WorkspacePage"; -test.beforeEach(async ({ page }) => { +const timeToWait = 100; + +test.beforeEach(async ({ page, context }) => { + await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE); + await WorkspacePage.init(page); await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); }); -test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => { +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 + }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.createTextShape(190, 150, 300, 200, initialText); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe(initialText); + + await workspace.textEditor.stopEditing(); +}); + +test("Create a new text shape from pasting text", async ({ page, context }) => { + const textToPaste = "Lorem ipsum"; + const workspace = new WorkspacePage(page, { + textEditor: true, + }); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + "update-file?id=*", + "text-editor/update-file.json", + ); + await workspace.goToWorkspace(); + + await Clipboard.writeText(page, textToPaste); + + await workspace.clickAt(190, 150); + await workspace.paste("keyboard"); + + await page.waitForTimeout(timeToWait); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe(textToPaste); + + await workspace.textEditor.stopEditing(); +}); + +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 + }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + + await Clipboard.writeText(page, textToPaste); + + await workspace.clickAt(190, 150); + await workspace.paste("context-menu"); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe(textToPaste); + + await workspace.textEditor.stopEditing(); +}) + +test("Update an already created text shape by appending text", async ({ page }) => { + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.moveFromEnd(0); + await page.keyboard.type(" dolor sit amet"); + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Lorem ipsum dolor sit amet"); + await workspace.textEditor.stopEditing(); +}); + +test("Update an already created text shape by prepending text", async ({ + page, +}) => { + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.moveFromStart(0); + await page.keyboard.type("Dolor sit amet "); + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Dolor sit amet Lorem ipsum"); + await workspace.textEditor.stopEditing(); +}); + +test("Update an already created text shape by inserting text in between", async ({ + page, +}) => { + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.moveFromStart(5); + await page.keyboard.type(" dolor sit amet"); + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Lorem dolor sit amet ipsum"); + await workspace.textEditor.stopEditing(); +}); + +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 + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + + await Clipboard.writeText(page, textToPaste); + + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.moveFromEnd(); + await workspace.paste("keyboard"); + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Lorem ipsum dolor sit amet"); + await workspace.textEditor.stopEditing(); +}); + +test("Update a new text shape prepending text by pasting text", async ({ + page, context +}) => { + const textToPaste = "Dolor sit amet "; + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + + await Clipboard.writeText(page, textToPaste); + + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.moveFromStart(); + await workspace.paste("keyboard"); + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Dolor sit amet Lorem ipsum"); + await workspace.textEditor.stopEditing(); +}); + +test("Update a new text shape replacing (starting) text with pasted text", async ({ + page, +}) => { + const textToPaste = "Dolor sit amet"; + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.selectFromStart(5); + + await Clipboard.writeText(page, textToPaste); + + await workspace.paste("keyboard"); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Dolor sit amet ipsum"); + + await workspace.textEditor.stopEditing(); +}); + +test("Update a new text shape replacing (ending) text with pasted text", async ({ + page, +}) => { + const textToPaste = "dolor sit amet"; + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.selectFromEnd(5); + + await Clipboard.writeText(page, textToPaste); + + await workspace.paste("keyboard"); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Lorem dolor sit amet"); + + await workspace.textEditor.stopEditing(); +}); + +test("Update a new text shape replacing (in between) text with pasted text", async ({ + page, +}) => { + const textToPaste = "dolor sit amet"; + const workspace = new WorkspacePage(page, { + textEditor: true + }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.selectFromStart(5, 3); + + await Clipboard.writeText(page, textToPaste); + + await workspace.paste("keyboard"); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Lordolor sit ametsum"); + + await workspace.textEditor.stopEditing(); +}); + +test("Update text font size selecting a part of it (starting)", async ({ + page, +}) => { + const workspace = new WorkspacePage(page, { + 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.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.selectFromStart(5); + await workspace.textEditor.changeFontSize(36); + + const textContent1 = await workspace.textEditor.waitForTextSpanContent(1); + expect(textContent1).toBe("Lorem"); + const textContent2 = await workspace.textEditor.waitForTextSpanContent(2); + expect(textContent2).toBe(" ipsum"); + await workspace.textEditor.stopEditing(); +}); + +test.skip("Update text line height selecting a part of it (starting)", async ({ + page, +}) => { + const workspace = new WorkspacePage(page, { + 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.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.selectFromStart(5); + await workspace.textEditor.changeLineHeight(1.4); + + const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height'); + expect(lineHeight).toBe("1.4"); + + const textContent = await workspace.textEditor.waitForTextSpanContent(); + expect(textContent).toBe("Lorem ipsum"); + + await workspace.textEditor.stopEditing(); +}); + +test.skip("Update text letter spacing selecting a part of it (starting)", async ({ + page, +}) => { + const workspace = new WorkspacePage(page, { + 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.goToWorkspace(); + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + await workspace.textEditor.selectFromStart(5); + await workspace.textEditor.changeLetterSpacing(10); + + const textContent1 = await workspace.textEditor.waitForTextSpanContent(1); + expect(textContent1).toBe("Lorem"); + const textContent2 = await workspace.textEditor.waitForTextSpanContent(2); + expect(textContent2).toBe(" ipsum"); + await workspace.textEditor.stopEditing(); +}); + +test("BUG 11552 - Apply styles to the current caret", async ({ page }) => { const workspace = new WorkspacePage(page); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-11552.json"); @@ -14,21 +319,16 @@ test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => { "update-file?id=*", "text-editor/update-file-11552.json", ); - - await workspace.goToWorkspace({ - fileId: "238a17e0-75ff-8075-8006-934586ea2230", - pageId: "238a17e0-75ff-8075-8006-934586ea2231", - }); - await workspace.clickLeafLayer("Lorem ipsum"); - await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.goToWorkspace(); + await workspace.doubleClickLeafLayer("Lorem ipsum"); const fontSizeInput = workspace.rightSidebar.getByRole("textbox", { name: "Font Size", }); await expect(fontSizeInput).toBeVisible(); - await workspace.page.keyboard.press("Enter"); - await workspace.page.keyboard.press("ArrowRight"); + await page.keyboard.press("Enter"); + await page.keyboard.press("ArrowRight"); await fontSizeInput.fill("36"); diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index a82f9ed167..eb7312744c 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -831,7 +831,8 @@ (effect [_ state _] (when (features/active-feature? state "text-editor/v2") (let [instance (:workspace-editor state) - attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration)) + attrs-to-override (some-> (editor.v2/getCurrentStyle instance) + (styles/get-styles-from-style-declaration)) overriden-attrs (merge attrs-to-override attrs) styles (styles/attrs->styles overriden-attrs)] (editor.v2/applyStylesToSelection instance styles)))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index e8312bd19c..6498e57d10 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -378,6 +378,7 @@ :step 0.1 :default-value "1.2" :class (stl/css :line-height-input) + :aria-label (tr "inspect.attributes.typography.line-height") :value (attr->string line-height) :placeholder (if (= :multiple line-height) (tr "settings.multiple") "--") :nillable (= :multiple line-height) @@ -396,6 +397,7 @@ :step 0.1 :default-value "0" :class (stl/css :letter-spacing-input) + :aria-label (tr "inspect.attributes.typography.letter-spacing") :value (attr->string letter-spacing) :placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--") :on-change #(handle-change % :letter-spacing) diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index 8cab737417..f37938525c 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -48,6 +48,9 @@ "This function strips units from attr values and un-scapes font-family" [k v] (cond + (= v "mixed") + :multiple + (and (or (= k :font-size) (= k :letter-spacing)) (= (str/slice v -2) "px")) diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index 8792e4d684..92e9111eff 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -26,6 +26,7 @@ import LayoutType from "./layout/LayoutType.js"; * @typedef {Object} TextEditorOptions * @property {CSSStyleDeclaration|Object.} [styleDefaults] * @property {SelectionControllerDebug} [debug] + * @property {boolean} [shouldUpdatePositionOnScroll=false] * @property {boolean} [allowHTMLPaste=false] */ @@ -92,6 +93,21 @@ export class TextEditor extends EventTarget { */ #canvas = null; + /** + * Text editor options. + * + * @type {TextEditorOptions} + */ + #options = {}; + + /** + * A boolean indicating that this instance was + * disposed or not. + * + * @type {boolean} + */ + #isDisposed = false; + /** * Constructor. * @@ -101,9 +117,9 @@ export class TextEditor extends EventTarget { */ constructor(element, canvas, options) { super(); - if (!(element instanceof HTMLElement)) + if (!(element instanceof HTMLElement)) { throw new TypeError("Invalid text editor element"); - + } this.#element = element; this.#canvas = canvas; this.#events = { @@ -119,6 +135,7 @@ export class TextEditor extends EventTarget { keydown: this.#onKeyDown, }; this.#styleDefaults = options?.styleDefaults; + this.#options = options; this.#setup(options); } @@ -150,14 +167,18 @@ export class TextEditor extends EventTarget { /** * Setups the root element. + * + * @param {TextEditorOptions} options */ - #setupRoot() { + #setupRoot(options) { this.#root = createEmptyRoot(this.#styleDefaults); this.#element.appendChild(this.#root); } /** * Setups event listeners. + * + * @param {TextEditorOptions} options */ #setupListeners(options) { this.#changeController.addEventListener("change", this.#onChange); @@ -174,18 +195,61 @@ export class TextEditor extends EventTarget { } /** - * Setups the elements, the properties and the - * initial content. + * Disposes everything. */ - #setup(options) { - this.#setupElementProperties(options); - this.#setupRoot(options); + dispose() { + if (this.#isDisposed) { + return this; + } + this.#isDisposed = true; + + // Dispose change controller. + this.#changeController.removeEventListener("change", this.#onChange); + this.#changeController.dispose(); + this.#changeController = null; + + // Disposes selection controller. + this.#selectionController.removeEventListener( + "stylechange", + this.#onStyleChange, + ); + this.#selectionController.dispose(); + this.#selectionController = null; + + // Disposes the rest of event listeners. + removeEventListeners(this.#element, this.#events); + if (this.#options.shouldUpdatePositionOnScroll) { + window.removeEventListener("scroll", this.#onScroll); + } + + // Disposes references to DOM elements. + this.#element = null; + this.#root = null; + return this; + } + + /** + * Setups controllers. + * + * @param {TextEditorOptions} options + */ + #setupControllers(options) { this.#changeController = new ChangeController(this); this.#selectionController = new SelectionController( this, document.getSelection(), options, ); + } + + /** + * Setups the elements, the properties and the + * initial content. + */ + #setup(options) { + this.#setupElementProperties(options); + this.#setupRoot(options); + this.#setupControllers(options); this.#setupListeners(options); } @@ -242,7 +306,9 @@ export class TextEditor extends EventTarget { * @param {CustomEvent} e * @returns {void} */ - #onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e)); + #onChange = (e) => { + this.dispatchEvent(new e.constructor(e.type, e)); + }; /** * Dispatchs a `stylechange` event. @@ -421,6 +487,15 @@ export class TextEditor extends EventTarget { ); } + /** + * Indicates that the TextEditor was disposed. + * + * @type {boolean} + */ + get isDisposed() { + return this.#isDisposed; + } + /** * Root element that contains all the paragraphs. * @@ -478,6 +553,15 @@ export class TextEditor extends EventTarget { return this.#selectionController.currentStyle; } + /** + * Text editor options + * + * @type {TextEditorOptions} + */ + get options() { + return this.#options; + } + /** * Focus the element */ @@ -540,7 +624,8 @@ export class TextEditor extends EventTarget { * Applies the current styles to the selection or * the current DOM node at the caret. * - * @param {*} styles + * @param {Object.} styles + * @returns {TextEditor} */ applyStylesToSelection(styles) { this.#selectionController.startMutation(); @@ -553,6 +638,8 @@ export class TextEditor extends EventTarget { /** * Selects all content. + * + * @returns {TextEditor} */ selectAll() { this.#selectionController.selectAll(); @@ -562,30 +649,12 @@ export class TextEditor extends EventTarget { /** * Moves cursor to end. * - * @returns + * @returns {TextEditor} */ cursorToEnd() { this.#selectionController.cursorToEnd(); return this; } - - /** - * Disposes everything. - */ - dispose() { - this.#changeController.removeEventListener("change", this.#onChange); - this.#changeController.dispose(); - this.#changeController = null; - this.#selectionController.removeEventListener( - "stylechange", - this.#onStyleChange, - ); - this.#selectionController.dispose(); - this.#selectionController = null; - removeEventListeners(this.#element, this.#events); - this.#element = null; - this.#root = null; - } } /** @@ -615,47 +684,98 @@ export function createRootFromString(string) { return root; } -export function isEditor(instance) { +/** + * Returns true if the passed object is a TextEditor + * instance. + * + * @param {*} instance + * @returns {boolean} + */ +export function isTextEditor(instance) { return instance instanceof TextEditor; } -/* Convenience function based API for Text Editor */ +/** + * Returns the root element of a TextEditor + * instance. + * + * @param {TextEditor} instance + * @returns {HTMLDivElement} + */ export function getRoot(instance) { - if (isEditor(instance)) { + if (isTextEditor(instance)) { return instance.root; - } else { - return null; } + return null; } +/** + * Sets the root of the text editor. + * + * @param {TextEditor} instance + * @param {HTMLDivElement} root + * @returns {TextEditor} + */ export function setRoot(instance, root) { - if (isEditor(instance)) { + if (isTextEditor(instance)) { instance.root = root; } return instance; } +/** + * Creates a new TextEditor instance. + * + * @param {HTMLDivElement} element + * @param {HTMLCanvasElement} canvas + * @param {TextEditorOptions} options + * @returns {TextEditor} + */ export function create(element, canvas, options) { return new TextEditor(element, canvas, { ...options }); } +/** + * Returns the current style of the TextEditor instance. + * + * @param {TextEditor} instance + * @returns {CSSStyleDeclaration|undefined} + */ export function getCurrentStyle(instance) { - if (isEditor(instance)) { + if (isTextEditor(instance)) { return instance.currentStyle; } + return null; } +/** + * Applies the specified styles to the TextEditor + * passed. + * + * @param {TextEditor} instance + * @param {Object.} styles + * @returns {TextEditor|null} + */ export function applyStylesToSelection(instance, styles) { - if (isEditor(instance)) { + if (isTextEditor(instance)) { return instance.applyStylesToSelection(styles); } + return null; } +/** + * Disposes the current instance resources by nullifying + * every property. + * + * @param {TextEditor} instance + * @returns {TextEditor|null} + */ export function dispose(instance) { - if (isEditor(instance)) { - instance.dispose(); + if (isTextEditor(instance)) { + return instance.dispose(); } + return null; } export default TextEditor; diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index 9daa1c3201..cc63579062 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -10,6 +10,7 @@ import { mapContentFragmentFromHTML, mapContentFragmentFromString, } from "../content/dom/Content.js"; +import { TextEditor } from "../TextEditor.js"; /** * Returns a DocumentFragment from text/html. @@ -38,19 +39,26 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) { } /** - * Returns a DocumentFragment (or null) if it contains - * a compatible clipboardData type. + * Returns a document fragment of html data. * * @param {DataTransfer} clipboardData - * @returns {DocumentFragment|null} + * @returns {DocumentFragment} */ -function getFragmentFromClipboardData(selectionController, clipboardData) { +function getFormattedOrPlainFragmentFromClipboardData( + selectionController, + clipboardData, +) { if (clipboardData.types.includes("text/html")) { - return getFormattedFragmentFromClipboardData(selectionController, clipboardData) + return getFormattedFragmentFromClipboardData( + selectionController, + clipboardData, + ); } else if (clipboardData.types.includes("text/plain")) { - return getPlainFragmentFromClipboardData(selectionController, clipboardData) + return getPlainFragmentFromClipboardData( + selectionController, + clipboardData, + ); } - return null } /** @@ -71,18 +79,37 @@ export function paste(event, editor, selectionController) { let fragment = null; if (editor?.options?.allowHTMLPaste) { - fragment = getFragmentFromClipboardData(selectionController, event.clipboardData); + fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData); } else { fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData); } if (!fragment) { + // NOOP return; } if (selectionController.isCollapsed) { - selectionController.insertPaste(fragment); + const hasOnlyOneParagraph = fragment.children.length === 1; + const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; + const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; + if (hasOnlyOneParagraph + && hasOnlyOneTextSpan + && forceTextSpan) { + selectionController.insertIntoFocus(fragment.textContent); + } else { + selectionController.insertPaste(fragment); + } } else { - selectionController.replaceWithPaste(fragment); + const hasOnlyOneParagraph = fragment.children.length === 1; + const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; + const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; + if (hasOnlyOneParagraph + && hasOnlyOneTextSpan + && forceTextSpan) { + selectionController.replaceText(fragment.textContent); + } else { + selectionController.replaceWithPaste(fragment); + } } } diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 89bd0cd0b5..357c7fbe42 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -230,14 +230,19 @@ export function mapContentFragmentFromString(string, styleDefaults) { const fragment = document.createDocumentFragment(); for (const line of lines) { if (line === "") { - fragment.appendChild(createEmptyParagraph(styleDefaults)); - } else { fragment.appendChild( - createParagraph( - [createTextSpan(new Text(line), styleDefaults)], - styleDefaults, - ), + createEmptyParagraph(styleDefaults) ); + } else { + const textSpan = createTextSpan(new Text(line), styleDefaults); + const paragraph = createParagraph( + [textSpan], + styleDefaults, + ); + if (lines.length === 1) { + paragraph.dataset.textSpan = "force"; + } + fragment.appendChild(paragraph); } } return fragment; diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js index 63027d6b0c..0a7f31d02a 100644 --- a/frontend/text-editor/src/editor/content/dom/Style.js +++ b/frontend/text-editor/src/editor/content/dom/Style.js @@ -6,6 +6,7 @@ * Copyright (c) KALEIDOS INC */ +import StyleDeclaration from '../../controllers/StyleDeclaration.js'; import { getFills } from "./Color.js"; const DEFAULT_FONT_SIZE = "16px"; @@ -386,7 +387,8 @@ export function setStylesFromDeclaration( * @returns {HTMLElement} */ export function setStyles(element, allowedStyles, styleObjectOrDeclaration) { - if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) { + if (styleObjectOrDeclaration instanceof CSSStyleDeclaration + || styleObjectOrDeclaration instanceof StyleDeclaration) { return setStylesFromDeclaration( element, allowedStyles, @@ -426,13 +428,15 @@ export function mergeStyles(allowedStyles, styleDeclaration, newStyles) { const mergedStyles = {}; for (const [styleName, styleUnit] of allowedStyles) { if (styleName in newStyles) { - mergedStyles[styleName] = newStyles[styleName]; + const styleValue = newStyles[styleName]; + mergedStyles[styleName] = styleValue; } else { - mergedStyles[styleName] = getStyleFromDeclaration( + const styleValue = getStyleFromDeclaration( styleDeclaration, styleName, styleUnit, ); + mergedStyles[styleName] = styleValue; } } return mergedStyles; diff --git a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js index 588a96c531..08357d0dd0 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js +++ b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js @@ -6,6 +6,8 @@ * Copyright (c) KALEIDOS INC */ +import SafeGuard from '../../controllers/SafeGuard.js'; + /** * Iterator direction. * @@ -245,6 +247,51 @@ export class TextNodeIterator { this.#currentNode = previousNode; return this.#currentNode; } + + /** + * Returns an array of text nodes. + * + * @param {TextNode} startNode + * @param {TextNode} endNode + * @returns {Array} + */ + collectFrom(startNode, endNode) { + const nodes = []; + for (const node of this.iterateFrom(startNode, endNode)) { + nodes.push(node); + } + return nodes; + } + + /** + * Iterates over a list of nodes. + * + * @param {TextNode} startNode + * @param {TextNode} endNode + * @yields {TextNode} + */ + * iterateFrom(startNode, endNode) { + const comparedPosition = startNode.compareDocumentPosition( + endNode + ); + this.#currentNode = startNode; + SafeGuard.start(); + while (this.#currentNode !== endNode) { + yield this.#currentNode; + SafeGuard.update(); + if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) { + if (!this.previousNode()) { + break; + } + } else if (comparedPosition === Node.DOCUMENT_POSITION_FOLLOWING) { + if (!this.nextNode()) { + break; + } + } else { + break; + } + } + } } export default TextNodeIterator; diff --git a/frontend/text-editor/src/editor/content/dom/Inline.test.js b/frontend/text-editor/src/editor/content/dom/TextSpan.test.js similarity index 100% rename from frontend/text-editor/src/editor/content/dom/Inline.test.js rename to frontend/text-editor/src/editor/content/dom/TextSpan.test.js diff --git a/frontend/text-editor/src/editor/controllers/SafeGuard.js b/frontend/text-editor/src/editor/controllers/SafeGuard.js index e3afedc183..c19f05eb41 100644 --- a/frontend/text-editor/src/editor/controllers/SafeGuard.js +++ b/frontend/text-editor/src/editor/controllers/SafeGuard.js @@ -28,7 +28,20 @@ export function update() { } } +let timeoutId = 0 +export function throwAfter(error, timeout = SAFE_GUARD_TIME) { + timeoutId = setTimeout(() => { + throw error + }, timeout) +} + +export function throwCancel() { + clearTimeout(timeoutId) +} + export default { start, update, -} + throwAfter, + throwCancel, +}; diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 35500bce61..869c4b620e 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -54,6 +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'; /** * Supported options for the SelectionController. @@ -64,39 +65,7 @@ import { sanitizeFontFamily } from "../content/dom/Style.js"; /** * SelectionController uses the same concepts used by the Selection API but extending it to support - * our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ - createParagraph([createTextSpan(new Text("Hello, "))]), - createEmptyParagraph(), - createParagraph([createTextSpan(new Text("World!"))]), - ]); - const root = textEditorMock.root; - const selection = document.getSelection(); - const selectionController = new SelectionController( - textEditorMock, - selection - ); - focus( - selection, - textEditorMock, - root.childNodes.item(2).firstChild.firstChild, - 0 - ); - selectionController.mergeBackwardParagraph(); - expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); - expect(textEditorMock.root.children.length).toBe(2); - expect(textEditorMock.root.dataset.itype).toBe("root"); - expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); - expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); - expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( - HTMLSpanElement - ); - expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( - "span" - ); - expect(textEditorMock.root.textContent).toBe("Hello, World!"); - expect(textEditorMock.root.firstChild.textContent).toBe("Hello, "); - expect(textEditorMock.root.lastChild.textContent).toBe("World!"); - t.js they were called blocks) and text spans. + * our own internal model based on paragraphs (in draft.js they were called blocks) and text spans. */ export class SelectionController extends EventTarget { /** @@ -164,21 +133,12 @@ export class SelectionController extends EventTarget { #textNodeIterator = null; /** - * CSSStyleDeclaration that we can mutate + * StyleDeclaration that we can mutate * to handle style changes. * - * @type {CSSStyleDeclaration} + * @type {StyleDeclaration} */ - #currentStyle = null; - - /** - * Element used to have a custom CSSStyleDeclaration - * that we can modify to handle style changes when the - * selection is changed. - * - * @type {HTMLDivElement} - */ - #inertElement = null; + #currentStyle = new StyleDeclaration(); /** * @type {SelectionControllerDebug} @@ -275,19 +235,58 @@ export class SelectionController extends EventTarget { * * @param {HTMLElement} element */ - #applyStylesToCurrentStyle(element) { + #applyStylesFromElementToCurrentStyle(element) { for (let index = 0; index < element.style.length; index++) { const styleName = element.style.item(index); - let styleValue = element.style.getPropertyValue(styleName); if (styleName === "font-family") { styleValue = sanitizeFontFamily(styleValue); } - this.#currentStyle.setProperty(styleName, styleValue); } } + /** + * Applies some styles to the currentStyle + * CSSStyleDeclaration + * + * @param {HTMLElement} element + */ + #mergeStylesFromElementToCurrentStyle(element) { + for (let index = 0; index < element.style.length; index++) { + const styleName = element.style.item(index); + let styleValue = element.style.getPropertyValue(styleName); + if (styleName === "font-family") { + styleValue = sanitizeFontFamily(styleValue); + } + this.#currentStyle.mergeProperty(styleName, styleValue); + } + } + + /** + * Updates current styles based on the currently selected text spans. + * + * @param {HTMLSpanElement} startNode + * @param {HTMLSpanElement} endNode + */ + #updateCurrentStyleFrom(startNode, endNode) { + this.#applyDefaultStylesToCurrentStyle(); + const root = startNode.parentElement.parentElement.parentElement; + this.#applyStylesFromElementToCurrentStyle(root); + // 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)) { + const paragraph = textNode.parentElement.parentElement; + this.#applyStylesFromElementToCurrentStyle(paragraph); + } + for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) { + const textSpan = textNode.parentElement; + this.#mergeStylesFromElementToCurrentStyle(textSpan); + } + return this; + } + /** * Updates current styles based on the currently selected text span. * @@ -297,20 +296,14 @@ export class SelectionController extends EventTarget { #updateCurrentStyle(textSpan) { this.#applyDefaultStylesToCurrentStyle(); const root = textSpan.parentElement.parentElement; - this.#applyStylesToCurrentStyle(root); + this.#applyStylesFromElementToCurrentStyle(root); const paragraph = textSpan.parentElement; - this.#applyStylesToCurrentStyle(paragraph); - this.#applyStylesToCurrentStyle(textSpan); + this.#applyStylesFromElementToCurrentStyle(paragraph); + this.#applyStylesFromElementToCurrentStyle(textSpan); return this; } - /** - * This is called on every `selectionchange` because it is dispatched - * only by the `document` object. - * - * @param {Event} e - */ - #onSelectionChange = (e) => { + #updateState() { // If we're outside the contenteditable element, then // we return. if (!this.hasFocus) { @@ -320,17 +313,23 @@ export class SelectionController extends EventTarget { let focusNodeChanges = false; let anchorNodeChanges = false; - if (this.#focusNode !== this.#selection.focusNode) { + if ( + this.#focusNode !== this.#selection.focusNode || + this.#focusOffset !== this.#selection.focusOffset + ) { this.#focusNode = this.#selection.focusNode; + this.#focusOffset = this.#selection.focusOffset; focusNodeChanges = true; } - this.#focusOffset = this.#selection.focusOffset; - if (this.#anchorNode !== this.#selection.anchorNode) { + if ( + this.#anchorNode !== this.#selection.anchorNode || + this.#anchorOffset !== this.#selection.anchorOffset + ) { this.#anchorNode = this.#selection.anchorNode; + this.#anchorOffset = this.#selection.anchorOffset; anchorNodeChanges = true; } - this.#anchorOffset = this.#selection.anchorOffset; // We need to handle multi selection from firefox // and remove all the old ranges and just keep the @@ -359,7 +358,7 @@ export class SelectionController extends EventTarget { // If focus node changed, we need to retrieve all the // styles of the current text span and dispatch an event // to notify that the styles have changed. - if (focusNodeChanges) { + if (focusNodeChanges || anchorNodeChanges) { this.#notifyStyleChange(); } @@ -372,43 +371,42 @@ export class SelectionController extends EventTarget { if (this.#debug) { this.#debug.update(this); } - }; + } + + /** + * This is called on every `selectionchange` because it is dispatched + * only by the `document` object. + * + * @param {Event} e + */ + #onSelectionChange = (_) => this.#updateState(); /** * Notifies that the styles have changed. */ #notifyStyleChange() { - const textSpan = this.focusTextSpan; - if (textSpan) { - this.#updateCurrentStyle(textSpan); - this.dispatchEvent( - new CustomEvent("stylechange", { - detail: this.#currentStyle, - }), - ); - } else { - const firstTextSpan = + if (this.#selection.isCollapsed) { + // CARET + const textSpan = + this.focusTextSpan ?? this.#textEditor.root?.firstElementChild?.firstElementChild; - if (firstTextSpan) { - this.#updateCurrentStyle(firstTextSpan); - this.dispatchEvent( - new CustomEvent("stylechange", { - detail: this.#currentStyle, - }), - ); - } + + this.#updateCurrentStyle(textSpan); + } else { + // SELECTION. + this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode); } + this.dispatchEvent( + new CustomEvent("stylechange", { + detail: this.#currentStyle, + }), + ); } /** * Setups */ #setup() { - // This element is not attached to the DOM - // so it doesn't trigger style or layout calculations. - // That's why it's called "inertElement". - this.#inertElement = document.createElement("div"); - this.#currentStyle = this.#inertElement.style; this.#applyDefaultStylesToCurrentStyle(); if (this.#selection.rangeCount > 0) { @@ -428,6 +426,22 @@ export class SelectionController extends EventTarget { document.addEventListener("selectionchange", this.#onSelectionChange); } + /** + * Disposes the current resources. + */ + dispose() { + document.removeEventListener("selectionchange", this.#onSelectionChange); + this.#textEditor = 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; + } + /** * Returns a Range-like object. * @@ -502,6 +516,8 @@ export class SelectionController extends EventTarget { * Marks the start of a mutation. * * Clears all the mutations kept in CommandMutations. + * + * @returns {boolean} */ startMutation() { this.#mutations.clear(); @@ -512,7 +528,7 @@ export class SelectionController extends EventTarget { /** * Marks the end of a mutation. * - * @returns + * @returns {CommandMutations} */ endMutation() { return this.#mutations; @@ -520,6 +536,8 @@ export class SelectionController extends EventTarget { /** * Selects all content. + * + * @returns {SelectionController} */ selectAll() { if (this.#textEditor.isEmpty) { @@ -558,23 +576,15 @@ export class SelectionController extends EventTarget { this.#selection.removeAllRanges(); this.#selection.addRange(range); - // Ensure internal state is synchronized - this.#focusNode = this.#selection.focusNode; - this.#focusOffset = this.#selection.focusOffset; - this.#anchorNode = this.#selection.anchorNode; - this.#anchorOffset = this.#selection.anchorOffset; - this.#range = range; - this.#ranges.clear(); - this.#ranges.add(range); - - // Notify style changes - this.#notifyStyleChange(); + this.#updateState(); return this; } /** * Moves cursor to end. + * + * @returns {SelectionController} */ cursorToEnd() { const range = document.createRange(); //Create a range (a range is a like the selection but invisible) @@ -662,22 +672,6 @@ export class SelectionController extends EventTarget { } } - /** - * Disposes the current resources. - */ - dispose() { - document.removeEventListener("selectionchange", this.#onSelectionChange); - this.#textEditor = 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; - } - /** * Returns the current selection. * @@ -1114,8 +1108,8 @@ export class SelectionController extends EventTarget { return isParagraphEnd(this.focusNode, this.focusOffset); } - #getFragmentInlineTextNode(fragment) { - if (isInline(fragment.firstElementChild.lastChild)) { + #getFragmentTextSpanTextNode(fragment) { + if (isTextSpan(fragment.firstElementChild.lastChild)) { return fragment.firstElementChild.firstElementChild.lastChild; } return fragment.firstElementChild.lastChild; @@ -1131,11 +1125,15 @@ export class SelectionController extends EventTarget { * @param {DocumentFragment} fragment */ insertPaste(fragment) { + const hasOnlyOneParagraph = fragment.children.length === 1; + const forceTextSpan = + fragment.firstElementChild?.dataset?.textSpan === "force"; if ( - fragment.children.length === 1 && - fragment.firstElementChild?.dataset?.textSpan === "force" + hasOnlyOneParagraph && + forceTextSpan ) { - const collapseNode = fragment.firstElementChild.firstChild; + // first text span + const collapseNode = fragment.firstElementChild.firstElementChild; if (this.isTextSpanStart) { this.focusTextSpan.before(...fragment.firstElementChild.children); } else if (this.isTextSpanEnd) { @@ -1147,7 +1145,9 @@ export class SelectionController extends EventTarget { newTextSpan, ); } - return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0); + // collapseNode could be a
, that's why we need to + // make `nodeValue` as optional. + return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0); } const collapseNode = this.#getFragmentParagraphTextNode(fragment); if (this.isParagraphStart) { @@ -1393,9 +1393,16 @@ export class SelectionController extends EventTarget { this.focusOffset, newText, ); + this.collapse(this.focusNode, this.focusOffset + newText.length); } else if (this.isLineBreakFocus) { const textNode = new Text(newText); - this.focusNode.replaceWith(textNode); + // the focus node is a . + if (isTextSpan(this.focusNode)) { + this.focusNode.firstElementChild.replaceWith(textNode); + // the focus node is a
. + } else { + this.focusNode.replaceWith(textNode); + } this.collapse(textNode, newText.length); } else { throw new Error("Unknown node type"); diff --git a/frontend/text-editor/src/editor/controllers/StyleDeclaration.js b/frontend/text-editor/src/editor/controllers/StyleDeclaration.js new file mode 100644 index 0000000000..3b2bf5d88d --- /dev/null +++ b/frontend/text-editor/src/editor/controllers/StyleDeclaration.js @@ -0,0 +1,108 @@ +export class StyleDeclaration { + static Property = class Property { + static NULL = '["~#\'",null]'; + + static Default = new Property("", "", ""); + + name; + value = ""; + priority = ""; + + constructor(name, value = "", priority = "") { + this.name = name; + this.value = value ?? ""; + this.priority = priority ?? ""; + } + }; + + #items = new Map(); + + get cssFloat() { + throw new Error("Not implemented"); + } + + get cssText() { + throw new Error("Not implemented"); + } + + get parentRule() { + throw new Error("Not implemented"); + } + + get length() { + return this.#items.size; + } + + #getProperty(name) { + return this.#items.get(name) ?? StyleDeclaration.Property.Default; + } + + getPropertyPriority(name) { + const { priority } = this.#getProperty(name); + return priority ?? ""; + } + + getPropertyValue(name) { + const { value } = this.#getProperty(name); + return value ?? ""; + } + + item(index) { + return Array.from(this.#items).at(index).name; + } + + removeProperty(name) { + const value = this.getPropertyValue(name); + this.#items.delete(name); + return value; + } + + setProperty(name, value, priority) { + this.#items.set(name, new StyleDeclaration.Property(name, value, priority)); + } + + /** Non compatible methods */ + #isQuotedValue(a, b) { + if (a.startsWith('"') && b.startsWith('"')) { + return a === b; + } else if (a.startsWith('"') && !b.startsWith('"')) { + return a.slice(1, -1) === b; + } else if (!a.startsWith('"') && b.startsWith('"')) { + return a === b.slice(1, -1); + } + return a === b; + } + + mergeProperty(name, value) { + const currentValue = this.getPropertyValue(name); + if (this.#isQuotedValue(currentValue, value)) { + return this.setProperty(name, value); + } else if (currentValue === "" && value === StyleDeclaration.Property.NULL) { + return this.setProperty(name, value); + } else if (currentValue === "" && ["initial", "none"].includes(value)) { + return this.setProperty(name, value); + } else if (currentValue !== value) { + return this.setProperty(name, "mixed"); + } + } + + fromCSSStyleDeclaration(cssStyleDeclaration) { + for (let index = 0; index < cssStyleDeclaration.length; index++) { + const name = cssStyleDeclaration.item(index); + const value = cssStyleDeclaration.getPropertyValue(name); + const priority = cssStyleDeclaration.getPropertyPriority(name); + this.setProperty(name, value, priority); + } + } + + toObject() { + return Object.fromEntries( + Array.from(this.#items.entries(), ([name, property]) => [ + name, + property.value, + ]), + ); + } +} + +export default StyleDeclaration diff --git a/frontend/text-editor/src/editor/controllers/StyleDeclaration.test.js b/frontend/text-editor/src/editor/controllers/StyleDeclaration.test.js new file mode 100644 index 0000000000..a9791190b6 --- /dev/null +++ b/frontend/text-editor/src/editor/controllers/StyleDeclaration.test.js @@ -0,0 +1,32 @@ +import { describe, test, expect } from "vitest"; +import { StyleDeclaration } from "./StyleDeclaration.js"; + +describe("StyleDeclaration", () => { + test("Create a new StyleDeclaration", () => { + const styleDeclaration = new StyleDeclaration(); + expect(styleDeclaration).toBeInstanceOf(StyleDeclaration); + }); + + test("Uninmplemented getters should throw", () => { + expect(() => styleDeclaration.cssFloat).toThrow(); + expect(() => styleDeclaration.cssText).toThrow(); + expect(() => styleDeclaration.parentRule).toThrow(); + }); + + test("Set property", () => { + const styleDeclaration = new StyleDeclaration(); + styleDeclaration.setProperty("line-height", "1.2"); + expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2"); + expect(styleDeclaration.getPropertyPriority("line-height")).toBe(""); + }); + + test("Remove property", () => { + const styleDeclaration = new StyleDeclaration(); + styleDeclaration.setProperty("line-height", "1.2"); + expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2"); + expect(styleDeclaration.getPropertyPriority("line-height")).toBe(""); + styleDeclaration.removeProperty("line-height"); + expect(styleDeclaration.getPropertyValue("line-height")).toBe(""); + expect(styleDeclaration.getPropertyPriority("line-height")).toBe(""); + }); +});