From 780cf5984a2e53336411dbcd99084e19383aa884 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 16:39:30 +0200 Subject: [PATCH 001/199] Keep local preview URL until server response --- eslint.config.js | 3 ++- src/nodes/action_text_attachment_node.js | 17 +++++++++++++++-- src/nodes/action_text_attachment_upload_node.js | 15 ++++++++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 436819e21..188ac0805 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -43,7 +43,8 @@ export default [ customElements: "readonly", Prism: "readonly", ResizeObserver: "readonly", - PointerEvent: "readonly" + PointerEvent: "readonly", + Image: "readonly" } }, rules: { diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index d8d698318..c49bf85d4 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -79,12 +79,13 @@ export class ActionTextAttachmentNode extends DecoratorNode { return Lexxy.global.get("attachmentTagName") } - constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) { + constructor({ tagName, sgid, src, previewSrc, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) { super(key) this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME this.sgid = sgid this.src = src + this.previewSrc = previewSrc this.previewable = parseBoolean(previewable) this.altText = altText || "" this.caption = caption || "" @@ -188,17 +189,29 @@ export class ActionTextAttachmentNode extends DecoratorNode { } #createDOMForImage(options = {}) { - const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options }) + const initialSrc = this.previewSrc || this.src + const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options }) if (this.previewable && !this.isPreviewableImage) { img.onerror = () => this.#swapPreviewToFileDOM(img) } + if (this.previewSrc) { + this.#preloadAndSwapSrc(img) + } + const container = createElement("div", { className: "attachment__container" }) container.appendChild(img) return container } + #preloadAndSwapSrc(img) { + const serverImage = new Image() + serverImage.onload = () => { img.src = this.src } + serverImage.onerror = () => { img.src = this.src } + serverImage.src = this.src + } + #swapPreviewToFileDOM(img) { const figure = img.closest("figure.attachment") if (!figure) return diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index c8db9437e..2523c0d97 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -214,7 +214,10 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } showUploadedAttachment(blob) { - const replacementNode = this.#toActionTextAttachmentNodeWith(blob) + const figure = this.editor.getElementByKey(this.getKey()) + const previewSrc = figure?.querySelector("img")?.src + + const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc) this.replace(replacementNode) if ($isRootOrShadowRoot(replacementNode.getParent())) { @@ -241,8 +244,8 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { return rootElement !== null && rootElement.contains(document.activeElement) } - #toActionTextAttachmentNodeWith(blob) { - const conversion = new AttachmentNodeConversion(this, blob) + #toActionTextAttachmentNodeWith(blob, previewSrc) { + const conversion = new AttachmentNodeConversion(this, blob, previewSrc) return conversion.toAttachmentNode() } @@ -253,16 +256,18 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } class AttachmentNodeConversion { - constructor(uploadNode, blob) { + constructor(uploadNode, blob, previewSrc) { this.uploadNode = uploadNode this.blob = blob + this.previewSrc = previewSrc } toAttachmentNode() { return new ActionTextAttachmentNode({ ...this.uploadNode, ...this.#propertiesFromBlob, - src: this.#src + src: this.#src, + previewSrc: this.previewSrc }) } From 866844a04679b0fc7d6b5e04165e7354c765d4b0 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 17:12:33 +0200 Subject: [PATCH 002/199] Swap to error state DOM on error --- src/nodes/action_text_attachment_node.js | 12 +++++++++++- src/nodes/action_text_attachment_upload_node.js | 9 +-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index c49bf85d4..7e9a8df50 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -169,6 +169,13 @@ export class ActionTextAttachmentNode extends DecoratorNode { return null } + createDOMForError() { + const figure = this.createAttachmentFigure() + figure.classList.add("attachment--error") + figure.appendChild(createElement("div", { innerText: `Error uploading ${this.fileName || "file"}` })) + return figure + } + createAttachmentFigure(previewable = this.isPreviewableAttachment) { const figure = createAttachmentFigure(this.contentType, previewable, this.fileName) figure.draggable = true @@ -208,7 +215,10 @@ export class ActionTextAttachmentNode extends DecoratorNode { #preloadAndSwapSrc(img) { const serverImage = new Image() serverImage.onload = () => { img.src = this.src } - serverImage.onerror = () => { img.src = this.src } + serverImage.onerror = () => { + const figure = img.closest("figure.attachment") + if (figure) figure.replaceWith(this.createDOMForError()) + } serverImage.src = this.src } diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index 2523c0d97..dc16a5215 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -38,7 +38,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } createDOM() { - if (this.uploadError) return this.#createDOMForError() + if (this.uploadError) return this.createDOMForError() // This side-effect is trigged on DOM load to fire only once and avoid multiple // uploads through cloning. The upload is guarded from restarting in case the @@ -98,13 +98,6 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { return this.progress !== null } - #createDOMForError() { - const figure = this.createAttachmentFigure() - figure.classList.add("attachment--error") - figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "file"}` })) - return figure - } - #createDOMForImage() { return createElement("img") } From 69620b74cc8090b4d0064cb5bb9da40c8c5fcdf7 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 17:14:08 +0200 Subject: [PATCH 003/199] Test update --- test/browser/helpers/active_storage_mock.js | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index 4d1f5dc79..6566ae96b 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -1,6 +1,14 @@ -// Mocks the two Active Storage direct upload endpoints using Playwright route interception. +// Mocks the Active Storage direct upload endpoints using Playwright route interception. // Returns a handle for asserting that the expected calls were made. +// 1×1 transparent PNG used as a fallback when the fixture file doesn't exist on disk. +/* eslint-disable camelcase */ +const TRANSPARENT_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==", + "base64" +) +/* eslint-enable camelcase */ + export async function mockActiveStorageUploads(page) { let blobCounter = 0 const calls = { blobCreations: [], fileUploads: [] } @@ -36,6 +44,26 @@ export async function mockActiveStorageUploads(page) { }) }) + // GET /rails/active_storage/blobs/* — serves the uploaded file back (for preload) + await page.route("**/rails/active_storage/blobs/**", async (route) => { + const request = route.request() + if (request.method() !== "GET") return route.fallback() + + const url = new URL(request.url()) + const filename = url.pathname.split("/").pop() + const blob = calls.blobCreations.find(b => b.filename === filename) + const contentType = blob?.content_type || "application/octet-stream" + + // Serve the fixture file if it exists, otherwise return a 1×1 transparent PNG + const fs = await import("fs") + const fixturePath = `test/fixtures/files/${filename}` + if (fs.existsSync(fixturePath)) { + await route.fulfill({ status: 200, contentType, path: fixturePath }) + } else { + await route.fulfill({ status: 200, contentType: "image/png", body: TRANSPARENT_PNG }) + } + }) + // PUT /rails/active_storage/disk/* — stores the file bytes await page.route("**/rails/active_storage/disk/**", async (route) => { const request = route.request() From c219e8d55d81dba5b7c96299af2ba39135b338e0 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 20:58:53 +0200 Subject: [PATCH 004/199] PR feedback --- src/nodes/action_text_attachment_node.js | 29 ++++++++++++--- .../action_text_attachment_upload_node.js | 3 +- test/browser/helpers/active_storage_mock.js | 35 +++++++++++++------ .../tests/attachments/attachments.test.js | 21 +++++++++++ 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index 7e9a8df50..8a55c47dc 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -79,7 +79,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { return Lexxy.global.get("attachmentTagName") } - constructor({ tagName, sgid, src, previewSrc, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) { + constructor({ tagName, sgid, src, previewSrc, previewable, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) { super(key) this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME @@ -94,11 +94,14 @@ export class ActionTextAttachmentNode extends DecoratorNode { this.fileSize = fileSize this.width = width this.height = height + this.uploadError = uploadError this.editor = $getEditor() } createDOM() { + if (this.uploadError) return this.createDOMForError() + const figure = this.createAttachmentFigure() if (this.isPreviewableAttachment) { @@ -112,7 +115,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { return figure } - updateDOM(_prevNode, dom) { + updateDOM(prevNode, dom) { + if (this.uploadError !== prevNode.uploadError) return true + const caption = dom.querySelector("figcaption textarea") if (caption && this.caption) { caption.value = this.caption @@ -213,15 +218,29 @@ export class ActionTextAttachmentNode extends DecoratorNode { } #preloadAndSwapSrc(img) { + const previewSrc = this.previewSrc const serverImage = new Image() - serverImage.onload = () => { img.src = this.src } + + serverImage.onload = () => { + img.src = this.src + this.#revokePreviewSrc(previewSrc) + } + serverImage.onerror = () => { - const figure = img.closest("figure.attachment") - if (figure) figure.replaceWith(this.createDOMForError()) + this.#revokePreviewSrc(previewSrc) + this.editor.update(() => { + this.getWritable().previewSrc = null + this.getWritable().uploadError = true + }) } + serverImage.src = this.src } + #revokePreviewSrc(previewSrc) { + if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc) + } + #swapPreviewToFileDOM(img) { const figure = img.closest("figure.attachment") if (!figure) return diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index dc16a5215..9f1fd0cb7 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -207,8 +207,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } showUploadedAttachment(blob) { - const figure = this.editor.getElementByKey(this.getKey()) - const previewSrc = figure?.querySelector("img")?.src + const previewSrc = this.file ? URL.createObjectURL(this.file) : null const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc) this.replace(replacementNode) diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index 6566ae96b..adc403539 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -2,16 +2,23 @@ // Returns a handle for asserting that the expected calls were made. // 1×1 transparent PNG used as a fallback when the fixture file doesn't exist on disk. -/* eslint-disable camelcase */ const TRANSPARENT_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==", "base64" ) -/* eslint-enable camelcase */ -export async function mockActiveStorageUploads(page) { +export async function mockActiveStorageUploads(page, { delayBlobResponses = false } = {}) { let blobCounter = 0 const calls = { blobCreations: [], fileUploads: [] } + const pendingBlobRoutes = [] + + // When delayBlobResponses is true, GET /blobs/* requests are held until + // calls.releaseBlobResponses() is called. This lets tests assert the local + // preview is visible before the server image arrives. + calls.releaseBlobResponses = () => { + pendingBlobRoutes.forEach(fulfill => fulfill()) + pendingBlobRoutes.length = 0 + } // POST /rails/active_storage/direct_uploads — creates a blob record await page.route("**/rails/active_storage/direct_uploads", async (route) => { @@ -50,17 +57,25 @@ export async function mockActiveStorageUploads(page) { if (request.method() !== "GET") return route.fallback() const url = new URL(request.url()) - const filename = url.pathname.split("/").pop() + const filename = decodeURIComponent(url.pathname.split("/").pop()) const blob = calls.blobCreations.find(b => b.filename === filename) const contentType = blob?.content_type || "application/octet-stream" - // Serve the fixture file if it exists, otherwise return a 1×1 transparent PNG - const fs = await import("fs") - const fixturePath = `test/fixtures/files/${filename}` - if (fs.existsSync(fixturePath)) { - await route.fulfill({ status: 200, contentType, path: fixturePath }) + const fulfill = async () => { + // Serve the fixture file if it exists, otherwise return a 1×1 transparent PNG + const fs = await import("fs") + const fixturePath = `test/fixtures/files/${filename}` + if (fs.existsSync(fixturePath)) { + await route.fulfill({ status: 200, contentType, path: fixturePath }) + } else { + await route.fulfill({ status: 200, contentType: "image/png", body: TRANSPARENT_PNG }) + } + } + + if (delayBlobResponses) { + pendingBlobRoutes.push(fulfill) } else { - await route.fulfill({ status: 200, contentType: "image/png", body: TRANSPARENT_PNG }) + await fulfill() } }) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 414756cb4..ae75ac983 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -30,6 +30,27 @@ test.describe("Attachments", () => { await expect(page.locator("[data-event='lexxy:upload-end']")).toHaveCount(1) }) + test("image keeps local preview until server image loads", async ({ page, editor }) => { + const calls = await mockActiveStorageUploads(page, { delayBlobResponses: true }) + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment[data-content-type='image/png']") + await expect(figure).toBeVisible({ timeout: 10_000 }) + + const img = figure.locator("img") + + // While the server image is delayed, the img should show a local blob: preview + await expect(img).toHaveAttribute("src", /^blob:/) + + // Release the server image response and verify it swaps + calls.releaseBlobResponses() + + await expect(img).toHaveAttribute( + "src", + /\/rails\/active_storage\/blobs\/mock-signed-id-\d+\/example\.png/, + ) + }) + test("upload non previewable attachment", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/note.txt", { via: "file" }) From cf7a337e4296cb2d686fd4fd6d0157e0ed425016 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 09:44:51 +0200 Subject: [PATCH 005/199] PR feedback 2 --- src/nodes/action_text_attachment_node.js | 3 +++ test/browser/helpers/active_storage_mock.js | 4 ++-- test/browser/tests/attachments/attachments.test.js | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index 8a55c47dc..e742cb13f 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -223,6 +223,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { serverImage.onload = () => { img.src = this.src + this.editor.update(() => { + this.getWritable().previewSrc = null + }) this.#revokePreviewSrc(previewSrc) } diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index adc403539..20ab30345 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -15,8 +15,8 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals // When delayBlobResponses is true, GET /blobs/* requests are held until // calls.releaseBlobResponses() is called. This lets tests assert the local // preview is visible before the server image arrives. - calls.releaseBlobResponses = () => { - pendingBlobRoutes.forEach(fulfill => fulfill()) + calls.releaseBlobResponses = async () => { + await Promise.all(pendingBlobRoutes.map(fulfill => fulfill())) pendingBlobRoutes.length = 0 } diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index ae75ac983..2913d8c4c 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -43,7 +43,7 @@ test.describe("Attachments", () => { await expect(img).toHaveAttribute("src", /^blob:/) // Release the server image response and verify it swaps - calls.releaseBlobResponses() + await calls.releaseBlobResponses() await expect(img).toHaveAttribute( "src", From 91de760aa90acdedd39c8bc98b4fff83a1f5248a Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 10:12:35 +0200 Subject: [PATCH 006/199] Test fix --- src/nodes/action_text_attachment_node.js | 18 +++++++++++++++--- .../action_text_attachment_upload_node.js | 2 +- test/browser/helpers/active_storage_mock.js | 7 +++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index e742cb13f..68344bb4d 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -1,5 +1,6 @@ import Lexxy from "../config/lexxy" -import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG } from "lexical" +import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" +import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" import { createAttachmentFigure, createElement, isPreviewableImage } from "../helpers/html_helper" import { bytesToHumanSize, extractFileName } from "../helpers/storage_helper" import { parseBoolean } from "../helpers/string_helper" @@ -225,7 +226,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { img.src = this.src this.editor.update(() => { this.getWritable().previewSrc = null - }) + }, { tag: this.#backgroundUpdateTags }) this.#revokePreviewSrc(previewSrc) } @@ -234,12 +235,23 @@ export class ActionTextAttachmentNode extends DecoratorNode { this.editor.update(() => { this.getWritable().previewSrc = null this.getWritable().uploadError = true - }) + }, { tag: this.#backgroundUpdateTags }) } serverImage.src = this.src } + get #backgroundUpdateTags() { + const rootElement = this.editor.getRootElement() + const editorHasFocus = rootElement !== null && rootElement.contains(document.activeElement) + + if (editorHasFocus) { + return SILENT_UPDATE_TAGS + } else { + return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ] + } + } + #revokePreviewSrc(previewSrc) { if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc) } diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index 9f1fd0cb7..ff89c2b42 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -207,7 +207,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } showUploadedAttachment(blob) { - const previewSrc = this.file ? URL.createObjectURL(this.file) : null + const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc) this.replace(replacementNode) diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index 20ab30345..a058bb65b 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -62,10 +62,13 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals const contentType = blob?.content_type || "application/octet-stream" const fulfill = async () => { - // Serve the fixture file if it exists, otherwise return a 1×1 transparent PNG + // Serve the fixture file if it exists, otherwise return a 1×1 transparent PNG. + // The fixture file is only needed when delayBlobResponses is true (preview swap test). + // For other tests, always serve the tiny PNG to avoid layout shifts from full-size + // images that can break position-dependent tests like drag and drop. const fs = await import("fs") const fixturePath = `test/fixtures/files/${filename}` - if (fs.existsSync(fixturePath)) { + if (delayBlobResponses && fs.existsSync(fixturePath)) { await route.fulfill({ status: 200, contentType, path: fixturePath }) } else { await route.fulfill({ status: 200, contentType: "image/png", body: TRANSPARENT_PNG }) From a9625cd61b5b204add9fe9e8713133d8b83c2d69 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 10:30:50 +0200 Subject: [PATCH 007/199] PR feedback --- src/nodes/action_text_attachment_node.js | 24 +++++++++++++-------- test/browser/helpers/active_storage_mock.js | 7 ++++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index 68344bb4d..949023425 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -224,18 +224,24 @@ export class ActionTextAttachmentNode extends DecoratorNode { serverImage.onload = () => { img.src = this.src - this.editor.update(() => { - this.getWritable().previewSrc = null - }, { tag: this.#backgroundUpdateTags }) - this.#revokePreviewSrc(previewSrc) + try { + this.editor.update(() => { + this.getWritable().previewSrc = null + }, { tag: this.#backgroundUpdateTags }) + } finally { + this.#revokePreviewSrc(previewSrc) + } } serverImage.onerror = () => { - this.#revokePreviewSrc(previewSrc) - this.editor.update(() => { - this.getWritable().previewSrc = null - this.getWritable().uploadError = true - }, { tag: this.#backgroundUpdateTags }) + try { + this.editor.update(() => { + this.getWritable().previewSrc = null + this.getWritable().uploadError = true + }, { tag: this.#backgroundUpdateTags }) + } finally { + this.#revokePreviewSrc(previewSrc) + } } serverImage.src = this.src diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index a058bb65b..a4c4d4b9a 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -11,11 +11,14 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals let blobCounter = 0 const calls = { blobCreations: [], fileUploads: [] } const pendingBlobRoutes = [] + let blobsReleased = false // When delayBlobResponses is true, GET /blobs/* requests are held until // calls.releaseBlobResponses() is called. This lets tests assert the local - // preview is visible before the server image arrives. + // preview is visible before the server image arrives. Idempotent: once + // released, any subsequent blob requests are fulfilled immediately. calls.releaseBlobResponses = async () => { + blobsReleased = true await Promise.all(pendingBlobRoutes.map(fulfill => fulfill())) pendingBlobRoutes.length = 0 } @@ -75,7 +78,7 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals } } - if (delayBlobResponses) { + if (delayBlobResponses && !blobsReleased) { pendingBlobRoutes.push(fulfill) } else { await fulfill() From 2600b718f45becc81a66567a30f3cd62b1b1cb1f Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 10:44:04 +0200 Subject: [PATCH 008/199] Stale-node guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced try/finally with isAttached() check before getWritable(). If the node was deleted, the callback simply skips the state mutation — no throw, and #revokePreviewSrc always runs unconditionally afterward. --- src/nodes/action_text_attachment_node.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index 949023425..b4e57342e 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -224,24 +224,20 @@ export class ActionTextAttachmentNode extends DecoratorNode { serverImage.onload = () => { img.src = this.src - try { - this.editor.update(() => { - this.getWritable().previewSrc = null - }, { tag: this.#backgroundUpdateTags }) - } finally { - this.#revokePreviewSrc(previewSrc) - } + this.editor.update(() => { + if (this.isAttached()) this.getWritable().previewSrc = null + }, { tag: this.#backgroundUpdateTags }) + this.#revokePreviewSrc(previewSrc) } serverImage.onerror = () => { - try { - this.editor.update(() => { + this.editor.update(() => { + if (this.isAttached()) { this.getWritable().previewSrc = null this.getWritable().uploadError = true - }, { tag: this.#backgroundUpdateTags }) - } finally { - this.#revokePreviewSrc(previewSrc) - } + } + }, { tag: this.#backgroundUpdateTags }) + this.#revokePreviewSrc(previewSrc) } serverImage.src = this.src From 420e8752ef590923286051c62084a2c83ff64d60 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 10:44:27 +0200 Subject: [PATCH 009/199] Regression test Uploads an image with delayed blob response, deletes the attachment, then releases the response --- .../tests/attachments/attachments.test.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 2913d8c4c..d619de94a 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -51,6 +51,27 @@ test.describe("Attachments", () => { ) }) + test("deleting attachment before server image loads does not crash", async ({ page, editor }) => { + const calls = await mockActiveStorageUploads(page, { delayBlobResponses: true }) + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment[data-content-type='image/png']") + await expect(figure).toBeVisible({ timeout: 10_000 }) + + // Delete the attachment while the server image is still pending + await figure.locator("img").click() + await editor.send("Delete") + await expect(figure).toHaveCount(0) + + // Release the blob response — should not throw on the now-removed node + await calls.releaseBlobResponses() + + // Editor should be empty and functional + await assertEditorHtml(editor, "") + await editor.send("Still works") + await expect(editor.content).toContainText("Still works") + }) + test("upload non previewable attachment", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/note.txt", { via: "file" }) From 3f68521767ccc93333337aa9928c9efd6c1d5740 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Sat, 4 Apr 2026 15:24:17 +0200 Subject: [PATCH 010/199] Clear format button --- src/editor/command_dispatcher.js | 6 ++++++ src/editor/contents.js | 16 ++++++++++++++++ src/elements/toolbar.js | 4 ++++ src/elements/toolbar_icons.js | 5 +++++ 4 files changed, 31 insertions(+) diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 84813391b..790384086 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -38,6 +38,7 @@ const COMMANDS = [ "setFormatHeadingMedium", "setFormatHeadingSmall", "setFormatParagraph", + "clearFormatting", "insertUnorderedList", "insertOrderedList", "insertQuoteBlock", @@ -248,6 +249,11 @@ export class CommandDispatcher { this.contents.applyParagraphFormat() } + dispatchClearFormatting() { + this.contents.clearFormatting() + this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND) + } + dispatchUploadImage() { this.#dispatchUploadAttachment("image/*,video/*") } diff --git a/src/editor/contents.js b/src/editor/contents.js index 4096dbfd2..3b724a522 100644 --- a/src/editor/contents.js +++ b/src/editor/contents.js @@ -85,6 +85,22 @@ export default class Contents { $setBlocksType(selection, () => $createHeadingNode(tag)) } + clearFormatting() { + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + + selection.getNodes().filter($isTextNode).forEach(node => { + node.setFormat(0) + node.setStyle("") + }) + + $toggleLink(null) + + this.#topLevelElementsInSelection(selection).filter($isQuoteNode).forEach(node => this.#unwrap(node)) + + $setBlocksType(selection, () => $createParagraphNode()) + } + #applyCodeBlockFormat() { const selection = $getSelection() if (!$isRangeSelection(selection)) return diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 815f24aad..898f5cf0c 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -398,6 +398,10 @@ export class LexicalToolbarElement extends HTMLElement { + + diff --git a/src/elements/toolbar_icons.js b/src/elements/toolbar_icons.js index a080e2824..5867b13b9 100644 --- a/src/elements/toolbar_icons.js +++ b/src/elements/toolbar_icons.js @@ -46,6 +46,11 @@ export default { `, + "clearFormatting": + ` + + `, + "highlight": ` From 0d617910c39bb8f022e0c2d0f7844df2b4311fd4 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Sat, 4 Apr 2026 15:24:28 +0200 Subject: [PATCH 011/199] Clear format tests --- test/browser/helpers/toolbar.js | 2 +- .../tests/formatting/clear_formatting.test.js | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 test/browser/tests/formatting/clear_formatting.test.js diff --git a/test/browser/helpers/toolbar.js b/test/browser/helpers/toolbar.js index 4eb2b6d68..d02c853fd 100644 --- a/test/browser/helpers/toolbar.js +++ b/test/browser/helpers/toolbar.js @@ -10,7 +10,7 @@ export async function openFormatDropdown(page) { const FORMAT_DROPDOWN_COMMANDS = new Set([ "setFormatParagraph", "setFormatHeadingLarge", "setFormatHeadingMedium", - "setFormatHeadingSmall", "strikethrough", "underline" + "setFormatHeadingSmall", "strikethrough", "underline", "clearFormatting" ]) export async function clickToolbarButton(page, command) { diff --git a/test/browser/tests/formatting/clear_formatting.test.js b/test/browser/tests/formatting/clear_formatting.test.js new file mode 100644 index 000000000..8094ed443 --- /dev/null +++ b/test/browser/tests/formatting/clear_formatting.test.js @@ -0,0 +1,111 @@ +import { test } from "../../test_helper.js" +import { assertEditorHtml } from "../../helpers/assertions.js" +import { HELLO_EVERYONE, clickToolbarButton, applyHighlightOption } from "../../helpers/toolbar.js" + +test.describe("Clear formatting", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("removes bold", async ({ page, editor }) => { + await editor.setValue("

Hello everyone

") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes italic", async ({ page, editor }) => { + await editor.setValue("

Hello everyone

") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes strikethrough", async ({ page, editor }) => { + await editor.setValue("

Hello everyone

") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes underline", async ({ page, editor }) => { + await editor.setValue("

Hello everyone

") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes link", async ({ page, editor }) => { + await editor.setValue('

Hello everyone

') + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes color highlight", async ({ page, editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await applyHighlightOption(page, "color", 1) + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes background highlight", async ({ page, editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await applyHighlightOption(page, "background-color", 1) + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("converts heading to paragraph", async ({ page, editor }) => { + await editor.setValue("

Hello everyone

") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("unwraps blockquote", async ({ page, editor }) => { + await editor.setValue("

Hello everyone

") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("unwraps code block", async ({ page, editor }) => { + await editor.setValue('
Hello everyone
') + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("unwraps bullet list", async ({ page, editor }) => { + await editor.setValue("
  • Hello everyone
") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("unwraps numbered list", async ({ page, editor }) => { + await editor.setValue("
  1. Hello everyone
") + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("removes all formatting at once", async ({ page, editor }) => { + await editor.setValue( + '

Heading

Hello bold italic and link

quoted

  • listed
', + ) + await editor.selectAll() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml( + editor, + "

Heading

Hello bold italic and link

quoted

listed

", + ) + }) +}) From 66d11b4a3d8521565a996aa563e4b51e20e11a14 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Sat, 4 Apr 2026 15:39:43 +0200 Subject: [PATCH 012/199] PR feedback --- src/editor/command_dispatcher.js | 1 - src/editor/contents.js | 4 ++-- .../tests/formatting/clear_formatting.test.js | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 790384086..649e3ece1 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -251,7 +251,6 @@ export class CommandDispatcher { dispatchClearFormatting() { this.contents.clearFormatting() - this.editor.dispatchCommand(REMOVE_HIGHLIGHT_COMMAND) } dispatchUploadImage() { diff --git a/src/editor/contents.js b/src/editor/contents.js index 3b724a522..9ec680fe3 100644 --- a/src/editor/contents.js +++ b/src/editor/contents.js @@ -11,7 +11,7 @@ import { $createHeadingNode, $createQuoteNode, $isQuoteNode } from "@lexical/ric import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" import { $createLinkNode, $toggleLink } from "@lexical/link" import { dispatch, parseHtml } from "../helpers/html_helper" -import { $setBlocksType } from "@lexical/selection" +import { $forEachSelectedTextNode, $setBlocksType } from "@lexical/selection" import Uploader from "./contents/uploader" import { $isActionTextAttachmentNode } from "../nodes/action_text_attachment_node" import { ActionTextAttachmentUploadNode } from "../nodes/action_text_attachment_upload_node" @@ -89,7 +89,7 @@ export default class Contents { const selection = $getSelection() if (!$isRangeSelection(selection)) return - selection.getNodes().filter($isTextNode).forEach(node => { + $forEachSelectedTextNode(node => { node.setFormat(0) node.setStyle("") }) diff --git a/test/browser/tests/formatting/clear_formatting.test.js b/test/browser/tests/formatting/clear_formatting.test.js index 8094ed443..2827da6d9 100644 --- a/test/browser/tests/formatting/clear_formatting.test.js +++ b/test/browser/tests/formatting/clear_formatting.test.js @@ -97,6 +97,23 @@ test.describe("Clear formatting", () => { await assertEditorHtml(editor, "

Hello everyone

") }) + test("preserves formatting outside partial selection", async ({ page, editor }) => { + await editor.setValue("

bold text here

") + await editor.content.evaluate((el) => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + const textNode = walker.nextNode() + const range = document.createRange() + range.setStart(textNode, 5) + range.setEnd(textNode, 9) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + }) + await editor.flush() + await clickToolbarButton(page, "clearFormatting") + await assertEditorHtml(editor, "

bold text here

") + }) + test("removes all formatting at once", async ({ page, editor }) => { await editor.setValue( '

Heading

Hello bold italic and link

quoted

  • listed
', From 49309ec87b8de855c69084533a5f6aa7435bfcca Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 10:31:46 +0200 Subject: [PATCH 013/199] Extract functions --- src/nodes/action_text_attachment_node.js | 36 +++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index b4e57342e..d71d4fb9f 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -222,25 +222,27 @@ export class ActionTextAttachmentNode extends DecoratorNode { const previewSrc = this.previewSrc const serverImage = new Image() - serverImage.onload = () => { - img.src = this.src - this.editor.update(() => { - if (this.isAttached()) this.getWritable().previewSrc = null - }, { tag: this.#backgroundUpdateTags }) - this.#revokePreviewSrc(previewSrc) - } + serverImage.onload = () => this.#handleImageLoaded(img, previewSrc) + serverImage.onerror = () => this.#handleImageLoadError(previewSrc) + serverImage.src = this.src + } - serverImage.onerror = () => { - this.editor.update(() => { - if (this.isAttached()) { - this.getWritable().previewSrc = null - this.getWritable().uploadError = true - } - }, { tag: this.#backgroundUpdateTags }) - this.#revokePreviewSrc(previewSrc) - } + #handleImageLoaded(img, previewSrc) { + img.src = this.src + this.editor.update(() => { + if (this.isAttached()) this.getWritable().previewSrc = null + }, { tag: this.#backgroundUpdateTags }) + this.#revokePreviewSrc(previewSrc) + } - serverImage.src = this.src + #handleImageLoadError(previewSrc) { + this.editor.update(() => { + if (this.isAttached()) { + this.getWritable().previewSrc = null + this.getWritable().uploadError = true + } + }, { tag: this.#backgroundUpdateTags }) + this.#revokePreviewSrc(previewSrc) } get #backgroundUpdateTags() { From b407c1b62dcb8228f245c547c4ec18bcd08c5c3b Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 12:54:39 +0200 Subject: [PATCH 014/199] Keep cursor location when upload completes --- .../action_text_attachment_upload_node.js | 20 ++++++- test/browser/helpers/active_storage_mock.js | 55 +++++++++++-------- .../tests/attachments/attachments.test.js | 17 ++++++ 3 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index ff89c2b42..a30a583f4 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,7 +1,8 @@ -import { $isRootOrShadowRoot, SKIP_DOM_SELECTION_TAG } from "lexical" +import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, SKIP_DOM_SELECTION_TAG } from "lexical" import Lexxy from "../config/lexxy" import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" import { ActionTextAttachmentNode } from "./action_text_attachment_node" +import { $isProvisionalParagraphNode } from "./provisional_paragraph_node" import { createElement, dispatch } from "../helpers/html_helper" import { loadFileIntoImage } from "../helpers/upload_helper" import { bytesToHumanSize } from "../helpers/storage_helper" @@ -210,9 +211,10 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc) + const shouldSelectAfterReplacement = this.#selectionIncludesUploadNode this.replace(replacementNode) - if ($isRootOrShadowRoot(replacementNode.getParent())) { + if (shouldSelectAfterReplacement && $isRootOrShadowRoot(replacementNode.getParent())) { replacementNode.selectNext() } @@ -236,6 +238,20 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { return rootElement !== null && rootElement.contains(document.activeElement) } + get #selectionIncludesUploadNode() { + const selection = $getSelection() + if (selection === null) return false + + if (selection.getNodes().some((node) => node.is(this))) return true + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false + + const anchorNode = selection.anchor.getNode() + if (!$isProvisionalParagraphNode(anchorNode) || !anchorNode.isEmpty()) return false + + const previousSibling = anchorNode.getPreviousSibling() + return previousSibling !== null && previousSibling.is(this) + } + #toActionTextAttachmentNodeWith(blob, previewSrc) { const conversion = new AttachmentNodeConversion(this, blob, previewSrc) return conversion.toAttachmentNode() diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index a4c4d4b9a..a2d1a2d62 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -12,13 +12,16 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals const calls = { blobCreations: [], fileUploads: [] } const pendingBlobRoutes = [] let blobsReleased = false + let blobResponsesReleased = false - // When delayBlobResponses is true, GET /blobs/* requests are held until - // calls.releaseBlobResponses() is called. This lets tests assert the local - // preview is visible before the server image arrives. Idempotent: once - // released, any subsequent blob requests are fulfilled immediately. + // When delayBlobResponses is true, direct upload responses and GET /blobs/* + // requests are held until calls.releaseBlobResponses() is called. This lets + // tests keep uploads pending while typing, then release completion + // deterministically. Idempotent: once released, any subsequent requests are + // fulfilled immediately. calls.releaseBlobResponses = async () => { blobsReleased = true + blobResponsesReleased = true await Promise.all(pendingBlobRoutes.map(fulfill => fulfill())) pendingBlobRoutes.length = 0 } @@ -34,24 +37,32 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals calls.blobCreations.push(blob) - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - id: blobCounter, - key: `test-key-${blobCounter}`, - filename: blob.filename, - content_type: blob.content_type, - byte_size: blob.byte_size, - checksum: blob.checksum, - signed_id: signedId, - attachable_sgid: `mock-sgid-${blobCounter}`, - direct_upload: { - url: `/rails/active_storage/disk/${signedId}`, - headers: { "Content-Type": blob.content_type }, - }, - }), - }) + const fulfill = async () => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: blobCounter, + key: `test-key-${blobCounter}`, + filename: blob.filename, + content_type: blob.content_type, + byte_size: blob.byte_size, + checksum: blob.checksum, + signed_id: signedId, + attachable_sgid: `mock-sgid-${blobCounter}`, + direct_upload: { + url: `/rails/active_storage/disk/${signedId}`, + headers: { "Content-Type": blob.content_type }, + }, + }), + }) + } + + if (delayBlobResponses && !blobResponsesReleased) { + pendingBlobRoutes.push(fulfill) + } else { + await fulfill() + } }) // GET /rails/active_storage/blobs/* — serves the uploaded file back (for preload) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index d619de94a..ad0dc5b15 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -234,6 +234,23 @@ test.describe("Attachments", () => { await expect(paragraph).toHaveText("hello below") }) + test("typing during pending upload keeps caret position after completion", async ({ page, editor }) => { + const calls = await mockActiveStorageUploads(page, { delayBlobResponses: true }) + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment[data-content-type='image/png']") + await expect(figure).toBeVisible({ timeout: 10_000 }) + + await editor.send("hello") + await expect.poll(() => editor.plainTextValue()).toContain("hello") + + await calls.releaseBlobResponses() + await editor.flush() + + await editor.send(" world") + await expect.poll(() => editor.plainTextValue()).toContain("hello world") + }) + test("Ctrl+C in caption copies text without losing focus", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/example.png") From 2131051b64b987c8cba8186ee51bf48d314838d1 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 11:01:45 +0200 Subject: [PATCH 015/199] Merge conflict resolution --- test/browser/helpers/active_storage_mock.js | 28 ++++++++++++------- .../tests/attachments/attachments.test.js | 4 +-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index a2d1a2d62..da03a9d1c 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -7,25 +7,33 @@ const TRANSPARENT_PNG = Buffer.from( "base64" ) -export async function mockActiveStorageUploads(page, { delayBlobResponses = false } = {}) { +export async function mockActiveStorageUploads(page, { delayBlobResponses = false, delayDirectUploadResponse = false } = {}) { let blobCounter = 0 const calls = { blobCreations: [], fileUploads: [] } const pendingBlobRoutes = [] + const pendingDirectUploadRoutes = [] let blobsReleased = false - let blobResponsesReleased = false + let directUploadReleased = false - // When delayBlobResponses is true, direct upload responses and GET /blobs/* - // requests are held until calls.releaseBlobResponses() is called. This lets - // tests keep uploads pending while typing, then release completion - // deterministically. Idempotent: once released, any subsequent requests are - // fulfilled immediately. + // When delayBlobResponses is true, GET /blobs/* requests are held until + // calls.releaseBlobResponses() is called. This lets tests assert the local + // preview is visible before the server image arrives. Idempotent: once + // released, any subsequent blob requests are fulfilled immediately. calls.releaseBlobResponses = async () => { blobsReleased = true - blobResponsesReleased = true await Promise.all(pendingBlobRoutes.map(fulfill => fulfill())) pendingBlobRoutes.length = 0 } + // When delayDirectUploadResponse is true, POST /direct_uploads responses are + // held until calls.releaseDirectUploadResponses() is called. This lets tests + // keep uploads pending while typing, then release completion deterministically. + calls.releaseDirectUploadResponses = async () => { + directUploadReleased = true + await Promise.all(pendingDirectUploadRoutes.map(fulfill => fulfill())) + pendingDirectUploadRoutes.length = 0 + } + // POST /rails/active_storage/direct_uploads — creates a blob record await page.route("**/rails/active_storage/direct_uploads", async (route) => { const request = route.request() @@ -58,8 +66,8 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals }) } - if (delayBlobResponses && !blobResponsesReleased) { - pendingBlobRoutes.push(fulfill) + if (delayDirectUploadResponse && !directUploadReleased) { + pendingDirectUploadRoutes.push(fulfill) } else { await fulfill() } diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index ad0dc5b15..63dd226a2 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -235,7 +235,7 @@ test.describe("Attachments", () => { }) test("typing during pending upload keeps caret position after completion", async ({ page, editor }) => { - const calls = await mockActiveStorageUploads(page, { delayBlobResponses: true }) + const calls = await mockActiveStorageUploads(page, { delayDirectUploadResponse: true }) await editor.uploadFile("test/fixtures/files/example.png") const figure = page.locator("figure.attachment[data-content-type='image/png']") @@ -244,7 +244,7 @@ test.describe("Attachments", () => { await editor.send("hello") await expect.poll(() => editor.plainTextValue()).toContain("hello") - await calls.releaseBlobResponses() + await calls.releaseDirectUploadResponses() await editor.flush() await editor.send(" world") From 5c0ea55975e4286179fef710c36113b6556fd36c Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 11:08:14 +0200 Subject: [PATCH 016/199] Update active_storage_mock.js --- test/browser/helpers/active_storage_mock.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index da03a9d1c..90683ea7c 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -41,7 +41,8 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals const body = JSON.parse(request.postData()) const blob = body.blob - const signedId = `mock-signed-id-${++blobCounter}` + const blobId = ++blobCounter + const signedId = `mock-signed-id-${blobId}` calls.blobCreations.push(blob) @@ -50,14 +51,14 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals status: 200, contentType: "application/json", body: JSON.stringify({ - id: blobCounter, - key: `test-key-${blobCounter}`, + id: blobId, + key: `test-key-${blobId}`, filename: blob.filename, content_type: blob.content_type, byte_size: blob.byte_size, checksum: blob.checksum, signed_id: signedId, - attachable_sgid: `mock-sgid-${blobCounter}`, + attachable_sgid: `mock-sgid-${blobId}`, direct_upload: { url: `/rails/active_storage/disk/${signedId}`, headers: { "Content-Type": blob.content_type }, From 5dc9db2424f9f9ef81de237722d550abe3557865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 1 Apr 2026 10:25:47 +0100 Subject: [PATCH 017/199] Add a registerListener helper `registerListener` wraps the native `addEventListener` to return a convinience `deregisterListener` function. The deregister function does not prevent GC of either the element or the callback (which would potentially pin the entire class context via `this`). Calling the deregister function muliple times or when the element or listener have been GC'd is a harmless noop. --- src/helpers/listener_helper.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/helpers/listener_helper.js diff --git a/src/helpers/listener_helper.js b/src/helpers/listener_helper.js new file mode 100644 index 000000000..313166393 --- /dev/null +++ b/src/helpers/listener_helper.js @@ -0,0 +1,12 @@ +// Register an event listener with a return function to deregister the listener. Both the element and +// the listener are WeakRefs so neither is pinned in memory by the deregister function. +export function registerEventListener(element, type, listener, options) { + element.addEventListener(type, listener, options) + const elementRef = new WeakRef(element) + const listenerRef = new WeakRef(listener) + + return function deregisterListener() { + const listener = listenerRef.deref() + if (listener) elementRef.deref()?.removeEventListener(type, listener, options) + } +} From 3a9f25b26230634853f538937819a87285e66f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 00:13:54 +0100 Subject: [PATCH 018/199] Add ListenerBin for listener handling A convenience wrapper around an array of handlers that responds to the common `dispose()` clean-up API. --- src/helpers/listener_helper.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/helpers/listener_helper.js b/src/helpers/listener_helper.js index 313166393..83a1362ad 100644 --- a/src/helpers/listener_helper.js +++ b/src/helpers/listener_helper.js @@ -10,3 +10,18 @@ export function registerEventListener(element, type, listener, options) { if (listener) elementRef.deref()?.removeEventListener(type, listener, options) } } + +export class ListenerBin { + #listeners = [] + + track(...listeners) { + this.#listeners.push(...listeners) + } + + dispose() { + while (this.#listeners.length) { + const teardown = this.#listeners.pop() + teardown() + } + } +} From 6c26ffcd2bac5ebf5d4a26083dd2563d8e404a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 00:39:35 +0100 Subject: [PATCH 019/199] Apply listener helpers to EditorElement --- src/elements/editor.js | 44 ++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index a5f30e3a0..4c4ab194a 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -1,4 +1,4 @@ -import { $addUpdateTag, $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode, mergeRegister } from "lexical" +import { $addUpdateTag, $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" @@ -18,6 +18,7 @@ import Selection from "../editor/selection" import { createElement, dispatch, generateDomId, parseHtml } from "../helpers/html_helper" import { isAttachmentSpacerTextNode } from "../helpers/lexical_helper" import { sanitize } from "../helpers/sanitization_helper" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import LexicalToolbar from "./toolbar" import Configuration from "../editor/configuration" import Contents from "../editor/contents" @@ -46,6 +47,7 @@ export class LexicalEditorElement extends HTMLElement { #initialValue = "" #validationTextArea = document.createElement("textarea") #editorInitializedRafId = null + #listeners = new ListenerBin() #disposables = [] constructor() { @@ -61,6 +63,7 @@ export class LexicalEditorElement extends HTMLElement { this.editor = this.#createEditor() this.#disposables.push(this.editor) + this.#disposables.push(this.#listeners) this.contents = new Contents(this) this.#disposables.push(this.contents) @@ -381,7 +384,9 @@ export class LexicalEditorElement extends HTMLElement { } #resetBeforeTurboCaches() { - document.addEventListener("turbo:before-cache", this.#handleTurboBeforeCache) + this.#listeners.track( + registerEventListener(document, "turbo:before-cache", this.#handleTurboBeforeCache) + ) } #handleTurboBeforeCache = (event) => { @@ -389,7 +394,7 @@ export class LexicalEditorElement extends HTMLElement { } #synchronizeWithChanges() { - this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => { + this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => { this.#clearCachedValues() this.#internalFormValue = this.value this.#toggleEmptyStatus() @@ -403,18 +408,6 @@ export class LexicalEditorElement extends HTMLElement { this.cachedStringValue = null } - #addUnregisterHandler(handler) { - this.unregisterHandlers = this.unregisterHandlers || [] - this.unregisterHandlers.push(handler) - } - - #unregisterHandlers() { - this.unregisterHandlers?.forEach((handler) => { - handler() - }) - this.unregisterHandlers = null - } - #registerComponents() { const registered = [] @@ -437,7 +430,7 @@ export class LexicalEditorElement extends HTMLElement { this.historyState = createEmptyHistoryState() registered.push(registerHistory(this.editor, this.historyState, 20)) - this.#addUnregisterHandler(mergeRegister(...registered)) + this.#listeners.track(...registered) } #registerTableComponents() { @@ -457,7 +450,7 @@ export class LexicalEditorElement extends HTMLElement { #handleEnter() { // We can't prevent these externally using regular keydown because Lexical handles it first. - this.#addUnregisterHandler(this.editor.registerCommand( + this.#listeners.track(this.editor.registerCommand( KEY_ENTER_COMMAND, (event) => { // Prevent CTRL+ENTER @@ -479,13 +472,10 @@ export class LexicalEditorElement extends HTMLElement { } #registerFocusEvents() { - this.addEventListener("focusin", this.#handleFocusIn) - this.addEventListener("focusout", this.#handleFocusOut) - - this.#addUnregisterHandler(() => { - this.removeEventListener("focusin", this.#handleFocusIn) - this.removeEventListener("focusout", this.#handleFocusOut) - }) + this.#listeners.track( + registerEventListener(this, "focusin", this.#handleFocusIn), + registerEventListener(this, "focusout", this.#handleFocusOut) + ) } #handleFocusIn(event) { @@ -525,7 +515,7 @@ export class LexicalEditorElement extends HTMLElement { #attachDebugHooks() { if (!LexicalEditorElement.debug) return - this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => { + this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { console.debug("HTML: ", this.value, "String:", this.toString()) console.debug("empty", this.isEmpty, "blank", this.isBlank) @@ -694,10 +684,6 @@ export class LexicalEditorElement extends HTMLElement { } #dispose() { - this.#unregisterHandlers() - this.adapter = null - document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache) - while (this.#disposables.length) { this.#disposables.pop().dispose() } From 372d2574c1c78cf00e127f0e5f7eb183617368e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 00:41:57 +0100 Subject: [PATCH 020/199] Apply listener helpers to CommandDispatcher --- src/editor/command_dispatcher.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 649e3ece1..20cf4351c 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -21,6 +21,7 @@ import { $createAutoLinkNode, $toggleLink } from "@lexical/link" import { INSERT_TABLE_COMMAND } from "@lexical/table" import { createElement } from "../helpers/html_helper" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import { getListType } from "../helpers/lexical_helper" import { HorizontalDividerNode } from "../nodes/horizontal_divider_node" import { REMOVE_HIGHLIGHT_COMMAND, TOGGLE_HIGHLIGHT_COMMAND } from "../extensions/highlight_extension" @@ -56,7 +57,7 @@ const COMMANDS = [ export class CommandDispatcher { #selectionBeforeDrag = null - #unregister = [] + #listeners = new ListenerBin() static configureFor(editorElement) { return new CommandDispatcher(editorElement) @@ -294,10 +295,7 @@ export class CommandDispatcher { } dispose() { - while (this.#unregister.length) { - const unregister = this.#unregister.pop() - unregister() - } + this.#listeners.dispose() } #registerCommands() { @@ -310,7 +308,7 @@ export class CommandDispatcher { } #registerCommandHandler(command, priority, handler) { - this.#unregister.push(this.editor.registerCommand(command, handler, priority)) + this.#listeners.track(this.editor.registerCommand(command, handler, priority)) } #registerKeyboardCommands() { @@ -335,10 +333,13 @@ export class CommandDispatcher { #registerDragAndDropHandlers() { if (this.editorElement.supportsAttachments) { this.dragCounter = 0 - this.editor.getRootElement().addEventListener("dragover", this.#handleDragOver.bind(this)) - this.editor.getRootElement().addEventListener("drop", this.#handleDrop.bind(this)) - this.editor.getRootElement().addEventListener("dragenter", this.#handleDragEnter.bind(this)) - this.editor.getRootElement().addEventListener("dragleave", this.#handleDragLeave.bind(this)) + const root = this.editor.getRootElement() + this.#listeners.track( + registerEventListener(root, "dragover", this.#handleDragOver.bind(this)), + registerEventListener(root, "drop", this.#handleDrop.bind(this)), + registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)), + registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this)) + ) } } From 12c46b5929e7cfc49f3c826dd43ba0426232a47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 00:46:24 +0100 Subject: [PATCH 021/199] Apply listener helpers to PromptElement --- src/elements/prompt.js | 57 +++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/src/elements/prompt.js b/src/elements/prompt.js index de6a4474d..4a570a3c7 100644 --- a/src/elements/prompt.js +++ b/src/elements/prompt.js @@ -8,13 +8,16 @@ import DeferredPromptSource from "../editor/prompt/deferred_source" import RemoteFilterSource from "../editor/prompt/remote_filter_source" import { $generateNodesFromDOM } from "@lexical/html" import { nextFrame } from "../helpers/timing_helpers" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found" export class LexicalPromptElement extends HTMLElement { + #globalListeners = new ListenerBin() + #popoverListeners = new ListenerBin() + constructor() { super() - this.keyListeners = [] this.showPopoverId = 0 } @@ -28,6 +31,8 @@ export class LexicalPromptElement extends HTMLElement { } disconnectedCallback() { + this.#popoverListeners.dispose() + this.#globalListeners.dispose() this.source = null this.popoverElement = null } @@ -77,7 +82,7 @@ export class LexicalPromptElement extends HTMLElement { } #addTriggerListener() { - const unregister = this.#editor.registerUpdateListener(({ editorState }) => { + this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { if (this.#selection.isInsideCodeBlock) return @@ -100,18 +105,18 @@ export class LexicalPromptElement extends HTMLElement { const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n" if (isAtStart || isPrecededBySpaceOrNewline) { - unregister() + this.#popoverListeners.dispose() this.#showPopover() } } } } }) - }) + })) } #addCursorPositionListener() { - this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => { + this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => { if (this.closed) return editorState.read(() => { @@ -138,14 +143,7 @@ export class LexicalPromptElement extends HTMLElement { this.#hidePopover() } }) - }) - } - - #removeCursorPositionListener() { - if (this.cursorPositionListener) { - this.cursorPositionListener() - this.cursorPositionListener = null - } + })) } get #editor() { @@ -172,8 +170,10 @@ export class LexicalPromptElement extends HTMLElement { this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true) this.#selectFirstOption() - this.#editorElement.addEventListener("keydown", this.#handleKeydownOnPopover) - this.#editorElement.addEventListener("lexxy:change", this.#filterOptions) + this.#popoverListeners.track( + registerEventListener(this.#editorElement, "keydown", this.#handleKeydownOnPopover), + registerEventListener(this.#editorElement, "lexxy:change", this.#filterOptions) + ) this.#registerKeyListeners() this.#addCursorPositionListener() @@ -181,16 +181,20 @@ export class LexicalPromptElement extends HTMLElement { #registerKeyListeners() { // We can't use a regular keydown for Enter as Lexical handles it first - this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL)) - this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL)) + this.#popoverListeners.track( + this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL), + this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL) + ) if (this.#doesSpaceSelect) { - this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL)) + this.#popoverListeners.track(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL)) } // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running - this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL)) - this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_CRITICAL)) + this.#popoverListeners.track( + this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL), + this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_CRITICAL) + ) } #handleArrowUp(event) { @@ -282,21 +286,12 @@ export class LexicalPromptElement extends HTMLElement { this.showPopoverId++ this.#clearSelection() this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false) - this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions) - this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover) - - this.#unregisterKeyListeners() - this.#removeCursorPositionListener() + this.#popoverListeners.dispose() await nextFrame() this.#addTriggerListener() } - #unregisterKeyListeners() { - this.keyListeners.forEach((unregister) => unregister()) - this.keyListeners = [] - } - #filterOptions = async () => { if (this.initialPrompt) { this.initialPrompt = false @@ -473,7 +468,7 @@ export class LexicalPromptElement extends HTMLElement { popoverContainer.style.position = "absolute" popoverContainer.setAttribute("nonce", getNonce()) popoverContainer.append(...await this.source.buildListItems()) - popoverContainer.addEventListener("click", this.#handlePopoverClick) + this.#globalListeners.track(registerEventListener(popoverContainer, "click", this.#handlePopoverClick)) this.#editorElement.appendChild(popoverContainer) return popoverContainer } From 0fb79c4f464e8ce8b347a994357c445c5e339fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 00:56:46 +0100 Subject: [PATCH 022/199] Apply listener helpers to ToolbarElement --- src/elements/toolbar.js | 55 +++++++++++++---------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 898f5cf0c..80dafc825 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -4,11 +4,13 @@ import { SKIP_DOM_SELECTION_TAG } from "lexical" import { getNonce } from "../helpers/csp_helper" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import { handleRollingTabIndex } from "../helpers/accessibility_helper" import ToolbarIcons from "./toolbar_icons" export class LexicalToolbarElement extends HTMLElement { static observedAttributes = [ "connected" ] + #listeners = new ListenerBin() constructor() { super() @@ -29,12 +31,7 @@ export class LexicalToolbarElement extends HTMLElement { } dispose() { - this.#uninstallResizeObserver() - this.#unbindButtons() - this.#unbindHotkeys() - this.#unbindFocusListeners() - this.unregisterSelectionListener?.() - this.unregisterHistoryListener?.() + this.#listeners.dispose() this.editorElement = null this.editor = null @@ -93,23 +90,13 @@ export class LexicalToolbarElement extends HTMLElement { } #installResizeObserver() { - this.resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow()) - this.resizeObserver.observe(this) - } - - #uninstallResizeObserver() { - if (this.resizeObserver) { - this.resizeObserver.disconnect() - this.resizeObserver = null - } + const resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow()) + resizeObserver.observe(this) + this.#listeners.track(() => resizeObserver.disconnect()) } #bindButtons() { - this.addEventListener("click", this.#handleButtonClicked) - } - - #unbindButtons() { - this.removeEventListener("click", this.#handleButtonClicked) + this.#listeners.track(registerEventListener(this, "click", this.#handleButtonClicked)) } #handleButtonClicked = (event) => { @@ -134,11 +121,7 @@ export class LexicalToolbarElement extends HTMLElement { } #bindHotkeys() { - this.editorElement.addEventListener("keydown", this.#handleHotkey) - } - - #unbindHotkeys() { - this.editorElement?.removeEventListener("keydown", this.#handleHotkey) + this.#listeners.track(registerEventListener(this.editorElement, "keydown", this.#handleHotkey)) } #handleHotkey = (event) => { @@ -166,15 +149,11 @@ export class LexicalToolbarElement extends HTMLElement { } #bindFocusListeners() { - this.editorElement.addEventListener("lexxy:focus", this.#handleEditorFocus) - this.editorElement.addEventListener("lexxy:blur", this.#handleEditorBlur) - this.addEventListener("keydown", this.#handleKeydown) - } - - #unbindFocusListeners() { - this.editorElement?.removeEventListener("lexxy:focus", this.#handleEditorFocus) - this.editorElement?.removeEventListener("lexxy:blur", this.#handleEditorBlur) - this.removeEventListener("keydown", this.#handleKeydown) + this.#listeners.track( + registerEventListener(this.editorElement, "lexxy:focus", this.#handleEditorFocus), + registerEventListener(this.editorElement, "lexxy:blur", this.#handleEditorBlur), + registerEventListener(this, "keydown", this.#handleKeydown) + ) } #handleEditorFocus = () => { @@ -197,18 +176,18 @@ export class LexicalToolbarElement extends HTMLElement { } #monitorSelectionChanges() { - this.unregisterSelectionListener = this.editor.registerUpdateListener(() => { + this.#listeners.track(this.editor.registerUpdateListener(() => { this.editor.getEditorState().read(() => { this.#updateButtonStates() this.#closeDropdowns() }) - }) + })) } #monitorHistoryChanges() { - this.unregisterHistoryListener = this.editor.registerUpdateListener(() => { + this.#listeners.track(this.editor.registerUpdateListener(() => { this.#updateUndoRedoButtonStates() - }) + })) } #updateUndoRedoButtonStates() { From 3d6a92fd746553720d65398fb34e23002470b371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 01:05:58 +0100 Subject: [PATCH 023/199] Apply listener helpers to Selection --- src/editor/selection.js | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/editor/selection.js b/src/editor/selection.js index 7d9e11884..1f5c1e887 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -1,8 +1,7 @@ import { $createParagraphNode, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $isTextNode, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, DELETE_CHARACTER_COMMAND, - KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode, - mergeRegister + KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode } from "lexical" import { $getNearestNodeOfType } from "@lexical/utils" import { $getListDepth, ListItemNode, ListNode } from "@lexical/list" @@ -15,9 +14,10 @@ import { $createNodeSelectionWith, $isListItemStructurallyEmpty, getListType } f import { LinkNode } from "@lexical/link" import { $isHeadingNode, $isQuoteNode } from "@lexical/rich-text" import { $isActionTextAttachmentNode } from "../nodes/action_text_attachment_node" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" export default class Selection { - #unregister = [] + #listeners = new ListenerBin() constructor(editorElement) { this.editorElement = editorElement @@ -281,10 +281,7 @@ export default class Selection { this.editor = null this.previouslySelectedKeys = null - while (this.#unregister.length) { - const unregister = this.#unregister.pop() - unregister() - } + this.#listeners.dispose() } // When all inline code text is deleted, Lexical's selection retains the stale @@ -302,7 +299,7 @@ export default class Selection { // detects that stale state and clears it so newly typed text won't be // code-formatted. #clearStaleInlineCodeFormat() { - this.#unregister.push(this.editor.registerUpdateListener(({ editorState, tags }) => { + this.#listeners.track(this.editor.registerUpdateListener(({ editorState, tags }) => { if (tags.has("history-merge") || tags.has("skip-dom-selection")) return let isStale = false @@ -350,7 +347,7 @@ export default class Selection { } #processSelectionChangeCommands() { - this.#unregister.push(mergeRegister( + this.#listeners.track( this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW), this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW), this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW), @@ -361,21 +358,21 @@ export default class Selection { this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { this.current = $getSelection() }, COMMAND_PRIORITY_LOW) - )) + ) } #listenForNodeSelections() { - this.#unregister.push(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => { + this.#listeners.track(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => { if (!isDOMNode(target)) return false const targetNode = $getNearestNodeFromDOMNode(target) return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode) }, COMMAND_PRIORITY_LOW)) - const moveNextLineHandler = () => this.#selectOrAppendNextLine() const rootElement = this.editor.getRootElement() - rootElement.addEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler) - this.#unregister.push(() => rootElement.removeEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler)) + this.#listeners.track( + registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine()) + ) } #containEditorFocus() { From 174bd68e3e95c9b46c64fe5a07a3ceae9eab1de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 01:06:01 +0100 Subject: [PATCH 024/199] Apply listener helpers to TableTools --- src/elements/table/table_tools.js | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/elements/table/table_tools.js b/src/elements/table/table_tools.js index 199fcb7ae..b0abaf2bd 100644 --- a/src/elements/table/table_tools.js +++ b/src/elements/table/table_tools.js @@ -7,8 +7,11 @@ import theme from "../../config/theme" import { handleRollingTabIndex } from "../../helpers/accessibility_helper" import { createElement } from "../../helpers/html_helper" import { nextFrame } from "../../helpers/timing_helpers" +import { ListenerBin, registerEventListener } from "../../helpers/listener_helper" export class TableTools extends HTMLElement { + #listeners = new ListenerBin() + connectedCallback() { this.tableController = new TableController(this.#editorElement) this.classList.add("lexxy-floating-controls") @@ -24,12 +27,7 @@ export class TableTools extends HTMLElement { } dispose() { - this.#unregisterKeyboardShortcuts() - - this.unregisterUpdateListener?.() - this.unregisterUpdateListener = null - - this.removeEventListener("keydown", this.#handleToolsKeydown) + this.#listeners.dispose() this.tableController?.destroy() this.tableController = null @@ -54,7 +52,7 @@ export class TableTools extends HTMLElement { this.appendChild(this.#createColumnButtonsContainer()) this.appendChild(this.#createDeleteTableButton()) - this.addEventListener("keydown", this.#handleToolsKeydown) + this.#listeners.track(registerEventListener(this, "keydown", this.#handleToolsKeydown)) } #createButtonsContainer(childType, setCountProperty, moreMenu) { @@ -147,12 +145,7 @@ export class TableTools extends HTMLElement { } #registerKeyboardShortcuts() { - this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH) - } - - #unregisterKeyboardShortcuts() { - this.unregisterKeyboardShortcuts?.() - this.unregisterKeyboardShortcuts = null + this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH)) } #handleAccessibilityShortcutKey = (event) => { @@ -222,7 +215,7 @@ export class TableTools extends HTMLElement { } #monitorForTableSelection() { - this.unregisterUpdateListener = this.#editor.registerUpdateListener(() => { + this.#listeners.track(this.#editor.registerUpdateListener(() => { this.tableController.updateSelectedTable() const tableNode = this.tableController.currentTableNode @@ -231,7 +224,7 @@ export class TableTools extends HTMLElement { } else { this.#hide() } - }) + })) } #executeTableCommand(command) { From 8834fffd768580afccabbf57793b5dbc8b7a82be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 01:06:05 +0100 Subject: [PATCH 025/199] Apply listener helpers to AttachmentDragAndDrop --- src/editor/attachments/drag_and_drop.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/editor/attachments/drag_and_drop.js b/src/editor/attachments/drag_and_drop.js index 7c337535c..e715c9c2e 100644 --- a/src/editor/attachments/drag_and_drop.js +++ b/src/editor/attachments/drag_and_drop.js @@ -11,6 +11,7 @@ import { import { $isListItemNode, $isListNode } from "@lexical/list" import { $isActionTextAttachmentNode } from "../../nodes/action_text_attachment_node" import { $findOrCreateGalleryForImage } from "../../nodes/image_gallery_node" +import { ListenerBin } from "../../helpers/listener_helper" const MIME_TYPE = "application/x-lexxy-node-key" @@ -19,21 +20,21 @@ export class AttachmentDragAndDrop { #draggedNodeKey = null #rafId = null #draggingRafId = null - #cleanupFns = [] + #listeners = new ListenerBin() constructor(editor) { this.#editor = editor // Register Lexical commands at HIGH priority to intercept before the // base @lexical/rich-text handlers (which return true and consume the events). - this.#cleanupFns.push( + this.#listeners.track( editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH), editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH), ) // Use a root listener to register DOM-level dragover/dragend handlers // (these events need throttled rAF handling that works better as DOM listeners). - const unregister = editor.registerRootListener((root, prevRoot) => { + this.#listeners.track(editor.registerRootListener((root, prevRoot) => { if (prevRoot) { prevRoot.removeEventListener("dragover", this.#onDragOver) prevRoot.removeEventListener("dragend", this.#onDragEnd) @@ -42,14 +43,12 @@ export class AttachmentDragAndDrop { root.addEventListener("dragover", this.#onDragOver) root.addEventListener("dragend", this.#onDragEnd) } - }) - this.#cleanupFns.push(unregister) + })) } destroy() { this.#cleanup() - for (const fn of this.#cleanupFns) fn() - this.#cleanupFns = [] + this.#listeners.dispose() } // -- Event handlers -------------------------------------------------------- From 129f816ccbde86ad63059d2303c27c6c233a16f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 01:12:36 +0100 Subject: [PATCH 026/199] Apply listener helpers to ToolbarDropdown and subclasses --- src/elements/dropdown/highlight.js | 20 ++++++-------------- src/elements/dropdown/link.js | 16 ++++++---------- src/elements/toolbar_dropdown.js | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/elements/dropdown/highlight.js b/src/elements/dropdown/highlight.js index 0d688f5a9..09c4b5d17 100644 --- a/src/elements/dropdown/highlight.js +++ b/src/elements/dropdown/highlight.js @@ -1,6 +1,7 @@ import { $getSelection, $isRangeSelection } from "lexical" import { $getSelectionStyleValueForProperty } from "@lexical/selection" import { ToolbarDropdown } from "../toolbar_dropdown" +import { registerEventListener } from "../../helpers/listener_helper" const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button" const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']" @@ -18,23 +19,14 @@ export class HighlightDropdown extends ToolbarDropdown { connectedCallback() { super.connectedCallback() - this.container.addEventListener("toggle", this.#handleToggle) - } - - disconnectedCallback() { - this.container?.removeEventListener("toggle", this.#handleToggle) - this.#removeButtonHandlers() - super.disconnectedCallback() + this.track(registerEventListener(this.container, "toggle", this.#handleToggle)) } #registerButtonHandlers() { - this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick)) - this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick) - } - - #removeButtonHandlers() { - this.#colorButtons.forEach(button => button.removeEventListener("click", this.#handleColorButtonClick)) - this.querySelector(REMOVE_HIGHLIGHT_SELECTOR)?.removeEventListener("click", this.#handleRemoveHighlightClick) + this.#colorButtons.forEach(button => { + this.track(registerEventListener(button, "click", this.#handleColorButtonClick)) + }) + this.track(registerEventListener(this.querySelector(REMOVE_HIGHLIGHT_SELECTOR), "click", this.#handleRemoveHighlightClick)) } #setUpButtons() { diff --git a/src/elements/dropdown/link.js b/src/elements/dropdown/link.js index 4b701bf54..262a51da0 100644 --- a/src/elements/dropdown/link.js +++ b/src/elements/dropdown/link.js @@ -1,22 +1,18 @@ import { $getSelection, $isRangeSelection } from "lexical" import { $isLinkNode } from "@lexical/link" import { ToolbarDropdown } from "../toolbar_dropdown" +import { registerEventListener } from "../../helpers/listener_helper" export class LinkDropdown extends ToolbarDropdown { connectedCallback() { super.connectedCallback() this.input = this.querySelector("input") - this.container.addEventListener("toggle", this.#handleToggle) - this.addEventListener("submit", this.#handleSubmit) - this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink) - } - - disconnectedCallback() { - this.container?.removeEventListener("toggle", this.#handleToggle) - this.removeEventListener("submit", this.#handleSubmit) - this.querySelector("[value='unlink']")?.removeEventListener("click", this.#handleUnlink) - super.disconnectedCallback() + this.track( + registerEventListener(this.container, "toggle", this.#handleToggle), + registerEventListener(this, "submit", this.#handleSubmit), + registerEventListener(this.querySelector("[value='unlink']"), "click", this.#handleUnlink) + ) } #handleToggle = ({ newState }) => { diff --git a/src/elements/toolbar_dropdown.js b/src/elements/toolbar_dropdown.js index c44b51c8a..ca57e4791 100644 --- a/src/elements/toolbar_dropdown.js +++ b/src/elements/toolbar_dropdown.js @@ -1,18 +1,22 @@ import { nextFrame } from "../helpers/timing_helpers" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" export class ToolbarDropdown extends HTMLElement { + #listeners = new ListenerBin() + connectedCallback() { this.container = this.closest("details") - this.container.addEventListener("toggle", this.#handleToggle) - this.container.addEventListener("keydown", this.#handleKeyDown) + this.#listeners.track( + registerEventListener(this.container, "toggle", this.#handleToggle), + registerEventListener(this.container, "keydown", this.#handleKeyDown) + ) this.#onToolbarEditor(this.initialize.bind(this)) } disconnectedCallback() { - this.container?.removeEventListener("toggle", this.#handleToggle) - this.container?.removeEventListener("keydown", this.#handleKeyDown) + this.#listeners.dispose() } get toolbar() { @@ -27,6 +31,10 @@ export class ToolbarDropdown extends HTMLElement { return this.toolbar.editor } + track(...listeners) { + this.#listeners.track(...listeners) + } + initialize() { // Any post-editor initialization } From 07dea7744afa468cd618b92e24cd09df0e3207d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 2 Apr 2026 01:12:37 +0100 Subject: [PATCH 027/199] Apply listener helpers to TableController --- src/elements/table/table_controller.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/elements/table/table_controller.js b/src/elements/table/table_controller.js index 580693cf6..1b22209dc 100644 --- a/src/elements/table/table_controller.js +++ b/src/elements/table/table_controller.js @@ -20,8 +20,11 @@ import { import { upcaseFirst } from "../../helpers/string_helper" import { nextFrame } from "../../helpers/timing_helpers" +import { ListenerBin } from "../../helpers/listener_helper" export class TableController { + #listeners = new ListenerBin() + constructor(editorElement) { this.editor = editorElement.editor this.contents = editorElement.contents @@ -37,7 +40,7 @@ export class TableController { this.currentTableNodeKey = null this.currentCellKey = null - this.#unregisterKeyHandlers() + this.#listeners.dispose() } get currentCell() { @@ -319,16 +322,10 @@ export class TableController { #registerKeyHandlers() { // We can't prevent these externally using regular keydown because Lexical handles it first. - this.unregisterBackspaceKeyHandler = this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH) - this.unregisterEnterKeyHandler = this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH) - } - - #unregisterKeyHandlers() { - this.unregisterBackspaceKeyHandler?.() - this.unregisterEnterKeyHandler?.() - - this.unregisterBackspaceKeyHandler = null - this.unregisterEnterKeyHandler = null + this.#listeners.track( + this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH), + this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH) + ) } #handleBackspaceKey(event) { From 0b864e581fe2c3bc8e9633392b7e905487d32f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Tue, 7 Apr 2026 12:24:45 +0100 Subject: [PATCH 028/199] Apply listener helpers to CodeLanguagePicker --- src/elements/code_language_picker.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index 80f0aff89..abcdffeeb 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -2,15 +2,18 @@ import { $isCodeNode, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from import { $getSelection, $isRangeSelection } from "lexical" import { createElement, dispatch } from "../helpers/html_helper" import { getNonce } from "../helpers/csp_helper" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" export class CodeLanguagePicker extends HTMLElement { #abortController = null + #listeners = new ListenerBin() connectedCallback() { this.editorElement = this.closest("lexxy-editor") this.editor = this.editorElement.editor this.classList.add("lexxy-floating-controls") this.#abortController = new AbortController() + this.#listeners.track(() => this.#abortController?.abort()) this.#attachLanguagePicker() this.#hide() @@ -22,10 +25,7 @@ export class CodeLanguagePicker extends HTMLElement { } dispose() { - this.#abortController?.abort() - this.#abortController = null - this.unregisterUpdateListener?.() - this.unregisterUpdateListener = null + this.#listeners.dispose() } #attachLanguagePicker() { @@ -33,13 +33,13 @@ export class CodeLanguagePicker extends HTMLElement { const signal = this.#abortController.signal - this.languagePickerElement.addEventListener("change", () => { + this.#listeners.track(registerEventListener(this.languagePickerElement, "change", () => { this.#updateCodeBlockLanguage(this.languagePickerElement.value) - }, { signal }) + }, { signal })) - this.languagePickerElement.addEventListener("mousedown", (event) => { + this.#listeners.track(registerEventListener(this.languagePickerElement, "mousedown", (event) => { this.#dispatchOpenEvent(event) - }, { signal }) + }, { signal })) this.languagePickerElement.setAttribute("nonce", getNonce()) this.appendChild(this.languagePickerElement) @@ -107,8 +107,8 @@ export class CodeLanguagePicker extends HTMLElement { } #monitorForCodeBlockSelection() { - this.unregisterUpdateListener = this.editor.registerUpdateListener(() => { - this.editor.getEditorState().read(() => { + this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { const codeNode = this.#getCurrentCodeNode() if (codeNode) { @@ -117,7 +117,7 @@ export class CodeLanguagePicker extends HTMLElement { this.#hide() } }) - }) + })) } #getCurrentCodeNode() { From 4d12a0fb2c1b384ce4db22cf90c394f65a998a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Tue, 7 Apr 2026 12:25:18 +0100 Subject: [PATCH 029/199] Simplify with selection.nearestNodeOfType finder --- src/elements/code_language_picker.js | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index abcdffeeb..4e3a2a4ba 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -1,5 +1,4 @@ -import { $isCodeNode, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from "@lexical/code" -import { $getSelection, $isRangeSelection } from "lexical" +import { CODE_LANGUAGE_FRIENDLY_NAME_MAP, CodeNode, normalizeCodeLang } from "@lexical/code" import { createElement, dispatch } from "../helpers/html_helper" import { getNonce } from "../helpers/csp_helper" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" @@ -121,22 +120,7 @@ export class CodeLanguagePicker extends HTMLElement { } #getCurrentCodeNode() { - const selection = $getSelection() - - if (!$isRangeSelection(selection)) { - return null - } - - const anchorNode = selection.anchor.getNode() - const parentNode = anchorNode.getParent() - - if ($isCodeNode(anchorNode)) { - return anchorNode - } else if ($isCodeNode(parentNode)) { - return parentNode - } - - return null + return this.editorElement.selection.nearestNodeOfType(CodeNode) } #codeNodeWasSelected(codeNode) { From edd019e0434a9391d7963ce21e9978059b331f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Tue, 7 Apr 2026 12:48:46 +0100 Subject: [PATCH 030/199] Simplify conditional language assignment --- src/elements/code_language_picker.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index 4e3a2a4ba..dfd7ea2c9 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -64,15 +64,14 @@ export class CodeLanguagePicker extends HTMLElement { get #languages() { const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP } - if (!languages.ruby) languages.ruby = "Ruby" - if (!languages.php) languages.php = "PHP" - if (!languages.go) languages.go = "Go" - if (!languages.bash) languages.bash = "Bash" - if (!languages.json) languages.json = "JSON" - if (!languages.diff) languages.diff = "Diff" - const sortedEntries = Object.entries(languages) .sort(([ , a ], [ , b ]) => a.localeCompare(b)) + languages.ruby ||= "Ruby" + languages.php ||= "PHP" + languages.go ||= "Go" + languages.bash ||= "Bash" + languages.json ||= "JSON" + languages.diff ||= "Diff" // Place the "plain" entry first, then the rest of language sorted alphabetically const plainIndex = sortedEntries.findIndex(([ key ]) => key === "plain") From 945ee77230d8d873df185a3ec2f2e1f016b4eedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Tue, 7 Apr 2026 12:49:18 +0100 Subject: [PATCH 031/199] Simplify language picker ordering --- src/elements/code_language_picker.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index dfd7ea2c9..3331c37be 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -64,8 +64,6 @@ export class CodeLanguagePicker extends HTMLElement { get #languages() { const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP } - const sortedEntries = Object.entries(languages) - .sort(([ , a ], [ , b ]) => a.localeCompare(b)) languages.ruby ||= "Ruby" languages.php ||= "PHP" languages.go ||= "Go" @@ -74,9 +72,10 @@ export class CodeLanguagePicker extends HTMLElement { languages.diff ||= "Diff" // Place the "plain" entry first, then the rest of language sorted alphabetically - const plainIndex = sortedEntries.findIndex(([ key ]) => key === "plain") - const plainEntry = sortedEntries.splice(plainIndex, 1)[0] - return Object.fromEntries([ plainEntry, ...sortedEntries ]) + delete languages.plain + const sortedEntries = Object.entries(languages) + .sort((a, b) => a[1].localeCompare(b[1])) + return { plain: "Plain text", ...Object.fromEntries(sortedEntries) } } #dispatchOpenEvent(event) { From 2ef7d8bc3ce0d50ae9cbaebc266cd88d4ca9e13f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 7 Apr 2026 14:10:26 +0200 Subject: [PATCH 032/199] Fix XSS via unsanitized action-text-attachment content attribute (#903) CustomActionTextAttachmentNode.createDOM() inserted the innerHtml extracted from the content attribute directly into the DOM via insertAdjacentHTML without sanitization. Because DOMPurify treats content as a harmless string attribute during paste, malicious HTML (e.g. , + + diff --git a/test/browser/fixtures/extension-toolbar-button.js b/test/browser/fixtures/extension-toolbar-button.js new file mode 100644 index 000000000..a56a4df0a --- /dev/null +++ b/test/browser/fixtures/extension-toolbar-button.js @@ -0,0 +1,15 @@ +import { configure, Extension } from "lexxy" + +class ToolbarButtonExtension extends Extension { + initializeToolbar(toolbar) { + const spacer = toolbar.querySelector(".lexxy-editor__toolbar-spacer") + const button = document.createElement("button") + button.type = "button" + button.name = "custom-extension-button" + button.className = "lexxy-editor__toolbar-button" + button.textContent = "Custom" + spacer.insertAdjacentElement("beforebegin", button) + } +} + +configure({ global: { extensions: [ToolbarButtonExtension] } }) diff --git a/test/browser/tests/editor/reconnect.test.js b/test/browser/tests/editor/reconnect.test.js index d68ee6fc7..e85600fc2 100644 --- a/test/browser/tests/editor/reconnect.test.js +++ b/test/browser/tests/editor/reconnect.test.js @@ -44,3 +44,25 @@ test.describe("Reconnect", () => { await expect(editorEl.locator("lexxy-code-language-picker")).toHaveCount(1) }) }) + +test.describe("Reconnect with extension toolbar buttons", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/extension-toolbar-button.html") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("extension toolbar button is not duplicated after reconnect", async ({ page, editor }) => { + await expect(page.locator("lexxy-toolbar button[name='custom-extension-button']")).toHaveCount(1) + + await page.evaluate(() => { + const el = document.querySelector("lexxy-editor") + const parent = el.parentElement + parent.removeChild(el) + parent.appendChild(el) + }) + + await editor.waitForConnected() + + await expect(page.locator("lexxy-toolbar button[name='custom-extension-button']")).toHaveCount(1) + }) +}) From 54432b1f550348318f34d310d112d4eb57287c8f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 14:07:29 +0200 Subject: [PATCH 058/199] Show file icon for freshly uploaded PDFs, poll for preview (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show file icon for freshly uploaded PDFs, poll for preview When uploading a PDF, the server returns previewable: true with a preview URL, but the thumbnail hasn't been generated yet. The server serves a file-type icon SVG as placeholder, which Lexxy displayed as a giant unconstrained image. Fix: for non-image previewable uploads (PDFs, videos), show the file icon initially and poll the preview URL in the background. When the real thumbnail is ready (detected by image dimensions exceeding the small icon SVG), swap the file icon for the preview image. Falls back to file icon if the preview never loads. * Update system test: previewable PDF shows file icon while preview loads The system test expected an immediately after uploading a PDF. With the pendingPreview fix, previewable non-image uploads now show the file icon initially while polling for the server-generated thumbnail. * Fix mock: only return previewable/url for non-image types Images are handled via isPreviewableImage and use the blob URL template. Only non-image previewable types (PDFs, videos) need the previewable flag and preview URL from the server response. * Extract #swapFigureContent to share between preview↔file swaps Both #swapPreviewToFileDOM and #swapToPreviewDOM follow the same pattern: change the figure CSS class, clear the content (container, icon, caption), and render the new content. Extract the shared logic into #swapFigureContent. * Fix preview polling: read node state inside Lexical read context isAttached() calls in #pollForPreview ran from setTimeout/onload callbacks outside Lexical's state context, throwing error #195. Wrap them in editor.read() so the polling works and the preview swap completes. --- src/nodes/action_text_attachment_node.js | 82 +++++++++++++++++-- .../action_text_attachment_upload_node.js | 3 +- test/browser/helpers/active_storage_mock.js | 7 ++ .../tests/attachments/attachments.test.js | 14 ++++ test/system/attachments_test.rb | 9 +- 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index d71d4fb9f..a41430f77 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -80,7 +80,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { return Lexxy.global.get("attachmentTagName") } - constructor({ tagName, sgid, src, previewSrc, previewable, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) { + constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) { super(key) this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME @@ -88,6 +88,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { this.src = src this.previewSrc = previewSrc this.previewable = parseBoolean(previewable) + this.pendingPreview = pendingPreview this.altText = altText || "" this.caption = caption || "" this.contentType = contentType || "" @@ -102,6 +103,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { createDOM() { if (this.uploadError) return this.createDOMForError() + if (this.pendingPreview) return this.#createDOMForPendingPreview() const figure = this.createAttachmentFigure() @@ -201,6 +203,14 @@ export class ActionTextAttachmentNode extends DecoratorNode { return isPreviewableImage(this.contentType) } + #createDOMForPendingPreview() { + const figure = this.createAttachmentFigure(false) + figure.appendChild(this.#createDOMForFile()) + figure.appendChild(this.#createDOMForNotImage()) + this.#pollForPreview(figure) + return figure + } + #createDOMForImage(options = {}) { const initialSrc = this.previewSrc || this.src const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options }) @@ -264,16 +274,72 @@ export class ActionTextAttachmentNode extends DecoratorNode { const figure = img.closest("figure.attachment") if (!figure) return - figure.className = figure.className.replace("attachment--preview", "attachment--file") + this.#swapFigureContent(figure, "attachment--preview", "attachment--file", () => { + figure.appendChild(this.#createDOMForFile()) + figure.appendChild(this.#createDOMForNotImage()) + }) + } - const container = figure.querySelector(".attachment__container") - if (container) container.remove() + #pollForPreview(figure) { + let attempt = 0 + const maxAttempts = 10 - const caption = figure.querySelector("figcaption") - if (caption) caption.remove() + const tryLoad = () => { + if (!this.editor.read(() => this.isAttached())) return - figure.appendChild(this.#createDOMForFile()) - figure.appendChild(this.#createDOMForNotImage()) + const img = new Image() + const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}` + + img.onload = () => { + if (!this.editor.read(() => this.isAttached())) return + + // The placeholder is a file-type icon SVG (86×100). A real thumbnail + // generated from PDF/video content is significantly larger. + if (img.naturalWidth > 150 && img.naturalHeight > 150) { + this.#swapToPreviewDOM(figure, cacheBustedSrc) + } else { + retry() + } + } + img.onerror = () => retry() + img.src = cacheBustedSrc + } + + const retry = () => { + attempt++ + if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) { + const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000) + setTimeout(tryLoad, delay) + } + } + + // Give the server time to start processing before the first attempt + setTimeout(tryLoad, 3000) + } + + #swapToPreviewDOM(figure, previewSrc) { + this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => { + const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText }) + img.onerror = () => this.#swapPreviewToFileDOM(img) + const container = createElement("div", { className: "attachment__container" }) + container.appendChild(img) + figure.appendChild(container) + figure.appendChild(this.#createEditableCaption()) + }) + + this.editor.update(() => { + if (this.isAttached()) this.getWritable().pendingPreview = false + }, { tag: this.#backgroundUpdateTags }) + } + + #swapFigureContent(figure, fromClass, toClass, renderContent) { + figure.className = figure.className.replace(fromClass, toClass) + + for (const child of [ ...figure.querySelectorAll(".attachment__container, .attachment__icon, figcaption") ]) { + child.remove() + } + + renderContent() } get #imageDimensions() { diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index a30a583f4..90252cc2f 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -275,7 +275,8 @@ class AttachmentNodeConversion { ...this.uploadNode, ...this.#propertiesFromBlob, src: this.#src, - previewSrc: this.previewSrc + previewSrc: this.previewSrc, + pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage }) } diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index 90683ea7c..d529cdb69 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -46,6 +46,11 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals calls.blobCreations.push(blob) + // Non-image previewable types (PDFs, videos) get a preview URL from the + // server. Images are handled via isPreviewableImage and don't need this. + const previewable = blob.content_type === "application/pdf" || + blob.content_type.startsWith("video/") + const fulfill = async () => { await route.fulfill({ status: 200, @@ -59,6 +64,8 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals checksum: blob.checksum, signed_id: signedId, attachable_sgid: `mock-sgid-${blobId}`, + previewable: previewable || undefined, + url: previewable ? `/rails/active_storage/blobs/${signedId}/previews/full` : undefined, direct_upload: { url: `/rails/active_storage/disk/${signedId}`, headers: { "Content-Type": blob.content_type }, diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 63dd226a2..5c82cb3ea 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -83,6 +83,20 @@ test.describe("Attachments", () => { await expect(figure.locator(".attachment__name")).toHaveText("note.txt") }) + test("upload previewable PDF shows file icon initially while preview loads", async ({ page, editor }) => { + await mockActiveStorageUploads(page) + await editor.uploadFile("test/fixtures/files/dummy.pdf", { via: "file" }) + + const figure = page.locator("figure.attachment[data-content-type='application/pdf']") + await expect(figure).toBeVisible({ timeout: 10_000 }) + + // Should show as file attachment initially (not a giant placeholder image) + await expect(figure).toHaveClass(/attachment--file/) + await expect(figure.locator("img")).toHaveCount(0) + await expect(figure.locator(".attachment__icon")).toBeVisible() + await expect(figure.locator(".attachment__name")).toHaveText("dummy.pdf") + }) + test("delete attachment with keyboard", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/example.png") diff --git a/test/system/attachments_test.rb b/test/system/attachments_test.rb index baa47271b..20fa20283 100644 --- a/test/system/attachments_test.rb +++ b/test/system/attachments_test.rb @@ -13,12 +13,17 @@ class AttachmentsTest < ApplicationSystemTestCase assert_image_figure_attachment content_type: "image/png", caption: "example.png" end - test "upload previewable attachment" do + test "upload previewable attachment shows file icon while preview loads" do attach_file file_fixture("dummy.pdf") do click_on "Upload files" end - assert_image_figure_attachment content_type: "application/pdf", caption: "dummy.pdf" + # Previewable non-image uploads (PDFs) show as file icon initially while + # the server generates the thumbnail. The preview swaps in once ready. + assert_figure_attachment content_type: "application/pdf" do + assert_selector ".attachment__icon" + assert_selector ".attachment__name", text: "dummy.pdf" + end end test "upload image via image button" do From d46aceca0163c35e74818f1a215536f96e782298 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 14:29:48 +0200 Subject: [PATCH 059/199] Split soft line breaks into paragraphs before inserting lists (#946) * Split soft line breaks into paragraphs before inserting lists When applying bullet or numbered list formatting to text containing soft line breaks (Shift+Enter /
), the entire paragraph was wrapped as a single list item. The quote command already handled this correctly by calling splitParagraphsAtLineBreaks before wrapping, but the list commands skipped that step. Expose splitParagraphsAtLineBreaks as a public method on Contents and call it in both dispatchInsertUnorderedList and dispatchInsertOrderedList before dispatching to Lexical's list plugin. The split only runs when not already inside a list, so Shift+Enter within a list item still produces a line break as expected. * Address Copilot review: encapsulate list-format split, fix nested endpoint detection - Extract applyUnorderedListFormat / applyOrderedListFormat on Contents, mirroring applyParagraphFormat. The split-then-dispatch sequence is now encapsulated, and the helper that decides whether to split is private. - Skip splitting when already inside any list (matches the PR description), not just when the current list type matches. - Compare anchor/focus top-level elements instead of direct child keys so endpoints inside nested inline nodes (e.g. text inside a LinkNode) are still recognized as needing a split. --- src/editor/command_dispatcher.js | 5 +-- src/editor/contents.js | 31 ++++++++++--- .../tests/formatting/block_formatting.test.js | 45 +++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 20cf4351c..9a2873d11 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -15,7 +15,6 @@ import { REDO_COMMAND, UNDO_COMMAND } from "lexical" -import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list" import { CodeNode } from "@lexical/code" import { $createAutoLinkNode, $toggleLink } from "@lexical/link" import { INSERT_TABLE_COMMAND } from "@lexical/table" @@ -139,7 +138,7 @@ export class CommandDispatcher { if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") { this.contents.applyParagraphFormat() } else { - this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) + this.contents.applyUnorderedListFormat() } } @@ -152,7 +151,7 @@ export class CommandDispatcher { if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") { this.contents.applyParagraphFormat() } else { - this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) + this.contents.applyOrderedListFormat() } } diff --git a/src/editor/contents.js b/src/editor/contents.js index 9ebf0af84..da0923fe1 100644 --- a/src/editor/contents.js +++ b/src/editor/contents.js @@ -9,6 +9,7 @@ import { import { $generateNodesFromDOM } from "@lexical/html" import { $createCodeNode, $isCodeNode, CodeNode } from "@lexical/code" import { $createHeadingNode, $createQuoteNode, $isQuoteNode, QuoteNode } from "@lexical/rich-text" +import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list" import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" import { $createLinkNode, $toggleLink } from "@lexical/link" import { dispatch, parseHtml } from "../helpers/html_helper" @@ -74,6 +75,16 @@ export default class Contents { $setBlocksType(selection, () => $createHeadingNode(tag)) } + applyUnorderedListFormat() { + this.#splitParagraphsAtLineBreaksUnlessInsideList() + this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) + } + + applyOrderedListFormat() { + this.#splitParagraphsAtLineBreaksUnlessInsideList() + this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) + } + clearFormatting() { const selection = $getSelection() if (!$isRangeSelection(selection)) return @@ -378,9 +389,18 @@ export default class Contents { codeNode.remove() } + #splitParagraphsAtLineBreaksUnlessInsideList() { + if (this.selection.isInsideList) return + + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + + this.#splitParagraphsAtLineBreaks(selection) + } + #splitParagraphsAtLineBreaks(selection) { - const anchorKey = selection.anchor.getNode().getKey() - const focusKey = selection.focus.getNode().getKey() + const anchorTopLevel = selection.anchor.getNode().getTopLevelElement() + const focusTopLevel = selection.focus.getNode().getTopLevelElement() const topLevelElements = this.#topLevelElementsInSelection(selection) for (const element of topLevelElements) { @@ -392,10 +412,9 @@ export default class Contents { // Check whether this paragraph needs splitting: skip only if neither // selection endpoint is inside it (meaning it's a middle paragraph // fully between anchor and focus with no partial lines to split off). - const hasEndpoint = children.some(child => - child.getKey() === anchorKey || child.getKey() === focusKey - ) - if (!hasEndpoint) continue + // Compare top-level elements so endpoints inside nested inline nodes + // (e.g. text inside a LinkNode) are still recognized. + if (element !== anchorTopLevel && element !== focusTopLevel) continue const groups = [ [] ] for (const child of children) { diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index 52244f993..b6c01404a 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -214,6 +214,51 @@ test.describe("Block formatting", () => { ) }) + test("bullet list only the selected line from soft line breaks", async ({ + page, + editor, + }) => { + await editor.setValue("

First line
Second line
Third line

") + await editor.select("Second line") + + await page.getByRole("button", { name: "Bullet list" }).click() + + await assertEditorHtml( + editor, + "

First line

  • Second line

Third line

", + ) + }) + + test("numbered list only the selected line from soft line breaks", async ({ + page, + editor, + }) => { + await editor.setValue("

First line
Second line
Third line

") + await editor.select("Second line") + + await page.getByRole("button", { name: "Numbered list" }).click() + + await assertEditorHtml( + editor, + "

First line

  1. Second line

Third line

", + ) + }) + + test("shift+enter inside a list item creates a line break, not a new item", async ({ + editor, + }) => { + await editor.setValue("
  • First item
") + await editor.select("First item") + await editor.send("End") + await editor.send("Shift+Enter") + await editor.send("continuation") + + await assertEditorHtml( + editor, + "
  • First item
    continuation
", + ) + }) + test("links", async ({ page, editor }) => { await editor.setValue(HELLO_EVERYONE) await editor.select("everyone") From b8993b8deab9f5646b78cc7e9a4fe7ad227b6873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 8 Apr 2026 21:22:41 +0100 Subject: [PATCH 060/199] import Prismjs with grammars into external helper --- src/config/prism.js | 6 ++++-- src/helpers/code_highlighting_helper.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/config/prism.js b/src/config/prism.js index 882efc279..903398a5c 100644 --- a/src/config/prism.js +++ b/src/config/prism.js @@ -1,9 +1,9 @@ // Configure Prism for manual highlighting mode // This must be set before importing prismjs -window.Prism = window.Prism || {} +window.Prism ||= {} window.Prism.manual = true -import "prismjs" +import Prism from "prismjs" // Import base language dependencies first import "prismjs/components/prism-clike" @@ -17,3 +17,5 @@ import "prismjs/components/prism-go" import "prismjs/components/prism-bash" import "prismjs/components/prism-json" import "prismjs/components/prism-diff" + +export default Prism diff --git a/src/helpers/code_highlighting_helper.js b/src/helpers/code_highlighting_helper.js index dced519a4..19d9a30b0 100644 --- a/src/helpers/code_highlighting_helper.js +++ b/src/helpers/code_highlighting_helper.js @@ -1,5 +1,5 @@ import { createElement } from "./html_helper" -import Prism from "prismjs" +import Prism from "../config/prism" export function highlightCode() { const elements = document.querySelectorAll("pre[data-language]") From f3a2cccb5ec576f056bdddf71f37db9a9eb4f051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 12:41:12 +0100 Subject: [PATCH 061/199] Test grammars are included --- .../helpers/code_highlighting_helper.test.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 test/javascript/unit/helpers/code_highlighting_helper.test.js diff --git a/test/javascript/unit/helpers/code_highlighting_helper.test.js b/test/javascript/unit/helpers/code_highlighting_helper.test.js new file mode 100644 index 000000000..31e4c4879 --- /dev/null +++ b/test/javascript/unit/helpers/code_highlighting_helper.test.js @@ -0,0 +1,20 @@ +import { expect, test } from "vitest" +import { highlightCode } from "../../../../src/helpers/code_highlighting_helper" + +const Prism = window.Prism + +const expectedGrammars = [ + "clike", + "markup", + "markup-templating", + "ruby", + "php", + "go", + "bash", + "json", + "diff", +] + +test.each(expectedGrammars)("Prism includes the %s grammar", (grammar) => { + expect(Prism.languages[grammar]).toBeDefined() +}) From 30c8dbc7800efc8aee3094e632f29735cf35ca48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 12:49:45 +0100 Subject: [PATCH 062/199] Remove deprecated legacy export BREAKING CHANGE: removed deprecated `highlightAll` export from < v0.7.0.beta --- src/index.js | 3 --- .../app/javascript/controllers/events_logger_controller.js | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index 19c436e82..cd46f69d4 100644 --- a/src/index.js +++ b/src/index.js @@ -12,8 +12,5 @@ export { NativeAdapter } from "./editor/adapters/native_adapter" export const configure = Lexxy.configure export { default as Extension } from "./extensions/lexxy_extension" -// legacy export for <=v0.7 -export { highlightCode as highlightAll } from "./helpers/code_highlighting_helper" - // Pushing elements definition to after the current call stack to allow global configuration to take place first setTimeout(defineElements, 0) diff --git a/test/dummy/app/javascript/controllers/events_logger_controller.js b/test/dummy/app/javascript/controllers/events_logger_controller.js index bec936354..f736aaa63 100644 --- a/test/dummy/app/javascript/controllers/events_logger_controller.js +++ b/test/dummy/app/javascript/controllers/events_logger_controller.js @@ -1,5 +1,5 @@ import { Controller } from "@hotwired/stimulus" -import { highlightAll } from "lexxy" +import { highlightCode } from "lexxy" export default class extends Controller { static targets = ["log"] From 65bc4ef2125df99727ebb1aca7cbbd4e39e6da68 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 14:39:54 +0200 Subject: [PATCH 063/199] Enable editable captions for video attachments (#950) Video attachments were rendered with a static file-style caption (just the filename and size), making it impossible to add a custom caption. Images already got an editable textarea via the `isPreviewableAttachment` path, but videos fell through to the non-editable `#createDOMForNotImage()` path. Add an `isVideo` getter and a dedicated branch in `createDOM()` so video attachments get the file icon alongside an editable caption textarea. --- src/nodes/action_text_attachment_node.js | 7 +++ .../tests/attachments/video_caption.test.js | 55 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/browser/tests/attachments/video_caption.test.js diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index a41430f77..dca6f58f2 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -110,6 +110,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { if (this.isPreviewableAttachment) { figure.appendChild(this.#createDOMForImage()) figure.appendChild(this.#createEditableCaption()) + } else if (this.isVideo) { + figure.appendChild(this.#createDOMForFile()) + figure.appendChild(this.#createEditableCaption()) } else { figure.appendChild(this.#createDOMForFile()) figure.appendChild(this.#createDOMForNotImage()) @@ -203,6 +206,10 @@ export class ActionTextAttachmentNode extends DecoratorNode { return isPreviewableImage(this.contentType) } + get isVideo() { + return this.contentType.startsWith("video/") + } + #createDOMForPendingPreview() { const figure = this.createAttachmentFigure(false) figure.appendChild(this.#createDOMForFile()) diff --git a/test/browser/tests/attachments/video_caption.test.js b/test/browser/tests/attachments/video_caption.test.js new file mode 100644 index 000000000..3f12cc88c --- /dev/null +++ b/test/browser/tests/attachments/video_caption.test.js @@ -0,0 +1,55 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +const videoAttachment = (attrs = {}) => { + const defaults = { + sgid: "test-sgid-video", + "content-type": "video/mp4", + filename: "clip.mp4", + filesize: "98765", + url: "http://example.com/clip.mp4", + } + const merged = { ...defaults, ...attrs } + const attrString = Object.entries(merged).map(([k, v]) => `${k}="${v}"`).join(" ") + return `` +} + +test.describe("Video attachment caption", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/attachments-enabled.html") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("video attachment has an editable caption", async ({ page, editor }) => { + await editor.setValue(videoAttachment()) + await editor.flush() + + const figure = page.locator("figure.attachment") + await expect(figure).toBeVisible() + + // Video attachments should have an editable caption textarea, just like images + const caption = figure.locator("figcaption textarea") + await expect(caption).toBeVisible() + await expect(caption).toHaveAttribute("placeholder", "clip.mp4") + }) + + test("video attachment caption can be edited and saved", async ({ page, editor }) => { + await editor.setValue(videoAttachment()) + await editor.flush() + + const figure = page.locator("figure.attachment") + await expect(figure).toBeVisible() + + const caption = figure.locator("figcaption textarea") + await expect(caption).toBeVisible() + + await caption.click() + await caption.pressSequentially("My video caption") + await caption.press("Enter") + + await expect.poll(async () => { + await editor.flush() + return await editor.value() + }, { timeout: 5_000 }).toContain('caption="My video caption"') + }) +}) From 29a994b80f96fe8f7ca541c85614e2bc99f58758 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 14:40:12 +0200 Subject: [PATCH 064/199] Convert empty first list item to paragraph on backspace (#948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing backspace on an empty first list item in a bullet or numbered list now converts it to a paragraph instead of deleting it. This matches the standard editor behavior users expect: backspace at the start of a list item should remove the list formatting, not the content. Previously, `#removeEmptyListItem` treated the first item the same as middle items — it deleted the empty item and moved the cursor to the next sibling. Now, first items (no previous sibling) are unwrapped into a paragraph inserted before the list, while middle items continue to be deleted with the cursor placed at the end of the previous sibling. --- src/editor/selection.js | 29 +++++--- .../formatting/list_item_deletion.test.js | 68 ++++++++++++++----- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/src/editor/selection.js b/src/editor/selection.js index 9034c0228..10102e658 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -586,13 +586,20 @@ export default class Selection { } // When backspace is pressed on an empty list item that has siblings, - // remove the empty item and place the cursor appropriately. Without this, - // Lexical's default collapseAtStart converts the empty item into a paragraph - // above the list, causing the cursor to jump away from the list content. + // handle the deletion appropriately: // - // This only applies when there IS a next sibling — if the empty item is the - // last one in the list, Lexical's default (convert to paragraph) provides - // the standard "exit list" behavior. + // - Middle/end items (has previous sibling): remove the empty item and + // place the cursor at the end of the previous sibling. Without this, + // Lexical's default collapseAtStart converts the empty item into a + // paragraph above the list, causing the cursor to jump away. + // + // - First item (no previous sibling): convert to a paragraph above the + // list, matching the standard "unwrap list formatting" behavior that + // users expect from pressing backspace at the start of a list item. + // + // When the empty item is the last/only one in the list, we return false + // and let Lexical's default (convert to paragraph) provide the standard + // "exit list" behavior. #removeEmptyListItem() { const selection = $getSelection() if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false @@ -609,11 +616,17 @@ export default class Selection { const previousSibling = listItem.getPreviousSibling() if (previousSibling) { previousSibling.selectEnd() - } else { - nextSibling.selectStart() + listItem.remove() + return true } + const listNode = $getNearestNodeOfType(listItem, ListNode) + if (!listNode) return false + + const paragraph = $createParagraphNode() + listNode.insertBefore(paragraph) listItem.remove() + paragraph.selectStart() return true } diff --git a/test/browser/tests/formatting/list_item_deletion.test.js b/test/browser/tests/formatting/list_item_deletion.test.js index 106f04617..578c36514 100644 --- a/test/browser/tests/formatting/list_item_deletion.test.js +++ b/test/browser/tests/formatting/list_item_deletion.test.js @@ -7,8 +7,7 @@ test.describe("List item deletion cursor position", () => { await page.waitForSelector("lexxy-editor[connected]") }) - test("deleting empty first list item keeps cursor in the list", async ({ - page, + test("backspace on empty first list item converts it to a paragraph", async ({ editor, }) => { // Set up a bullet list with one item @@ -29,26 +28,22 @@ test.describe("List item deletion cursor position", () => { await editor.send("ArrowUp") await editor.flush() - // Delete the empty first list item by pressing Backspace. - // This should merge it with the item below or remove it, - // leaving cursor at the start of "Some text". + // Press Backspace on the empty first list item. + // Expected: the empty list item is converted to an empty paragraph above the list. await editor.send("Backspace") await editor.flush() - // The list should still contain "Some text" - await assertEditorHtml(editor, "
  • Some text
") + await assertEditorHtml(editor, "


  • Some text
") // Type a marker character to verify cursor position. - // If cursor is correctly at the start of the list item, - // the marker should appear before "Some text". + // Cursor should be in the new paragraph, so marker appears there. await editor.send("X") await editor.flush() - await assertEditorHtml(editor, "
  • XSome text
") + await assertEditorHtml(editor, "

X

  • Some text
") }) - test("deleting empty first list item does not jump cursor to document root", async ({ - page, + test("backspace on empty first list item with paragraph above converts to paragraph", async ({ editor, }) => { // Set up content before the list, then a list @@ -70,19 +65,60 @@ test.describe("List item deletion cursor position", () => { await editor.send("ArrowUp") await editor.flush() - // Delete the empty list item + // Delete the empty list item — it should become a paragraph await editor.send("Backspace") await editor.flush() // Type a marker to check cursor position. - // The marker should appear at the start of "List item text", - // NOT at the top of the document or in the paragraph above. + // The marker should appear in the new paragraph between "Paragraph above" and the list. await editor.send("X") await editor.flush() await assertEditorHtml( editor, - "

Paragraph above

  • XList item text
", + "

Paragraph above

X

  • List item text
", + ) + }) + + test("deleting empty middle list item keeps cursor in the list", async ({ + editor, + }) => { + // Set up a bullet list with two items + await editor.setValue( + "
  • First item
  • Second item
", + ) + await editor.flush() + + // Place cursor at the start of "Second item" + await editor.content.locator("ul li").nth(1).click() + await editor.send("Home") + await editor.flush() + + // Press Enter to create a blank list item between First and Second + await editor.send("Enter") + await editor.flush() + + // Move cursor up to the empty middle list item + await editor.send("ArrowUp") + await editor.flush() + + // Delete the empty middle list item by pressing Backspace. + // This should remove it and leave cursor at the end of "First item". + await editor.send("Backspace") + await editor.flush() + + await assertEditorHtml( + editor, + "
  • First item
  • Second item
", + ) + + // Type a marker to verify cursor is at the end of "First item" + await editor.send("X") + await editor.flush() + + await assertEditorHtml( + editor, + "
  • First itemX
  • Second item
", ) }) }) From d658e6d77a38b1304e7284eb16035a268f6c149f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 14:48:03 +0200 Subject: [PATCH 065/199] Fix code blocks only allowing one highlight at a time (#955) * Fix code blocks only allowing one highlight at a time Applying a second highlight in a code block removed the first and failed to apply the second. The root cause was twofold: 1. `$selectionIsInCodeBlock` only recognized `CodeHighlightNode` instances, but after the retokenizer/reapplication cycle, the code block could contain plain `TextNode` instances (created by `splitText`) that are children of the `CodeNode`. The second highlight selection fell through to the normal `$patchStyleText` path instead of the code-block-aware `$patchCodeHighlightStyles`. 2. `$patchCodeHighlightStyles` filtered selection nodes for `$isCodeHighlightNode` only, excluding `TextNode` children of the code block. Fix: broaden both checks to accept any text node whose parent is a `CodeNode`. Also save highlight ranges after applying styles so they survive the inevitable retokenization that follows (reusing the same pending-highlights mechanism used for HTML import). * Address Copilot review: skip plain TextNodes in highlight apply, drop unused import - $applyHighlightRangesToCodeNode now skips children that aren't CodeHighlightNodes, avoiding a potential getHighlightType() crash if a TextNode lingers between retokenization passes. The iteration still walks all children so character offsets stay aligned. - Remove unused assertEditorContent import from the regression test. --- src/extensions/highlight_extension.js | 61 ++++++++++++++++--- ...bug_code_block_multiple_highlights.test.js | 39 ++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 test/browser/tests/formatting/bug_code_block_multiple_highlights.test.js diff --git a/src/extensions/highlight_extension.js b/src/extensions/highlight_extension.js index 88c1b9195..380f2ec15 100644 --- a/src/extensions/highlight_extension.js +++ b/src/extensions/highlight_extension.js @@ -1,4 +1,4 @@ -import { $getNodeByKey, $getState, $hasUpdateTag, $setState, COMMAND_PRIORITY_NORMAL, PASTE_TAG, TextNode, createCommand, createState, defineExtension } from "lexical" +import { $getNodeByKey, $getState, $hasUpdateTag, $isTextNode, $setState, COMMAND_PRIORITY_NORMAL, PASTE_TAG, TextNode, createCommand, createState, defineExtension } from "lexical" import { $getSelection, $isRangeSelection } from "lexical" import { $getSelectionStyleValueForProperty, $patchStyleText, getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" import { $createCodeHighlightNode, $createCodeNode, $isCodeHighlightNode, $isCodeNode, CodeHighlightNode, CodeNode } from "@lexical/code" @@ -225,6 +225,13 @@ function $applyHighlightRangesToCodeNode(codeNode, highlights) { const childRanges = $buildChildRanges(codeNode) for (const { node, start: nodeStart, end: nodeEnd } of childRanges) { + // Skip plain TextNodes: only CodeHighlightNodes can be split into + // styled replacements here. The retokenizer normally converts any + // TextNode children back to CodeHighlightNodes before this runs, + // but the iteration over $buildChildRanges has to keep counting + // them so character offsets stay aligned with the saved ranges. + if (!$isCodeHighlightNode(node)) continue + // Check if this child overlaps with the highlight range const overlapStart = Math.max(hlStart, nodeStart) const overlapEnd = Math.min(hlEnd, nodeEnd) @@ -273,7 +280,7 @@ function $buildChildRanges(codeNode) { let charOffset = 0 for (const child of codeNode.getChildren()) { - if ($isCodeHighlightNode(child)) { + if ($isCodeHighlightNode(child) || $isTextNode(child)) { const text = child.getTextContent() childRanges.push({ node: child, start: charOffset, end: charOffset + text.length }) charOffset += text.length @@ -286,6 +293,23 @@ function $buildChildRanges(codeNode) { return childRanges } +// Extract highlight ranges from the Lexical node tree of a CodeNode. +// This mirrors extractHighlightRanges (which works on DOM elements during +// HTML import) but reads from live CodeHighlightNode children instead. +function $extractHighlightRangesFromCodeNode(codeNode) { + const ranges = [] + const childRanges = $buildChildRanges(codeNode) + + for (const { node, start, end } of childRanges) { + const style = node.getStyle() + if (style && hasHighlightStyles(style)) { + ranges.push({ start, end, style }) + } + } + + return ranges +} + function buildCanonicalizers(config) { return [ new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]), @@ -313,15 +337,23 @@ function $toggleSelectionStyles(editor, styles) { function $selectionIsInCodeBlock(selection) { const nodes = selection.getNodes() return nodes.some((node) => { - const parent = $isCodeHighlightNode(node) ? node.getParent() : node - return $isCodeNode(parent) + // A text node inside a code block may be either a CodeHighlightNode + // (after retokenization) or a plain TextNode (after splitText or before + // the retokenizer has run). Check the parent in both cases. + if ($isCodeHighlightNode(node) || $isTextNode(node)) { + return $isCodeNode(node.getParent()) + } + return $isCodeNode(node) }) } function $patchCodeHighlightStyles(editor, selection, patch) { - // Capture selection state and node keys before the nested update + // Capture selection state and node keys before the nested update. + // Accept both CodeHighlightNode and TextNode children of a CodeNode + // because splitText creates TextNode instances and the retokenizer + // may not have converted them back to CodeHighlightNodes yet. const nodeKeys = selection.getNodes() - .filter((node) => $isCodeHighlightNode(node)) + .filter((node) => ($isCodeHighlightNode(node) || $isTextNode(node)) && $isCodeNode(node.getParent())) .map((node) => ({ key: node.getKey(), startOffset: $getNodeSelectionOffsets(node, selection)[0], @@ -335,14 +367,18 @@ function $patchCodeHighlightStyles(editor, selection, patch) { // are committed before editor.focus() triggers a second update cycle // that would re-run transforms and wipe out the styles. editor.update(() => { + const affectedCodeNodes = new Set() + for (const { key, startOffset, endOffset, textSize } of nodeKeys) { const node = $getNodeByKey(key) - if (!node || !$isCodeHighlightNode(node)) continue + if (!node) continue const parent = node.getParent() if (!$isCodeNode(parent)) continue if (startOffset === endOffset) continue + affectedCodeNodes.add(parent) + if (startOffset === 0 && endOffset === textSize) { $applyStylePatchToNode(node, patch) } else { @@ -351,6 +387,17 @@ function $patchCodeHighlightStyles(editor, selection, patch) { $applyStylePatchToNode(targetNode, patch) } } + + // After applying styles, save highlight ranges for each affected CodeNode. + // The code retokenizer will replace the styled nodes with fresh unstyled + // tokens when transforms run. The pending highlights are picked up by the + // CodeNode mutation listener and reapplied after retokenization. + for (const codeNode of affectedCodeNodes) { + const ranges = $extractHighlightRangesFromCodeNode(codeNode) + if (ranges.length > 0) { + $getPendingHighlights(editor).set(codeNode.getKey(), ranges) + } + } }, { skipTransforms: true, discrete: true }) } diff --git a/test/browser/tests/formatting/bug_code_block_multiple_highlights.test.js b/test/browser/tests/formatting/bug_code_block_multiple_highlights.test.js new file mode 100644 index 000000000..da5a89032 --- /dev/null +++ b/test/browser/tests/formatting/bug_code_block_multiple_highlights.test.js @@ -0,0 +1,39 @@ +import { test } from "../../test_helper.js" +import { applyHighlightOption } from "../../helpers/toolbar.js" +import { expect } from "@playwright/test" + +test.describe("Bug: Code block allows only one highlight at a time", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("applying two highlights to different words in a code block preserves both", async ({ page, editor }) => { + await editor.setValue('
hello world goodbye
') + await expect(page.locator("select[name=lexxy-code-language]")).toHaveValue("plain") + + // Apply first highlight to "hello" + await editor.select("hello") + await applyHighlightOption(page, "background-color", 1) + + // Wait for first highlight to stabilize (retokenizer + reapplication cycle) + await expect(async () => { + await editor.flush() + await expect(editor.content.locator("code mark")).toHaveCount(1) + await expect(editor.content.locator("code mark").first()).toContainText("hello") + }).toPass({ timeout: 5_000 }) + + // Apply second highlight to "goodbye" + await editor.select("goodbye") + await applyHighlightOption(page, "background-color", 2) + + // Both highlights should be present + await expect(async () => { + await editor.flush() + await expect(editor.content.locator("code mark")).toHaveCount(2) + await expect(editor.content.locator("code mark").nth(0)).toContainText("hello") + await expect(editor.content.locator("code mark").nth(1)).toContainText("goodbye") + }).toPass({ timeout: 5_000 }) + }) +}) From e995ece3e17e79dffe2235ac500ec51cf0732e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 13:49:55 +0100 Subject: [PATCH 066/199] Click attachments after upload (#957) The race condition with the new src swapping was flaking the tests as selection was cleared when the src was swapped. --- test/browser/tests/attachments/gallery.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/browser/tests/attachments/gallery.test.js b/test/browser/tests/attachments/gallery.test.js index 87cc82854..782f4cd17 100644 --- a/test/browser/tests/attachments/gallery.test.js +++ b/test/browser/tests/attachments/gallery.test.js @@ -26,7 +26,7 @@ test.describe("Gallery", () => { await editor.uploadFile("test/fixtures/files/example.png") await assertAttachmentVisible(page, "image/png") - await page.locator("figure.attachment img").click() + await selectAttachment(page) await editor.uploadFile("test/fixtures/files/example2.png") await assertGalleryWithImages(editor, 2) @@ -53,7 +53,7 @@ test.describe("Gallery", () => { await editor.uploadFile("test/fixtures/files/example.png") await assertAttachmentVisible(page, "image/png") - await page.locator("figure.attachment img").click() + await selectAttachment(page) await editor.uploadFile([ "test/fixtures/files/example2.png", @@ -388,6 +388,12 @@ async function assertGalleryImageSelected(page, index, galleryIndex = 0) { await expect(figure).toHaveClass(/node--selected/) } +async function selectAttachment(page) { + await page.locator("figure.attachment img[src*='/blobs/']").waitFor() + await page.locator("figure.attachment img").click() + await expect(page.locator("figure.attachment")).toHaveClass(/node--selected/) +} + // Mirrors Ruby helper: select image and use arrow keys to position cursor at offset async function selectGalleryAtOffset(page, editor, offset, galleryIndex = 0) { if (offset === 0) { From f8f5496d072ca1f96c7fe820e94e983640462c38 Mon Sep 17 00:00:00 2001 From: Zacharias Knudsen Date: Thu, 9 Apr 2026 14:52:59 +0200 Subject: [PATCH 067/199] Build sanitizer allowlist dynamically (#909) * Build sanitizer allowlist dynamically Replace the hardcoded ALLOWED_HTML_TAGS and move attachment-specific attributes out of the global list. Tags are now derived from Lexical's import conversion map, and extensions declare additional elements via an allowedElements getter. * Unwrap textnode spans during export --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jorgemanrubia <129938+jorgemanrubia@users.noreply.github.com> --- src/config/dom_purify.js | 24 +++++++++++++++--------- src/editor/extensions.js | 4 ++++ src/elements/editor.js | 13 +++++++++++-- src/extensions/attachments_extension.js | 7 +++++++ src/extensions/lexxy_extension.js | 4 ++++ src/extensions/tables_extension.js | 4 ++++ src/helpers/sanitization_helper.js | 4 ++-- src/helpers/text_node_export_helper.js | 17 +++++++++++++++-- 8 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index 8c1ae74f9..b680f8943 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -1,13 +1,7 @@ import DOMPurify from "dompurify" import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" -import Lexxy from "./lexxy" -const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em", - "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "u", "ul", "table", "tbody", "tr", "th", "td" ] - -const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable", - "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation", - "previewable", "sgid", "src", "style", "title", "url", "width" ] +const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ] const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ] @@ -38,10 +32,22 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => { } }) -export function buildConfig() { +export function buildConfig(allowedElements) { + const tagAttributes = {} + + for (const element of allowedElements) { + if (typeof element === "string") { + tagAttributes[element] ||= [] + } else { + tagAttributes[element.tag] ||= [] + tagAttributes[element.tag].push(...element.attributes) + } + } + return { - ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")), + ALLOWED_TAGS: Object.keys(tagAttributes), ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES, + ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute), ADD_URI_SAFE_ATTR: [ "caption", "filename" ], SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content) } diff --git a/src/editor/extensions.js b/src/editor/extensions.js index 186ebee39..0dc3b69cd 100644 --- a/src/editor/extensions.js +++ b/src/editor/extensions.js @@ -36,6 +36,10 @@ export default class Extensions { }) } + get allowedElements() { + return this.enabledExtensions.flatMap(ext => ext.allowedElements) + } + get #lexxyToolbar() { return this.lexxyElement.toolbar } diff --git a/src/elements/editor.js b/src/elements/editor.js index d91e6718e..f125f56ee 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -228,7 +228,7 @@ export class LexicalEditorElement extends HTMLElement { get value() { if (!this.cachedValue) { this.editor?.getEditorState().read(() => { - this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null)) + this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null), this.#allowedElements) }) } @@ -301,7 +301,7 @@ export class LexicalEditorElement extends HTMLElement { theme: theme, nodes: this.#lexicalNodes, html: { - export: new Map([ [ TextNode, exportTextNodeDOM ] ]) + export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ]) } }, ...this.extensions.lexicalExtensions @@ -570,6 +570,15 @@ export class LexicalEditorElement extends HTMLElement { } } + get #allowedElements() { + return this.#importableTags.concat(this.extensions.allowedElements) + } + + get #importableTags() { + const tags = Array.from(this.editor._htmlConversions.keys()) + return tags.filter(tag => !tag.startsWith("#")) + } + #dispatchAttributesChange() { let attributes = null let linkHref = null diff --git a/src/extensions/attachments_extension.js b/src/extensions/attachments_extension.js index 60c19a82f..f72c99586 100644 --- a/src/extensions/attachments_extension.js +++ b/src/extensions/attachments_extension.js @@ -9,11 +9,18 @@ import { AttachmentDragAndDrop } from "../editor/attachments/drag_and_drop" import LexxyExtension from "./lexxy_extension" import { $isAtNodeEdge } from "../helpers/lexical_helper.js" +const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id", + "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ] + export class AttachmentsExtension extends LexxyExtension { get enabled() { return this.editorElement.supportsAttachments } + get allowedElements() { + return [ { tag: ActionTextAttachmentNode.TAG_NAME, attributes: ATTACHMENT_ATTRIBUTES } ] + } + get lexicalExtension() { return defineExtension({ name: "lexxy/action-text-attachments", diff --git a/src/extensions/lexxy_extension.js b/src/extensions/lexxy_extension.js index aecaf8f7a..953d51e54 100644 --- a/src/extensions/lexxy_extension.js +++ b/src/extensions/lexxy_extension.js @@ -22,6 +22,10 @@ export default class LexxyExtension { return null } + get allowedElements() { + return [] + } + initializeToolbar(_lexxyToolbar) { } diff --git a/src/extensions/tables_extension.js b/src/extensions/tables_extension.js index 04e84fe0b..21858638a 100644 --- a/src/extensions/tables_extension.js +++ b/src/extensions/tables_extension.js @@ -24,6 +24,10 @@ export class TablesExtension extends LexxyExtension { return this.editorElement.supportsRichText } + get allowedElements() { + return [ "figure", "tbody" ] + } + get lexicalExtension() { return defineExtension({ name: "lexxy/tables", diff --git a/src/helpers/sanitization_helper.js b/src/helpers/sanitization_helper.js index d2f2acdcf..8942fd0aa 100644 --- a/src/helpers/sanitization_helper.js +++ b/src/helpers/sanitization_helper.js @@ -1,8 +1,8 @@ import DOMPurify from "dompurify" import { buildConfig } from "../config/dom_purify" -export function sanitize(html) { - return DOMPurify.sanitize(html, buildConfig()) +export function sanitize(html, allowedElements) { + return DOMPurify.sanitize(html, buildConfig(allowedElements)) } // Sanitize HTML for custom attachment content (mentions, cards, etc.). diff --git a/src/helpers/text_node_export_helper.js b/src/helpers/text_node_export_helper.js index 1b2fe4801..2d1ed5543 100644 --- a/src/helpers/text_node_export_helper.js +++ b/src/helpers/text_node_export_helper.js @@ -1,4 +1,4 @@ -// Custom TextNode exportDOM that avoids redundant bold/italic wrapping. +// Custom TextNode exportDOM that avoids redundant wrapping. // // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags // like for bold and for italic, then unconditionally wraps the result @@ -8,6 +8,9 @@ // This custom export skips when is already present and when is // already present, while preserving and wrappers which have no semantic equivalents // in createDOM's output. +// +// Any elements produced by createDOM() are unwrapped, since they only carry +// editor classes that aren't meaningful in exported HTML. export function exportTextNodeDOM(editor, textNode) { const element = textNode.createDOM(editor._config, editor) @@ -36,7 +39,7 @@ export function exportTextNodeDOM(editor, textNode) { result = wrapWith(result, "u") } - return { element: result } + return { element: unwrapSpans(result) } } function containsTag(element, tagName) { @@ -51,3 +54,13 @@ function wrapWith(element, tag) { wrapper.appendChild(element) return wrapper } + +function unwrapSpans(element) { + if (element.tagName === "SPAN") return element.firstChild + + for (const span of element.querySelectorAll("span")) { + span.replaceWith(...span.childNodes) + } + + return element +} From 2336c03617922422d248239e3aeda2fd76d9ba1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 13:53:41 +0100 Subject: [PATCH 068/199] Fix @mentions menu rendering out of bounds near right edge (#913) When the mentions popover is clipped at the right edge, it flips to inset-inline-end: 1ch but the max-inline-size was still based on the original left offset, allowing the popover to overflow. Constrain max-inline-size to calc(100% - 1ch) when right-aligned so it stays within the editor bounds. --- app/assets/stylesheets/lexxy-editor.css | 1 + test/browser/tests/prompts/mentions.test.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 0271efd0a..47a9ca6f2 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -1018,6 +1018,7 @@ &[data-clipped-at-right] { inset-inline-start: unset; inset-inline-end: 1ch; + max-inline-size: calc(100% - 1ch); } &[data-clipped-at-bottom] { diff --git a/test/browser/tests/prompts/mentions.test.js b/test/browser/tests/prompts/mentions.test.js index 1b25b52cc..1d3a6552f 100644 --- a/test/browser/tests/prompts/mentions.test.js +++ b/test/browser/tests/prompts/mentions.test.js @@ -81,4 +81,24 @@ test.describe("Mentions", () => { expect(positions.textTop).toBeLessThan(positions.mentionBottom) expect(positions.textBottom).toBeGreaterThan(positions.mentionTop) }) + + test("popover stays within viewport when triggered near right edge", async ({ page, editor }) => { + await page.setViewportSize({ width: 400, height: 600 }) + await editor.locator.evaluate((el) => { + el.style.width = "150px" + el.style.marginLeft = "auto" + }) + + await editor.send("Some text @") + + const popover = page.locator(".lexxy-prompt-menu--visible") + await expect(popover).toBeVisible({ timeout: 5_000 }) + + const rect = await popover.evaluate((el) => { + const r = el.getBoundingClientRect() + return { left: r.left, right: r.right } + }) + expect(rect.right).toBeLessThanOrEqual(400) + expect(rect.left).toBeGreaterThanOrEqual(0) + }) }) From 1b97d82cc2f038619e9f01cda58ff89017fa119f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 14:57:54 +0200 Subject: [PATCH 069/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index e4d49e7cc..f5ecddbbc 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.3.beta" + VERSION = "0.9.4.beta" end From 898255ed1cb270e360dd1c83006458cc0ce6586d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 15:03:09 +0200 Subject: [PATCH 070/199] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a638493a..cff84d645 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@37signals/lexxy", - "version": "0.9.3-beta", + "version": "0.9.4-beta", "description": "Lexxy - A modern rich text editor for Rails.", "module": "dist/lexxy.esm.js", "type": "module", From 213e84ef7f8c486449007acf189b1ba719b9b89f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 15:33:42 +0200 Subject: [PATCH 071/199] Configure Action Text adapter as :lexxy automatically (#962) * Configure Action Text adapter as :lexxy automatically When Rails supports the editor adapter API, the engine now sets config.action_text.editor = :lexxy so host apps don't need to configure it manually. * Use ||= to avoid overriding explicit host-app configuration * Revert ||= back to = since Rails defaults editor to :trix Rails sets config.action_text.editor = :trix at class load time, so ||= would never override it. We need a hard assignment. --- lib/lexxy/engine.rb | 1 + test/dummy/config/application.rb | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/engine.rb b/lib/lexxy/engine.rb index 9ac030dab..d5cdd8801 100644 --- a/lib/lexxy/engine.rb +++ b/lib/lexxy/engine.rb @@ -13,6 +13,7 @@ class Engine < ::Rails::Engine initializer "lexxy.action_text_editor", before: "action_text.editors" do |app| app.config.action_text.editors[:lexxy] = {} + app.config.action_text.editor = :lexxy end else # Rails 8.0/8.1 fallback: monkey-patch Action Text helpers diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 2c3f0f11c..7826770e0 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -27,6 +27,5 @@ class Application < Rails::Application # config.eager_load_paths << Rails.root.join("extras") config.hosts = [] - config.action_text.editor = :lexxy if Lexxy.supports_editor_adapter? end end From bfa9146fdd91978c2d78b6a84832423d7f58fe2b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 15:34:41 +0200 Subject: [PATCH 072/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index f5ecddbbc..c44efbd82 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.4.beta" + VERSION = "0.9.5.beta" end From a164809a5620095b6282ed2e8ad9d49606faecb5 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Apr 2026 15:36:26 +0200 Subject: [PATCH 073/199] v0.9.5-beta --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cff84d645..ad3ca3d4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@37signals/lexxy", - "version": "0.9.4-beta", + "version": "0.9.5-beta", "description": "Lexxy - A modern rich text editor for Rails.", "module": "dist/lexxy.esm.js", "type": "module", From a1134bfb240b29c1126cae5fdf1506c10f000248 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 7 Apr 2026 21:11:55 +0200 Subject: [PATCH 074/199] Allow inserting text before a code block When a code block is the first element in the editor, there was no way to place the cursor above it to add preceding content. Two changes fix this: 1. EarlyEscapeCodeNode: pressing Enter with the cursor at the very start of a code block now inserts a paragraph before the block (mirroring the existing "escape from the end" behavior). 2. ProvisionalParagraphNode: code blocks are now treated as non-selectable elements for provisional-paragraph purposes, so an invisible spacer paragraph appears above a leading code block, giving the cursor a place to land. --- src/nodes/early_escape_code_node.js | 18 ++++++ src/nodes/provisional_paragraph_node.js | 11 +++- .../formatting/code_block_navigation.test.js | 56 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 test/browser/tests/formatting/code_block_navigation.test.js diff --git a/src/nodes/early_escape_code_node.js b/src/nodes/early_escape_code_node.js index f694f47f7..28cab397d 100644 --- a/src/nodes/early_escape_code_node.js +++ b/src/nodes/early_escape_code_node.js @@ -17,6 +17,13 @@ export class EarlyEscapeCodeNode extends CodeNode { insertNewAfter(selection, restoreSelection) { if (!selection.isCollapsed()) return super.insertNewAfter(selection, restoreSelection) + if (this.#isCursorAtStart(selection)) { + const paragraph = $createParagraphNode() + this.insertBefore(paragraph) + this.selectStart() + return null + } + if (this.#isCursorOnEmptyLastLine(selection)) { $trimTrailingBlankNodes(this) @@ -28,6 +35,17 @@ export class EarlyEscapeCodeNode extends CodeNode { return super.insertNewAfter(selection, restoreSelection) } + #isCursorAtStart(selection) { + const { anchor } = selection + if (anchor.offset !== 0) return false + + const anchorNode = anchor.getNode() + if (anchorNode === this) return true + + const firstChild = this.getFirstChild() + return firstChild !== null && anchorNode === firstChild + } + #isCursorOnEmptyLastLine(selection) { if (!$isCursorOnLastLine(selection)) return false diff --git a/src/nodes/provisional_paragraph_node.js b/src/nodes/provisional_paragraph_node.js index 84bea10c3..0ac126347 100644 --- a/src/nodes/provisional_paragraph_node.js +++ b/src/nodes/provisional_paragraph_node.js @@ -1,4 +1,5 @@ import { $createParagraphNode, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, ParagraphNode } from "lexical" +import { $isCodeNode } from "@lexical/code" export class ProvisionalParagraphNode extends ParagraphNode { $config() { @@ -98,5 +99,13 @@ export function $isProvisionalParagraphNode(node) { } function $isSelectableElement(node, direction) { - return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter()) + if (!$isElementNode(node)) return false + + // Code blocks report canInsertTextBefore/After as true, but placing the + // cursor at their boundary puts you *inside* the code block, not before/after + // it in the normal document flow. Treat them as non-selectable so provisional + // paragraphs are inserted around them. + if ($isCodeNode(node)) return false + + return direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter() } diff --git a/test/browser/tests/formatting/code_block_navigation.test.js b/test/browser/tests/formatting/code_block_navigation.test.js new file mode 100644 index 000000000..7e9f0da21 --- /dev/null +++ b/test/browser/tests/formatting/code_block_navigation.test.js @@ -0,0 +1,56 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { assertEditorContent } from "../../helpers/assertions.js" + +test.describe("Code block navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("pressing Enter at start of code block inserts paragraph before it", async ({ editor }) => { + await editor.setValue("
some code
") + + // Click directly on the code block text to place cursor inside it + await editor.content.locator("code").click() + await editor.flush() + + // Move to the very beginning of the code block + await editor.send("Home") + await editor.flush() + + // Press Enter at start to create paragraph before code block + await editor.send("Enter") + await editor.flush() + + // ArrowUp moves to the new paragraph above + await editor.send("ArrowUp") + await editor.send("text before code") + + await assertEditorContent(editor, async (content) => { + const paragraphs = content.locator("p:not(.provisional-paragraph)") + await expect(paragraphs).toHaveCount(1) + await expect(paragraphs.first()).toContainText("text before code") + await expect(content.locator("code")).toContainText("some code") + }) + }) + + test("code block content is preserved after inserting paragraph before it", async ({ editor }) => { + await editor.setValue("
line one\nline two
") + + await editor.content.locator("code").click() + await editor.flush() + + await editor.send("Home") + await editor.flush() + + await editor.send("Enter") + await editor.flush() + + await assertEditorContent(editor, async (content) => { + await expect(content.locator("code")).toContainText("line one") + await expect(content.locator("code")).toContainText("line two") + }) + }) +}) From 63b923295151e54a0fd6a807ef6c074f06a50271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 14:55:28 +0100 Subject: [PATCH 075/199] Revert placing provisional paragraphs around code blocks --- src/nodes/provisional_paragraph_node.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/nodes/provisional_paragraph_node.js b/src/nodes/provisional_paragraph_node.js index 0ac126347..84bea10c3 100644 --- a/src/nodes/provisional_paragraph_node.js +++ b/src/nodes/provisional_paragraph_node.js @@ -1,5 +1,4 @@ import { $createParagraphNode, $getSelection, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, ParagraphNode } from "lexical" -import { $isCodeNode } from "@lexical/code" export class ProvisionalParagraphNode extends ParagraphNode { $config() { @@ -99,13 +98,5 @@ export function $isProvisionalParagraphNode(node) { } function $isSelectableElement(node, direction) { - if (!$isElementNode(node)) return false - - // Code blocks report canInsertTextBefore/After as true, but placing the - // cursor at their boundary puts you *inside* the code block, not before/after - // it in the normal document flow. Treat them as non-selectable so provisional - // paragraphs are inserted around them. - if ($isCodeNode(node)) return false - - return direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter() + return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter()) } From 2e65fad6ec3805ed3fb150cccbc2f29d581c4425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 15:12:20 +0100 Subject: [PATCH 076/199] Don't change selection returning null will leave selection as-is --- src/nodes/early_escape_code_node.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/nodes/early_escape_code_node.js b/src/nodes/early_escape_code_node.js index 28cab397d..f4df864f7 100644 --- a/src/nodes/early_escape_code_node.js +++ b/src/nodes/early_escape_code_node.js @@ -18,9 +18,7 @@ export class EarlyEscapeCodeNode extends CodeNode { if (!selection.isCollapsed()) return super.insertNewAfter(selection, restoreSelection) if (this.#isCursorAtStart(selection)) { - const paragraph = $createParagraphNode() - this.insertBefore(paragraph) - this.selectStart() + this.insertBefore($createParagraphNode()) return null } From ae3bc29999e6010961dff563aa9429c42c361fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 15:12:41 +0100 Subject: [PATCH 077/199] Use $isAtNodeStart helper --- src/nodes/early_escape_code_node.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nodes/early_escape_code_node.js b/src/nodes/early_escape_code_node.js index f4df864f7..bda243ff0 100644 --- a/src/nodes/early_escape_code_node.js +++ b/src/nodes/early_escape_code_node.js @@ -1,7 +1,7 @@ import { $createParagraphNode } from "lexical" import { CodeNode } from "@lexical/code" import { $getNearestNodeOfType } from "@lexical/utils" -import { $isCursorOnLastLine, $trimTrailingBlankNodes } from "../helpers/lexical_helper" +import { $isAtNodeStart, $isCursorOnLastLine, $trimTrailingBlankNodes } from "../helpers/lexical_helper" export class EarlyEscapeCodeNode extends CodeNode { $config() { @@ -35,7 +35,7 @@ export class EarlyEscapeCodeNode extends CodeNode { #isCursorAtStart(selection) { const { anchor } = selection - if (anchor.offset !== 0) return false + if (!$isAtNodeStart(anchor)) return false const anchorNode = anchor.getNode() if (anchorNode === this) return true From 1172106f845763d4cdfcd85b7c9d883c91de20e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 15:16:24 +0100 Subject: [PATCH 078/199] Use `is()` for identity checks and collapse logic - `anchorNode` will not be null as `getNode()` would throw. - `is()` should be used for identity checks between nodes. Also reads better. --- src/nodes/early_escape_code_node.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/nodes/early_escape_code_node.js b/src/nodes/early_escape_code_node.js index bda243ff0..dcc7b0545 100644 --- a/src/nodes/early_escape_code_node.js +++ b/src/nodes/early_escape_code_node.js @@ -38,10 +38,7 @@ export class EarlyEscapeCodeNode extends CodeNode { if (!$isAtNodeStart(anchor)) return false const anchorNode = anchor.getNode() - if (anchorNode === this) return true - - const firstChild = this.getFirstChild() - return firstChild !== null && anchorNode === firstChild + return this.is(anchorNode) || this.getFirstChild()?.is(anchorNode) } #isCursorOnEmptyLastLine(selection) { From 25691310e669ca11913cf67e2793f4e578985191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 18:25:30 +0100 Subject: [PATCH 079/199] Import Lexical default prism languages --- src/config/prism.js | 24 +++++++++++++++---- .../helpers/code_highlighting_helper.test.js | 21 +++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/config/prism.js b/src/config/prism.js index 903398a5c..ad328c643 100644 --- a/src/config/prism.js +++ b/src/config/prism.js @@ -3,19 +3,35 @@ window.Prism ||= {} window.Prism.manual = true +// Prism and base @lexical/code languages +// Don't reorder: they extend each other import Prism from "prismjs" - -// Import base language dependencies first import "prismjs/components/prism-clike" +import "prismjs/components/prism-diff" +import "prismjs/components/prism-javascript" import "prismjs/components/prism-markup" +import "prismjs/components/prism-markdown" +import "prismjs/components/prism-c" +import "prismjs/components/prism-css" +import "prismjs/components/prism-objectivec" +import "prismjs/components/prism-sql" +import "prismjs/components/prism-powershell" +import "prismjs/components/prism-python" +import "prismjs/components/prism-rust" +import "prismjs/components/prism-swift" +import "prismjs/components/prism-typescript" +import "prismjs/components/prism-java" +import "prismjs/components/prism-cpp" + + +// Import extra base language dependencies import "prismjs/components/prism-markup-templating" -// Import languages +// Import extra languages import "prismjs/components/prism-ruby" import "prismjs/components/prism-php" import "prismjs/components/prism-go" import "prismjs/components/prism-bash" import "prismjs/components/prism-json" -import "prismjs/components/prism-diff" export default Prism diff --git a/test/javascript/unit/helpers/code_highlighting_helper.test.js b/test/javascript/unit/helpers/code_highlighting_helper.test.js index 31e4c4879..a12aa3e26 100644 --- a/test/javascript/unit/helpers/code_highlighting_helper.test.js +++ b/test/javascript/unit/helpers/code_highlighting_helper.test.js @@ -4,15 +4,34 @@ import { highlightCode } from "../../../../src/helpers/code_highlighting_helper" const Prism = window.Prism const expectedGrammars = [ + // lexical default "clike", "markup", + "diff", + "clike", + "diff", + "javascript", + "markup", + "markdown", + "c", + "css", + "objectivec", + "sql", + "powershell", + "python", + "rust", + "swift", + "typescript", + "java", + "cpp", + // extra languages "markup-templating", "ruby", "php", "go", "bash", "json", - "diff", + "kotlin" ] test.each(expectedGrammars)("Prism includes the %s grammar", (grammar) => { From bb431e29ad0e284a86eaa7bf01672594ba45f8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 9 Apr 2026 19:01:07 +0100 Subject: [PATCH 080/199] Support Kotlin --- src/config/prism.js | 1 + src/elements/code_language_picker.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/config/prism.js b/src/config/prism.js index ad328c643..c01b8b180 100644 --- a/src/config/prism.js +++ b/src/config/prism.js @@ -33,5 +33,6 @@ import "prismjs/components/prism-php" import "prismjs/components/prism-go" import "prismjs/components/prism-bash" import "prismjs/components/prism-json" +import "prismjs/components/prism-kotlin" export default Prism diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index 3331c37be..d9512e4a0 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -70,6 +70,8 @@ export class CodeLanguagePicker extends HTMLElement { languages.bash ||= "Bash" languages.json ||= "JSON" languages.diff ||= "Diff" + languages.kotlin ||= "Kotlin" + // Place the "plain" entry first, then the rest of language sorted alphabetically delete languages.plain From fbc3836a163e1e4b6ec587e0a0494475b7745d88 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 10:35:32 +0200 Subject: [PATCH 081/199] Auto-trigger mass-bug-fixing skill for multi-bug requests The skill now activates automatically when the user asks to fix more than one bug at a time, in addition to the existing /mass-bug-fixing slash command invocation. --- .claude/skills/mass-bug-fixing/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/mass-bug-fixing/SKILL.md b/.claude/skills/mass-bug-fixing/SKILL.md index 165c99148..19dd9873d 100644 --- a/.claude/skills/mass-bug-fixing/SKILL.md +++ b/.claude/skills/mass-bug-fixing/SKILL.md @@ -3,7 +3,8 @@ name: mass-bug-fixing description: | Process multiple bugs from a Fizzy board (or GitHub issues) in parallel. Uses worktree agents to reproduce, fix, test, create PRs, and comment on cards. -invocation: user +invocation: auto_and_user +auto_trigger: when the user asks to fix more than one bug at a time (e.g., provides multiple bug URLs, card numbers, or issue references) user_invocation: /mass-bug-fixing --- From 84a1de7249105884c23845a1cd76d5d5b65688e9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 11:49:05 +0200 Subject: [PATCH 082/199] Fix crash when applying list formatting to a selected attachment (#967) List formatting commands (bullet/ordered) crashed when an attachment was selected because NodeSelection has no anchor property. Replace the null check with a $isRangeSelection guard so the commands bail out silently when the selection isn't a text range. --- src/editor/command_dispatcher.js | 4 +- ...st_format_with_attachment_selected.test.js | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 test/browser/tests/formatting/list_format_with_attachment_selected.test.js diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 9a2873d11..cdfbf118e 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -131,7 +131,7 @@ export class CommandDispatcher { dispatchInsertUnorderedList() { const selection = $getSelection() - if (!selection) return + if (!$isRangeSelection(selection)) return const anchorNode = selection.anchor.getNode() @@ -144,7 +144,7 @@ export class CommandDispatcher { dispatchInsertOrderedList() { const selection = $getSelection() - if (!selection) return + if (!$isRangeSelection(selection)) return const anchorNode = selection.anchor.getNode() diff --git a/test/browser/tests/formatting/list_format_with_attachment_selected.test.js b/test/browser/tests/formatting/list_format_with_attachment_selected.test.js new file mode 100644 index 000000000..c889b2850 --- /dev/null +++ b/test/browser/tests/formatting/list_format_with_attachment_selected.test.js @@ -0,0 +1,61 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +test.describe("List formatting with attachment selected", () => { + const ATTACHMENT_HTML = + '' + + test.beforeEach(async ({ page }) => { + await page.goto("/attachments-enabled.html") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("bullet list does not crash when an attachment is selected", async ({ page, editor }) => { + await editor.setValue(`

Hello

${ATTACHMENT_HTML}`) + + // Click the figure to select the attachment (creating a NodeSelection) + await editor.content.locator("figure.attachment").click() + await editor.flush() + await expect(editor.content.locator("figure.node--selected")).toHaveCount(1) + + // Listen for errors -- the bug causes "Cannot read properties of undefined (reading 'getNode')" + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + + // Click the bullet list button -- should not crash + await page.getByRole("button", { name: "Bullet list" }).click() + await page.waitForTimeout(500) + + // No JS errors should have been thrown + expect(errors).toHaveLength(0) + + // The paragraph and attachment should still be present (command was a no-op) + await expect(editor.content).toContainText("Hello") + await expect(editor.content.locator("figure.attachment")).toHaveCount(1) + }) + + test("numbered list does not crash when an attachment is selected", async ({ page, editor }) => { + await editor.setValue(`

Hello

${ATTACHMENT_HTML}`) + + // Click the figure to select the attachment (creating a NodeSelection) + await editor.content.locator("figure.attachment").click() + await editor.flush() + await expect(editor.content.locator("figure.node--selected")).toHaveCount(1) + + // Listen for errors + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + + // Click the numbered list button -- should not crash + await page.getByRole("button", { name: "Numbered list" }).click() + await page.waitForTimeout(500) + + // No JS errors should have been thrown + expect(errors).toHaveLength(0) + + // The paragraph and attachment should still be present (command was a no-op) + await expect(editor.content).toContainText("Hello") + await expect(editor.content.locator("figure.attachment")).toHaveCount(1) + }) +}) From 30009da59fcfd330aa1b0aa49c23dc7695397924 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 12:46:40 +0200 Subject: [PATCH 083/199] Fix text drag crash in attachment drag handler (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Prevent native text drag-and-drop to fix code formatting crash Native text drag-and-drop within contenteditable fires insertFromDrop then deleteByDrag, which can leave Lexical's selection referencing nodes removed during the operation. This causes "Point.getNode: node not found" crashes on subsequent actions like Enter or code formatting. Prevent text D&D at DRAGSTART_COMMAND (NORMAL priority) so the browser never initiates the drag. Attachment D&D is unaffected — it's handled at HIGH priority by AttachmentDragAndDrop, which returns true and stops the command chain before this handler runs. * Guard against text node targets in attachment drag start handler When dragging text (not an attachment), event.target can be a text node which has no .closest() method. Return false early to let the command chain continue to the normal-priority handler that prevents native text drag-and-drop. * Revert drag_and_drop.js changes — not needed * Fix text drag crash without blocking text D&D Use optional chaining on event.target.closest() in the attachment drag handler to gracefully handle text node targets (which lack the .closest method). Remove the DRAGSTART_COMMAND handler that was blocking all native text drag-and-drop. * Fix text drag crash with optional chaining in attachment drag handler When dragging selected text, event.target can lack .closest() (e.g. text nodes). Use optional chaining in the attachment drag handler to gracefully skip non-element targets instead of crashing. Remove the DRAGSTART_COMMAND handler that blocked all native text D&D — the actual fix is in the attachment handler, not in preventing drag. --- src/editor/attachments/drag_and_drop.js | 4 +- .../drag_and_drop_formatting.test.js | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/browser/tests/formatting/drag_and_drop_formatting.test.js diff --git a/src/editor/attachments/drag_and_drop.js b/src/editor/attachments/drag_and_drop.js index e715c9c2e..275585a28 100644 --- a/src/editor/attachments/drag_and_drop.js +++ b/src/editor/attachments/drag_and_drop.js @@ -54,9 +54,9 @@ export class AttachmentDragAndDrop { // -- Event handlers -------------------------------------------------------- #handleDragStart(event) { - if (event.target.closest("textarea")) return false + if (event.target.closest?.("textarea")) return false - const figure = event.target.closest("figure.attachment[data-lexical-node-key]") + const figure = event.target.closest?.("figure.attachment[data-lexical-node-key]") if (!figure) return false this.#draggedNodeKey = figure.dataset.lexicalNodeKey diff --git a/test/browser/tests/formatting/drag_and_drop_formatting.test.js b/test/browser/tests/formatting/drag_and_drop_formatting.test.js new file mode 100644 index 000000000..01a8f3a50 --- /dev/null +++ b/test/browser/tests/formatting/drag_and_drop_formatting.test.js @@ -0,0 +1,51 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +test.describe("Text drag-and-drop in editor", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + // Dragging selected text then applying code formatting should not + // crash the editor. A previous bug caused "closest is not a function" + // in the attachment drag handler when event.target lacked .closest(). + test("text drag-and-drop followed by code formatting works", async ({ page, editor }) => { + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + + await editor.setValue("

Hello world this is some text

") + await editor.select("world") + await editor.flush() + + // Perform a drag gesture using Playwright mouse API + const wordBound = await page.evaluate(() => { + const sel = window.getSelection() + if (!sel.rangeCount) return null + const range = sel.getRangeAt(0) + const rect = range.getBoundingClientRect() + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } + }) + + if (wordBound) { + await page.mouse.move(wordBound.x, wordBound.y) + await page.mouse.down() + await page.mouse.move(wordBound.x + 30, wordBound.y, { steps: 5 }) + await page.mouse.up() + } + + await editor.flush() + + // Apply code formatting — should not crash + await page.getByRole("button", { name: "Code" }).click() + await editor.flush() + + const fatalErrors = errors.filter( + (e) => + e.includes("closest is not a function") || + e.includes("node not found") + ) + expect(fatalErrors).toHaveLength(0) + }) +}) From d8be7be7571e8a6b532edd6366b43a56f385fa15 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 12:47:55 +0200 Subject: [PATCH 084/199] Fix input lag in prompt/mention system (#968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Debounce prompt filtering and cap rendered suggestions The prompt/mention system was running the full filter-and-render pipeline on every keystroke without debounce, and rendering all matching items to the DOM regardless of count. For large accounts with hundreds of people, this caused noticeable input lag — especially on the first characters. Two changes, mirroring the BC3 fix for sluggish ping autocomplete: 1. Debounce the filter callback at 50ms so rapid keystrokes coalesce into a single filter cycle instead of one per character. 2. Cap rendered suggestions at 50 items in both LocalFilterSource and RemoteFilterSource, avoiding hundreds of DOM elements for broad queries. * Increase max rendered suggestions from 50 to 100 * Update performance test cap from 50 to 100 --- src/editor/prompt/local_filter_source.js | 9 +- src/editor/prompt/remote_filter_source.js | 3 + src/elements/prompt.js | 6 +- src/helpers/timing_helpers.js | 9 + test/browser/fixtures/mentions-large.html | 2423 +++++++++++++++++ .../tests/prompts/prompt_performance.test.js | 45 + 6 files changed, 2491 insertions(+), 4 deletions(-) create mode 100644 test/browser/fixtures/mentions-large.html create mode 100644 test/browser/tests/prompts/prompt_performance.test.js diff --git a/src/editor/prompt/local_filter_source.js b/src/editor/prompt/local_filter_source.js index 084dc5f24..b4b689ec0 100644 --- a/src/editor/prompt/local_filter_source.js +++ b/src/editor/prompt/local_filter_source.js @@ -1,6 +1,8 @@ import BaseSource from "./base_source" import { filterMatches } from "../../helpers/string_helper" +const MAX_RENDERED_SUGGESTIONS = 100 + export default class LocalFilterSource extends BaseSource { async buildListItems(filter = "") { const promptItems = await this.fetchPromptItems() @@ -19,7 +21,10 @@ export default class LocalFilterSource extends BaseSource { #buildListItemsFromPromptItems(promptItems, filter) { const listItems = [] this.promptItemByListItem = new WeakMap() - promptItems.forEach((promptItem) => { + + for (const promptItem of promptItems) { + if (listItems.length >= MAX_RENDERED_SUGGESTIONS) break + const searchableText = promptItem.getAttribute("search") if (!filter || filterMatches(searchableText, filter)) { @@ -27,7 +32,7 @@ export default class LocalFilterSource extends BaseSource { this.promptItemByListItem.set(listItem, promptItem) listItems.push(listItem) } - }) + } return listItems } diff --git a/src/editor/prompt/remote_filter_source.js b/src/editor/prompt/remote_filter_source.js index e669239cd..cb81664fb 100644 --- a/src/editor/prompt/remote_filter_source.js +++ b/src/editor/prompt/remote_filter_source.js @@ -2,6 +2,7 @@ import BaseSource from "./base_source" import { debounceAsync } from "../../helpers/timing_helpers" const DEBOUNCE_INTERVAL = 200 +const MAX_RENDERED_SUGGESTIONS = 100 export default class RemoteFilterSource extends BaseSource { constructor(url) { @@ -35,6 +36,8 @@ export default class RemoteFilterSource extends BaseSource { this.promptItemByListItem = new WeakMap() for (const promptItem of promptItems) { + if (listItems.length >= MAX_RENDERED_SUGGESTIONS) break + const listItem = this.buildListItemElementFor(promptItem) this.promptItemByListItem.set(listItem, promptItem) listItems.push(listItem) diff --git a/src/elements/prompt.js b/src/elements/prompt.js index 4a570a3c7..e981014ed 100644 --- a/src/elements/prompt.js +++ b/src/elements/prompt.js @@ -7,14 +7,16 @@ import InlinePromptSource from "../editor/prompt/inline_source" import DeferredPromptSource from "../editor/prompt/deferred_source" import RemoteFilterSource from "../editor/prompt/remote_filter_source" import { $generateNodesFromDOM } from "@lexical/html" -import { nextFrame } from "../helpers/timing_helpers" +import { debounce, nextFrame } from "../helpers/timing_helpers" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found" +const FILTER_DEBOUNCE_INTERVAL = 50 export class LexicalPromptElement extends HTMLElement { #globalListeners = new ListenerBin() #popoverListeners = new ListenerBin() + #debouncedFilterOptions = debounce(() => this.#filterOptions(), FILTER_DEBOUNCE_INTERVAL) constructor() { super() @@ -172,7 +174,7 @@ export class LexicalPromptElement extends HTMLElement { this.#popoverListeners.track( registerEventListener(this.#editorElement, "keydown", this.#handleKeydownOnPopover), - registerEventListener(this.#editorElement, "lexxy:change", this.#filterOptions) + registerEventListener(this.#editorElement, "lexxy:change", this.#debouncedFilterOptions) ) this.#registerKeyListeners() diff --git a/src/helpers/timing_helpers.js b/src/helpers/timing_helpers.js index da0e34b6b..c5fdfe606 100644 --- a/src/helpers/timing_helpers.js +++ b/src/helpers/timing_helpers.js @@ -1,3 +1,12 @@ +export function debounce(fn, wait) { + let timeout + + return (...args) => { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), wait) + } +} + export function debounceAsync(fn, wait) { let timeout diff --git a/test/browser/fixtures/mentions-large.html b/test/browser/fixtures/mentions-large.html new file mode 100644 index 000000000..557b84e2d --- /dev/null +++ b/test/browser/fixtures/mentions-large.html @@ -0,0 +1,2423 @@ + + + + + + Lexxy Test — Mentions (Large List) + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + diff --git a/test/browser/tests/prompts/prompt_performance.test.js b/test/browser/tests/prompts/prompt_performance.test.js new file mode 100644 index 000000000..06f016585 --- /dev/null +++ b/test/browser/tests/prompts/prompt_performance.test.js @@ -0,0 +1,45 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +test.describe("Prompt performance with large lists", () => { + test.describe.configure({ mode: "serial" }) + + test.beforeEach(async ({ page }) => { + await page.goto("/mentions-large.html") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("caps the number of rendered suggestions", async ({ page, editor }) => { + await editor.send("@") + + const popover = page.locator(".lexxy-prompt-menu--visible") + await expect(popover).toBeVisible({ timeout: 5_000 }) + + // With 200 matching items, the popover should cap rendered items at 100 + const itemCount = await popover.locator(".lexxy-prompt-menu__item").count() + expect(itemCount).toBeLessThanOrEqual(100) + expect(itemCount).toBeGreaterThan(0) + }) + + test("filtering narrows results within the cap", async ({ page, editor }) => { + await editor.send("@") + + const popover = page.locator(".lexxy-prompt-menu--visible") + await expect(popover).toBeVisible({ timeout: 5_000 }) + + // Type a numeric filter to narrow results without triggering space-select. + // "19" matches "Person 19", "Person 190"-"Person 199" = 11 items + await editor.content.pressSequentially("19") + await editor.flush() + + // Wait for debounce to settle and results to update + await page.waitForTimeout(200) + + const items = popover.locator(".lexxy-prompt-menu__item") + await expect(items.first()).toBeVisible({ timeout: 2_000 }) + + const itemCount = await items.count() + expect(itemCount).toBeGreaterThan(0) + expect(itemCount).toBeLessThan(100) + }) +}) From bf65fd79c7b4be919b9d920196cf6d79b23ee15a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 12:48:23 +0200 Subject: [PATCH 085/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index c44efbd82..cb2f94755 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.5.beta" + VERSION = "0.9.6.beta" end From b20082c986a03ee8e4f661e6f6a5749cfb98b161 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 12:54:29 +0200 Subject: [PATCH 086/199] Update beta language in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab808f219..bd183203b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A modern rich text editor for Rails. > [!IMPORTANT] -> This is an early beta. It hasn't been battle-tested yet. Please try it out and report any issues you find. +> This is a beta. It hasn't been battle-tested yet. Please try it out and report any issues you find. **[Try it out!](https://basecamp.github.io/lexxy/try-it)** @@ -26,7 +26,7 @@ Visit the **[documentation site](https://basecamp.github.io/lexxy)**. ## Roadmap -This is an early beta. Here's what's coming next: +This is a beta. Here's what's coming next: - [x] Configurable editors in Action Text: Choose your editor like you choose your database. - [x] More editing features: From a3b672977969d766844121d572dd6b3f126ab11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Fri, 10 Apr 2026 12:24:33 +0100 Subject: [PATCH 087/199] Fix over-sanitization of custom attachment contents (#966) * Configure sanitizer with allowed tags on initialization DOMPurify can take a config with will apply to future sanitization https://github.com/cure53/DOMPurify?tab=readme-ov-file#persistent-configuration * Use configured sanitizer for custom ActionText attachments --- src/config/dom_purify.js | 4 +++- src/elements/editor.js | 9 +++++++-- src/helpers/sanitization_helper.js | 14 +++++--------- src/nodes/custom_action_text_attachment_node.js | 4 ++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index b680f8943..78d2971cb 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -32,7 +32,9 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => { } }) -export function buildConfig(allowedElements) { +export { DOMPurify } + +export function buildConfig(allowedElements ) { const tagAttributes = {} for (const element of allowedElements) { diff --git a/src/elements/editor.js b/src/elements/editor.js index f125f56ee..e5414e8d0 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -18,7 +18,7 @@ import { CommandDispatcher } from "../editor/command_dispatcher" import Selection from "../editor/selection" import { createElement, dispatch, generateDomId, parseHtml } from "../helpers/html_helper" import { isAttachmentSpacerTextNode } from "../helpers/lexical_helper" -import { sanitize } from "../helpers/sanitization_helper" +import { sanitize, setSanitizerConfig } from "../helpers/sanitization_helper" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import LexicalToolbar from "./toolbar" import Configuration from "../editor/configuration" @@ -228,7 +228,7 @@ export class LexicalEditorElement extends HTMLElement { get value() { if (!this.cachedValue) { this.editor?.getEditorState().read(() => { - this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null), this.#allowedElements) + this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null)) }) } @@ -287,6 +287,7 @@ export class LexicalEditorElement extends HTMLElement { this.#registerFocusEvents() this.#attachDebugHooks() this.#attachToolbar() + this.#configureSanitizer() this.#loadInitialValue() this.#resetBeforeTurboCaches() } @@ -570,6 +571,10 @@ export class LexicalEditorElement extends HTMLElement { } } + #configureSanitizer() { + setSanitizerConfig(this.#allowedElements) + } + get #allowedElements() { return this.#importableTags.concat(this.extensions.allowedElements) } diff --git a/src/helpers/sanitization_helper.js b/src/helpers/sanitization_helper.js index 8942fd0aa..a745c680a 100644 --- a/src/helpers/sanitization_helper.js +++ b/src/helpers/sanitization_helper.js @@ -1,14 +1,10 @@ -import DOMPurify from "dompurify" -import { buildConfig } from "../config/dom_purify" +import { DOMPurify, buildConfig } from "../config/dom_purify" -export function sanitize(html, allowedElements) { - return DOMPurify.sanitize(html, buildConfig(allowedElements)) +export function setSanitizerConfig(allowedTags) { + DOMPurify.clearConfig() + DOMPurify.setConfig(buildConfig(allowedTags)) } -// Sanitize HTML for custom attachment content (mentions, cards, etc.). -// Uses DOMPurify defaults to strip XSS vectors (scripts, event handlers) -// while preserving the richer tag set that server-rendered attachment -// content legitimately uses (e.g. ,
, ). -export function sanitizeAttachmentContent(html) { +export function sanitize(html) { return DOMPurify.sanitize(html) } diff --git a/src/nodes/custom_action_text_attachment_node.js b/src/nodes/custom_action_text_attachment_node.js index 086d5dedc..ca386a87f 100644 --- a/src/nodes/custom_action_text_attachment_node.js +++ b/src/nodes/custom_action_text_attachment_node.js @@ -2,7 +2,7 @@ import Lexxy from "../config/lexxy" import { $createTextNode, DecoratorNode } from "lexical" import { createElement, extractPlainTextFromHtml } from "../helpers/html_helper" -import { sanitizeAttachmentContent } from "../helpers/sanitization_helper" +import { sanitize } from "../helpers/sanitization_helper" import { parseAttachmentContent } from "../helpers/storage_helper" export class CustomActionTextAttachmentNode extends DecoratorNode { @@ -75,7 +75,7 @@ export class CustomActionTextAttachmentNode extends DecoratorNode { createDOM() { const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true }) - figure.insertAdjacentHTML("beforeend", sanitizeAttachmentContent(this.innerHtml)) + figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml)) const deleteButton = createElement("lexxy-node-delete-button") figure.appendChild(deleteButton) From 39a00ed6dc181a82ce28828493d61232a1149395 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 13:25:15 +0200 Subject: [PATCH 088/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index cb2f94755..b682020ad 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.6.beta" + VERSION = "0.9.7.beta" end From 9d33b825e9821a70c483e7120b196222076e4878 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:35:57 +0200 Subject: [PATCH 089/199] Bump the github-actions group with 3 updates (#941) Bumps the github-actions group with 3 updates: [ruby/setup-ruby](https://github.com/ruby/setup-ruby), [actions/configure-pages](https://github.com/actions/configure-pages) and [actions/deploy-pages](https://github.com/actions/deploy-pages). Updates `ruby/setup-ruby` from 1.295.0 to 1.299.0 - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/319994f95fa847cf3fb3cd3dbe89f6dcde9f178f...3ff19f5e2baf30647122352b96108b1fbe250c64) Updates `actions/configure-pages` from 5.0.0 to 6.0.0 - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/983d7736d9b0ae728b81ab479565c72886d7745b...45bfe0192ca1faeb007ade9deae92b16b8254a0d) Updates `actions/deploy-pages` from 4.0.5 to 5.0.0 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e...cd2ce8fcbc39b97be8ca5fce6e763baed58fa128) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-version: 1.299.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/configure-pages dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/deploy-pages dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docs.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49106b7e1..bf558aaf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: persist-credentials: false - name: Set up Ruby - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: .ruby-version bundler-cache: true @@ -110,7 +110,7 @@ jobs: persist-credentials: false - name: Set up Ruby - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: .ruby-version bundler-cache: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2645b0803..e8fa2f4f4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Setup Ruby - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 + uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: '3.3' bundler-cache: true @@ -38,7 +38,7 @@ jobs: - name: Setup Pages id: pages - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Build with Jekyll run: bundle exec jekyll build --baseurl "${STEPS_PAGES_OUTPUTS_BASE_PATH}" @@ -63,4 +63,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 From 356f69b3b08dd4c9d268fc69d1415a9ae8c5a3ce Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 12:08:54 +0200 Subject: [PATCH 090/199] Fix link dialog URL value --- src/elements/dropdown/link.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/dropdown/link.js b/src/elements/dropdown/link.js index 68d11431e..b1b5271af 100644 --- a/src/elements/dropdown/link.js +++ b/src/elements/dropdown/link.js @@ -55,7 +55,7 @@ export class LinkDropdown extends ToolbarDropdown { get #selectedLinkUrl() { return this.editor.getEditorState().read(() => { const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode) - return linkNode?.getUrl() ?? null + return linkNode?.getURL() ?? null }) } } From 000615fde693c960951d1e9e142eb884dd0d41b4 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 12:12:59 +0200 Subject: [PATCH 091/199] Add test for regression --- .../tests/formatting/block_formatting.test.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index b6c01404a..14bad098e 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -325,4 +325,27 @@ test.describe("Block formatting", () => { const submitCount = await page.evaluate(() => window.__submitCount) expect(submitCount).toBe(0) }) + + test("link dialog shows existing URL when link is selected", async ({ + page, + editor, + }) => { + await editor.setValue( + '

Hello everyone

', + ) + await editor.select("everyone") + await editor.flush() + + await page.evaluate(() => { + const details = document.querySelector( + "details:has(summary[name='link'])", + ) + details.open = true + details.dispatchEvent(new Event("toggle")) + }) + + const input = page.locator("lexxy-link-dropdown input[type='url']").first() + await expect(input).toBeVisible({ timeout: 2_000 }) + await expect(input).toHaveValue("https://37signals.com") + }) }) From bbb540a3c715f7e8cbae33a72bca4f9e6ef83a4b Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 12:17:52 +0200 Subject: [PATCH 092/199] Empty string instead of null --- src/elements/dropdown/link.js | 2 +- .../tests/formatting/block_formatting.test.js | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/elements/dropdown/link.js b/src/elements/dropdown/link.js index b1b5271af..b354b7db6 100644 --- a/src/elements/dropdown/link.js +++ b/src/elements/dropdown/link.js @@ -55,7 +55,7 @@ export class LinkDropdown extends ToolbarDropdown { get #selectedLinkUrl() { return this.editor.getEditorState().read(() => { const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode) - return linkNode?.getURL() ?? null + return linkNode?.getURL() ?? "" }) } } diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index 14bad098e..a190f7404 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -348,4 +348,25 @@ test.describe("Block formatting", () => { await expect(input).toBeVisible({ timeout: 2_000 }) await expect(input).toHaveValue("https://37signals.com") }) + + test("link dialog shows empty input when no link is selected", async ({ + page, + editor, + }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await editor.flush() + + await page.evaluate(() => { + const details = document.querySelector( + "details:has(summary[name='link'])", + ) + details.open = true + details.dispatchEvent(new Event("toggle")) + }) + + const input = page.locator("lexxy-link-dropdown input[type='url']").first() + await expect(input).toBeVisible({ timeout: 2_000 }) + await expect(input).toHaveValue("") + }) }) From d129358b5e71bf8341d46980d405bdebc410197d Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 12:43:17 +0200 Subject: [PATCH 093/199] Update filtering method Changes: - order by first name - don't match results within words --- src/editor/prompt/local_filter_source.js | 38 ++++++++++++++++++------ src/helpers/string_helper.js | 21 ++++++++++++- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/editor/prompt/local_filter_source.js b/src/editor/prompt/local_filter_source.js index b4b689ec0..5b2f4d3e9 100644 --- a/src/editor/prompt/local_filter_source.js +++ b/src/editor/prompt/local_filter_source.js @@ -1,5 +1,5 @@ import BaseSource from "./base_source" -import { filterMatches } from "../../helpers/string_helper" +import { filterMatchPosition } from "../../helpers/string_helper" const MAX_RENDERED_SUGGESTIONS = 100 @@ -19,21 +19,41 @@ export default class LocalFilterSource extends BaseSource { } #buildListItemsFromPromptItems(promptItems, filter) { - const listItems = [] this.promptItemByListItem = new WeakMap() - for (const promptItem of promptItems) { - if (listItems.length >= MAX_RENDERED_SUGGESTIONS) break + if (!filter) { + return this.#buildAllListItems(promptItems) + } + const matches = [] + for (const promptItem of promptItems) { const searchableText = promptItem.getAttribute("search") - - if (!filter || filterMatches(searchableText, filter)) { - const listItem = this.buildListItemElementFor(promptItem) - this.promptItemByListItem.set(listItem, promptItem) - listItems.push(listItem) + const position = filterMatchPosition(searchableText, filter) + if (position >= 0) { + matches.push({ promptItem, position }) } } + matches.sort((a, b) => a.position - b.position) + + const listItems = [] + for (const { promptItem } of matches) { + if (listItems.length >= MAX_RENDERED_SUGGESTIONS) break + const listItem = this.buildListItemElementFor(promptItem) + this.promptItemByListItem.set(listItem, promptItem) + listItems.push(listItem) + } + return listItems + } + + #buildAllListItems(promptItems) { + const listItems = [] + for (const promptItem of promptItems) { + if (listItems.length >= MAX_RENDERED_SUGGESTIONS) break + const listItem = this.buildListItemElementFor(promptItem) + this.promptItemByListItem.set(listItem, promptItem) + listItems.push(listItem) + } return listItems } } diff --git a/src/helpers/string_helper.js b/src/helpers/string_helper.js index e1426574d..4030eb036 100644 --- a/src/helpers/string_helper.js +++ b/src/helpers/string_helper.js @@ -22,7 +22,26 @@ export function normalizeFilteredText(string) { } export function filterMatches(text, potentialMatch) { - return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch)) + return filterMatchPosition(text, potentialMatch) >= 0 +} + +export function filterMatchPosition(text, potentialMatch) { + const normalizedText = normalizeFilteredText(text) + const normalizedMatch = normalizeFilteredText(potentialMatch) + + if (!normalizedMatch) return 0 + + let i = 0 + while (i < normalizedText.length) { + if (normalizedText.startsWith(normalizedMatch, i)) { + return i + } + const nextSpace = normalizedText.indexOf(" ", i) + if (nextSpace === -1) break + i = nextSpace + 1 + } + + return -1 } export function upcaseFirst(string) { From 459ace2e08c1674e98d50c49fbf083e6b6a513ce Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 12:55:52 +0200 Subject: [PATCH 094/199] Tests for expected prompt filtering logic --- test/browser/fixtures/mentions-filtering.html | 45 +++++++++++++++++++ .../tests/prompts/mention_filtering.test.js | 43 ++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 test/browser/fixtures/mentions-filtering.html create mode 100644 test/browser/tests/prompts/mention_filtering.test.js diff --git a/test/browser/fixtures/mentions-filtering.html b/test/browser/fixtures/mentions-filtering.html new file mode 100644 index 000000000..e6d61271d --- /dev/null +++ b/test/browser/fixtures/mentions-filtering.html @@ -0,0 +1,45 @@ + + + + + + Lexxy Test — Mention Filtering + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff --git a/test/browser/tests/prompts/mention_filtering.test.js b/test/browser/tests/prompts/mention_filtering.test.js new file mode 100644 index 000000000..5ad3d99fa --- /dev/null +++ b/test/browser/tests/prompts/mention_filtering.test.js @@ -0,0 +1,43 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +test.describe("Mention filtering", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/mentions-filtering.html") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("matches only at word boundaries and orders by first match position", async ({ page, editor }) => { + await editor.send("@") + + const popover = page.locator(".lexxy-prompt-menu--visible") + await expect(popover).toBeVisible({ timeout: 5_000 }) + + await editor.content.pressSequentially("ja") + await page.waitForTimeout(200) + + const items = popover.locator(".lexxy-prompt-menu__item") + await expect(items).toHaveCount(4) + + const names = await items.allTextContents() + expect(names).toEqual([ + "Jack Franklin", + "Jason Clack", + "Clara Jackson", + "Thomas Jaiden", + ]) + }) + + test("does not match filter in the middle of a word", async ({ page, editor }) => { + await editor.send("@") + + const popover = page.locator(".lexxy-prompt-menu--visible") + await expect(popover).toBeVisible({ timeout: 5_000 }) + + await editor.content.pressSequentially("mid") + await page.waitForTimeout(200) + + const items = popover.locator(".lexxy-prompt-menu__item") + await expect(items).toHaveCount(0) + }) +}) From a0dfd9607c5ff9e9fd4e0baafe3898c203a7d1df Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 14:06:26 +0200 Subject: [PATCH 095/199] Simplify with regex --- src/helpers/string_helper.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/helpers/string_helper.js b/src/helpers/string_helper.js index 4030eb036..dd0740a8c 100644 --- a/src/helpers/string_helper.js +++ b/src/helpers/string_helper.js @@ -21,27 +21,14 @@ export function normalizeFilteredText(string) { .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics } -export function filterMatches(text, potentialMatch) { - return filterMatchPosition(text, potentialMatch) >= 0 -} - export function filterMatchPosition(text, potentialMatch) { const normalizedText = normalizeFilteredText(text) const normalizedMatch = normalizeFilteredText(potentialMatch) if (!normalizedMatch) return 0 - let i = 0 - while (i < normalizedText.length) { - if (normalizedText.startsWith(normalizedMatch, i)) { - return i - } - const nextSpace = normalizedText.indexOf(" ", i) - if (nextSpace === -1) break - i = nextSpace + 1 - } - - return -1 + const match = normalizedText.match(new RegExp(`(?:^|\\b)${RegExp.escape(normalizedMatch)}`)) + return match ? match.index : -1 } export function upcaseFirst(string) { From 8499efac4fafb05e14e32e3a3d1fc4a55a851450 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Mon, 13 Apr 2026 14:12:56 +0200 Subject: [PATCH 096/199] PR feedback --- src/helpers/string_helper.js | 6 +++++- test/browser/tests/prompts/mention_filtering.test.js | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/helpers/string_helper.js b/src/helpers/string_helper.js index dd0740a8c..f1712b824 100644 --- a/src/helpers/string_helper.js +++ b/src/helpers/string_helper.js @@ -27,7 +27,7 @@ export function filterMatchPosition(text, potentialMatch) { if (!normalizedMatch) return 0 - const match = normalizedText.match(new RegExp(`(?:^|\\b)${RegExp.escape(normalizedMatch)}`)) + const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`)) return match ? match.index : -1 } @@ -35,6 +35,10 @@ export function upcaseFirst(string) { return string.charAt(0).toUpperCase() + string.slice(1) } +function escapeForRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + // Parses a value that may arrive as a boolean or as a string (e.g. from DOM // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy. export function parseBoolean(value) { diff --git a/test/browser/tests/prompts/mention_filtering.test.js b/test/browser/tests/prompts/mention_filtering.test.js index 5ad3d99fa..541556156 100644 --- a/test/browser/tests/prompts/mention_filtering.test.js +++ b/test/browser/tests/prompts/mention_filtering.test.js @@ -14,7 +14,7 @@ test.describe("Mention filtering", () => { await expect(popover).toBeVisible({ timeout: 5_000 }) await editor.content.pressSequentially("ja") - await page.waitForTimeout(200) + await editor.flush() const items = popover.locator(".lexxy-prompt-menu__item") await expect(items).toHaveCount(4) @@ -35,7 +35,7 @@ test.describe("Mention filtering", () => { await expect(popover).toBeVisible({ timeout: 5_000 }) await editor.content.pressSequentially("mid") - await page.waitForTimeout(200) + await editor.flush() const items = popover.locator(".lexxy-prompt-menu__item") await expect(items).toHaveCount(0) From 5d9063c19fffec2180e342b440e33dac3749684d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 10 Apr 2026 13:28:53 +0200 Subject: [PATCH 097/199] v0.9.7-beta --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad3ca3d4d..86dc1f595 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@37signals/lexxy", - "version": "0.9.5-beta", + "version": "0.9.7-beta", "description": "Lexxy - A modern rich text editor for Rails.", "module": "dist/lexxy.esm.js", "type": "module", From af2805b6579fac96ac6122d790843868f2a5c944 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 13 Apr 2026 18:30:01 +0200 Subject: [PATCH 098/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index b682020ad..edec829af 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.7.beta" + VERSION = "0.9.8.beta" end From 230ad065191301251348b36d18287a070088e74d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 13 Apr 2026 18:32:42 +0200 Subject: [PATCH 099/199] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 86dc1f595..d45a7c248 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@37signals/lexxy", - "version": "0.9.7-beta", + "version": "0.9.8-beta", "description": "Lexxy - A modern rich text editor for Rails.", "module": "dist/lexxy.esm.js", "type": "module", From 20c95582727191370c1ab8aac3a73d053d1ce8b0 Mon Sep 17 00:00:00 2001 From: Zacharias Dyna Knudsen Date: Tue, 14 Apr 2026 18:57:26 +0200 Subject: [PATCH 100/199] Open links in editor using ctrl/cmd+click Links become non-editable with target="_blank" while the platform modifier key is held (Cmd on macOS, Ctrl elsewhere), allowing the browser to handle link navigation natively. --- src/elements/editor.js | 4 +- src/extensions/link_opener_extension.js | 63 +++++++++++++++++++ test/browser/tests/editor/link_opener.test.js | 60 ++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/extensions/link_opener_extension.js create mode 100644 test/browser/tests/editor/link_opener.test.js diff --git a/src/elements/editor.js b/src/elements/editor.js index e5414e8d0..65aa83f3c 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -36,6 +36,7 @@ import { TrixContentExtension } from "../extensions/trix_content_extension" import { TablesExtension } from "../extensions/tables_extension" import { AttachmentsExtension } from "../extensions/attachments_extension.js" import { FormatEscapeExtension } from "../extensions/format_escape_extension.js" +import { LinkOpenerExtension } from "../extensions/link_opener_extension.js" export class LexicalEditorElement extends HTMLElement { @@ -142,7 +143,8 @@ export class LexicalEditorElement extends HTMLElement { TrixContentExtension, TablesExtension, AttachmentsExtension, - FormatEscapeExtension + FormatEscapeExtension, + LinkOpenerExtension ] } diff --git a/src/extensions/link_opener_extension.js b/src/extensions/link_opener_extension.js new file mode 100644 index 000000000..1070f1a4f --- /dev/null +++ b/src/extensions/link_opener_extension.js @@ -0,0 +1,63 @@ +import { defineExtension } from "lexical" +import { IS_APPLE, mergeRegister } from "@lexical/utils" +import { registerEventListener } from "../helpers/listener_helper.js" +import LexxyExtension from "./lexxy_extension.js" + +export class LinkOpenerExtension extends LexxyExtension { + get enabled() { + return this.editorElement.supportsRichText + } + + get lexicalExtension() { + return defineExtension({ + name: "lexxy/link-opener", + register: () => { + return mergeRegister( + registerEventListener(window, "keydown", this.#update.bind(this)), + registerEventListener(window, "keyup", this.#update.bind(this)), + registerEventListener(window, "blur", this.#disable.bind(this)), + registerEventListener(window, "focus", this.#refresh.bind(this)) + ) + } + }) + } + + #update(event) { + if (this.#isModified(event)) { + this.#enable() + } else { + this.#disable() + } + } + + #refresh() { + // Chrome dispatches events without modifier keys *for a while* after changing tabs + setTimeout(() => { + window.addEventListener("mousemove", this.#update.bind(this), { once: true }) + }, 200) + } + + #isModified(event) { + return IS_APPLE ? event.metaKey : event.ctrlKey + } + + #enable() { + for (const anchor of this.#anchors) { + anchor.setAttribute("contenteditable", "false") + anchor.setAttribute("target", "_blank") + anchor.setAttribute("rel", "noopener noreferrer") + } + } + + #disable() { + for (const anchor of this.#anchors) { + anchor.removeAttribute("contenteditable") + anchor.removeAttribute("target") + anchor.removeAttribute("rel") + } + } + + get #anchors() { + return this.editorElement.editorContentElement?.querySelectorAll("a") ?? [] + } +} diff --git a/test/browser/tests/editor/link_opener.test.js b/test/browser/tests/editor/link_opener.test.js new file mode 100644 index 000000000..f5c408e6e --- /dev/null +++ b/test/browser/tests/editor/link_opener.test.js @@ -0,0 +1,60 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +const modifier = process.platform === "darwin" ? "Meta" : "Control" + +test.describe("Link opener", () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/") + await editor.waitForConnected() + await editor.setValue('

Visit example today

') + await editor.flush() + }) + + test("holding modifier makes links non-editable", async ({ page, editor }) => { + const anchor = editor.content.locator("a") + + await expect(anchor).not.toHaveAttribute("contenteditable") + await page.keyboard.down(modifier) + await expect(anchor).toHaveAttribute("contenteditable", "false") + await page.keyboard.up(modifier) + await expect(anchor).not.toHaveAttribute("contenteditable") + }) + + test("holding modifier sets target and rel on links", async ({ page, editor }) => { + const anchor = editor.content.locator("a") + + await page.keyboard.down(modifier) + await expect(anchor).toHaveAttribute("target", "_blank") + await expect(anchor).toHaveAttribute("rel", "noopener noreferrer") + await page.keyboard.up(modifier) + await expect(anchor).not.toHaveAttribute("target") + await expect(anchor).not.toHaveAttribute("rel") + }) + + test("applies to all links in the editor", async ({ editor, page }) => { + await editor.setValue( + '

first and second

', + ) + await editor.flush() + + const anchors = editor.content.locator("a") + + await page.keyboard.down(modifier) + await expect(anchors.nth(0)).toHaveAttribute("contenteditable", "false") + await expect(anchors.nth(1)).toHaveAttribute("contenteditable", "false") + await page.keyboard.up(modifier) + await expect(anchors.nth(0)).not.toHaveAttribute("contenteditable") + await expect(anchors.nth(1)).not.toHaveAttribute("contenteditable") + }) + + test("clears link attributes on window blur", async ({ page, editor }) => { + const anchor = editor.content.locator("a") + + await page.keyboard.down(modifier) + await expect(anchor).toHaveAttribute("contenteditable", "false") + + await page.evaluate(() => window.dispatchEvent(new Event("blur"))) + await expect(anchor).not.toHaveAttribute("contenteditable") + }) +}) From 0b379e7bf9ee2a7f9c612a7ac71e5325cb2e373c Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 16:24:19 +0200 Subject: [PATCH 101/199] Fix toolbar focus If the first item in the toolbar was hidden, the toolbar was not keyboard accessible with Shift+Tab, as the tabindex="0" was set on the hidden item. --- src/elements/toolbar.js | 3 ++- src/helpers/accessibility_helper.js | 6 ++---- src/helpers/html_helper.js | 4 ++++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index d532eac8f..bc1a95802 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -7,6 +7,7 @@ import { getNonce } from "../helpers/csp_helper" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import { handleRollingTabIndex } from "../helpers/accessibility_helper" import ToolbarIcons from "./toolbar_icons" +import { isActiveAndVisible } from "../helpers/html_helper" export class LexicalToolbarElement extends HTMLElement { static observedAttributes = [ "connected" ] @@ -328,7 +329,7 @@ export class LexicalToolbarElement extends HTMLElement { } get #focusableItems() { - return Array.from(this.querySelectorAll(":scope button, :scope > details > summary")) + return Array.from(this.querySelectorAll(":scope button, :scope > details > summary")).filter(isActiveAndVisible) } get #toolbarItems() { diff --git a/src/helpers/accessibility_helper.js b/src/helpers/accessibility_helper.js index 8d537ebb7..1d5a9f29b 100644 --- a/src/helpers/accessibility_helper.js +++ b/src/helpers/accessibility_helper.js @@ -1,3 +1,5 @@ +import { isActiveAndVisible } from "./html_helper" + export function handleRollingTabIndex(elements, event) { const previousActiveElement = document.activeElement @@ -80,7 +82,3 @@ class NextElementFinder { elements.forEach(element => element.tabIndex = -1) } } - -function isActiveAndVisible(element) { - return element && !element.disabled && element.checkVisibility() -} diff --git a/src/helpers/html_helper.js b/src/helpers/html_helper.js index f9436106a..df48feadf 100644 --- a/src/helpers/html_helper.js +++ b/src/helpers/html_helper.js @@ -59,3 +59,7 @@ export function generateDomId(prefix) { export function extractPlainTextFromHtml(innerHtml = "") { return parseHtml(innerHtml).body.textContent.trim() } + +export function isActiveAndVisible(element) { + return element && !element.disabled && element.checkVisibility() +} From 636cd77462884792ad5e3561e46358a6b0b2a6d5 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 16:44:41 +0200 Subject: [PATCH 102/199] Update command_dispatcher.js --- src/editor/command_dispatcher.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index cdfbf118e..d0f6370b9 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -16,7 +16,8 @@ import { UNDO_COMMAND } from "lexical" import { CodeNode } from "@lexical/code" -import { $createAutoLinkNode, $toggleLink } from "@lexical/link" +import { $createAutoLinkNode, $toggleLink, LinkNode } from "@lexical/link" +import { $getNearestNodeOfType } from "@lexical/utils" import { INSERT_TABLE_COMMAND } from "@lexical/table" import { createElement } from "../helpers/html_helper" @@ -107,7 +108,9 @@ export class CommandDispatcher { const selection = $getSelection() if (!$isRangeSelection(selection)) return - if (selection.isCollapsed()) { + const anchorNode = selection.anchor.getNode() + + if (selection.isCollapsed() && !$getNearestNodeOfType(anchorNode, LinkNode)) { const autoLinkNode = $createAutoLinkNode(url) const textNode = $createTextNode(url) autoLinkNode.append(textNode) From 7f722a1b927d9470afa729cfbde2048d5834879a Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 16:50:04 +0200 Subject: [PATCH 103/199] Update toolbar.js --- src/elements/toolbar.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index bc1a95802..9deadda60 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -158,7 +158,8 @@ export class LexicalToolbarElement extends HTMLElement { } #handleEditorFocus = () => { - this.#focusableItems[0].tabIndex = 0 + const firstVisible = this.#focusableItems.find(isActiveAndVisible) + if (firstVisible) firstVisible.tabIndex = 0 } #handleEditorBlur = () => { @@ -329,7 +330,7 @@ export class LexicalToolbarElement extends HTMLElement { } get #focusableItems() { - return Array.from(this.querySelectorAll(":scope button, :scope > details > summary")).filter(isActiveAndVisible) + return Array.from(this.querySelectorAll(":scope button, :scope > details > summary")) } get #toolbarItems() { From 77e0dea5590c4bccbf5f5c0314496a0ff15faacb Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 22:57:58 +0200 Subject: [PATCH 104/199] Allow value Need to allow `value` to make nested `ol` count correctly for exported editor value() --- src/config/dom_purify.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index 78d2971cb..a337edff4 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -1,7 +1,7 @@ import DOMPurify from "dompurify" import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" -const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ] +const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title", "value" ] const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ] From 5b6c52bf8b78304784f32c2fa9e4449a199a3177 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 23:07:22 +0200 Subject: [PATCH 105/199] Update engine.rb --- lib/lexxy/engine.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/engine.rb b/lib/lexxy/engine.rb index d5cdd8801..d01913a2e 100644 --- a/lib/lexxy/engine.rb +++ b/lib/lexxy/engine.rb @@ -55,7 +55,7 @@ class Engine < ::Rails::Engine ActionText::ContentHelper.allowed_tags = default_allowed_tags + %w[ video audio source embed table tbody tr th td ] default_allowed_attributes = Class.new.include(ActionText::ContentHelper).new.sanitizer_allowed_attributes - ActionText::ContentHelper.allowed_attributes = default_allowed_attributes + %w[ controls poster data-language style ] + ActionText::ContentHelper.allowed_attributes = default_allowed_attributes + %w[ controls poster data-language style value ] Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS << "var" end From 0591414e91892460ab244ac7c6fa629225657cfd Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 23:18:16 +0200 Subject: [PATCH 106/199] Move attr registration to proper place --- src/config/dom_purify.js | 2 +- src/extensions/format_escape_extension.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index a337edff4..78d2971cb 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -1,7 +1,7 @@ import DOMPurify from "dompurify" import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" -const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title", "value" ] +const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ] const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ] diff --git a/src/extensions/format_escape_extension.js b/src/extensions/format_escape_extension.js index 03e5de3d3..4117ea10d 100644 --- a/src/extensions/format_escape_extension.js +++ b/src/extensions/format_escape_extension.js @@ -14,6 +14,10 @@ export class FormatEscapeExtension extends LexxyExtension { return this.editorElement.supportsRichText } + get allowedElements() { + return [ { tag: "li", attributes: [ "value" ] } ] + } + get lexicalExtension() { return defineExtension({ name: "lexxy/format-escape", From bf02aa696214679af30cba6fe44e0500635e2265 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 23:29:35 +0200 Subject: [PATCH 107/199] Update block_formatting.test.js --- test/browser/tests/formatting/block_formatting.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index a190f7404..9d5dadaf9 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -95,6 +95,11 @@ test.describe("Block formatting", () => { await assertEditorHtml(editor, "

Alpha

Bravo

Charlie

") }) + test("ordered list exports li value attribute", async ({ editor }) => { + await editor.setValue("
  1. First
  2. Second
") + await assertEditorHtml(editor, '
  1. First
  2. Second
') + }) + test("insert quote without selection", async ({ page, editor }) => { await editor.setValue(HELLO_EVERYONE) await page.getByRole("button", { name: "Quote" }).click() From 447b7a213d805c8808fcf6ba63e014f79acbce46 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 16 Apr 2026 23:54:21 +0200 Subject: [PATCH 108/199] Add value to all list tests --- .../tests/formatting/block_formatting.test.js | 18 +++++++++--------- .../tests/formatting/escape_format.test.js | 14 +++++++------- .../formatting/inline_code_escape.test.js | 2 +- .../tests/formatting/list_indentation.test.js | 12 ++++++------ .../formatting/list_item_deletion.test.js | 16 ++++++++-------- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index 9d5dadaf9..f7935cbd2 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -34,14 +34,14 @@ test.describe("Block formatting", () => { await editor.setValue(HELLO_EVERYONE) await editor.select("everyone") await page.getByRole("button", { name: "Bullet list" }).click() - await assertEditorHtml(editor, "
  • Hello everyone
") + await assertEditorHtml(editor, '
  • Hello everyone
') }) test("toggle bullet list off", async ({ page, editor }) => { await editor.setValue(HELLO_EVERYONE) await editor.select("everyone") await page.getByRole("button", { name: "Bullet list" }).click() - await assertEditorHtml(editor, "
  • Hello everyone
") + await assertEditorHtml(editor, '
  • Hello everyone
') await editor.select("everyone") await page.getByRole("button", { name: "Bullet list" }).click() @@ -52,7 +52,7 @@ test.describe("Block formatting", () => { await editor.setValue("

Alpha

Bravo

Charlie

") await editor.selectAll() await page.getByRole("button", { name: "Bullet list" }).click() - await assertEditorHtml(editor, "
  • Alpha
  • Bravo
  • Charlie
") + await assertEditorHtml(editor, '
  • Alpha
  • Bravo
  • Charlie
') await editor.selectAll() await page.getByRole("button", { name: "Bullet list" }).click() @@ -70,14 +70,14 @@ test.describe("Block formatting", () => { await editor.setValue(HELLO_EVERYONE) await editor.select("everyone") await page.getByRole("button", { name: "Numbered list" }).click() - await assertEditorHtml(editor, "
  1. Hello everyone
") + await assertEditorHtml(editor, '
  1. Hello everyone
') }) test("toggle numbered list off", async ({ page, editor }) => { await editor.setValue(HELLO_EVERYONE) await editor.select("everyone") await page.getByRole("button", { name: "Numbered list" }).click() - await assertEditorHtml(editor, "
  1. Hello everyone
") + await assertEditorHtml(editor, '
  1. Hello everyone
') await editor.select("everyone") await page.getByRole("button", { name: "Numbered list" }).click() @@ -88,7 +88,7 @@ test.describe("Block formatting", () => { await editor.setValue("

Alpha

Bravo

Charlie

") await editor.selectAll() await page.getByRole("button", { name: "Numbered list" }).click() - await assertEditorHtml(editor, "
  1. Alpha
  2. Bravo
  3. Charlie
") + await assertEditorHtml(editor, '
  1. Alpha
  2. Bravo
  3. Charlie
') await editor.selectAll() await page.getByRole("button", { name: "Numbered list" }).click() @@ -230,7 +230,7 @@ test.describe("Block formatting", () => { await assertEditorHtml( editor, - "

First line

  • Second line

Third line

", + '

First line

  • Second line

Third line

', ) }) @@ -245,7 +245,7 @@ test.describe("Block formatting", () => { await assertEditorHtml( editor, - "

First line

  1. Second line

Third line

", + '

First line

  1. Second line

Third line

', ) }) @@ -260,7 +260,7 @@ test.describe("Block formatting", () => { await assertEditorHtml( editor, - "
  • First item
    continuation
", + '
  • First item
    continuation
', ) }) diff --git a/test/browser/tests/formatting/escape_format.test.js b/test/browser/tests/formatting/escape_format.test.js index 368d80c16..07e70e015 100644 --- a/test/browser/tests/formatting/escape_format.test.js +++ b/test/browser/tests/formatting/escape_format.test.js @@ -15,12 +15,12 @@ test.describe("Escape format", () => { await editor.selectAll() await page.getByRole("button", { name: "Bullet list" }).click() - await assertEditorHtml(editor, "
  • First line
") + await assertEditorHtml(editor, "
  • First line
") await clickToolbarButton(page, "insertQuoteBlock") await assertEditorHtml( editor, - "
  • First line
", + "
  • First line
", ) await editor.send("ArrowRight") @@ -30,7 +30,7 @@ test.describe("Escape format", () => { await assertEditorHtml( editor, - "
  • First line

Outside quote

", + "
  • First line

Outside quote

", ) }) @@ -74,7 +74,7 @@ test.describe("Escape format", () => { await clickToolbarButton(page, "insertQuoteBlock") await assertEditorHtml( editor, - "
  • Item one
  • Item two
  • Item three
", + "
  • Item one
  • Item two
  • Item three
", ) await editor.select("Item two") @@ -86,7 +86,7 @@ test.describe("Escape format", () => { await assertEditorHtml( editor, - "
  • Item one
  • Item two

Middle text

  • Item three
", + "
  • Item one
  • Item two

Middle text

  • Item three
", ) }) @@ -101,7 +101,7 @@ test.describe("Escape format", () => { await clickToolbarButton(page, "insertQuoteBlock") await assertEditorHtml( editor, - "
  • Item one
", + "
  • Item one
", ) await editor.send("ArrowRight") @@ -111,7 +111,7 @@ test.describe("Escape format", () => { await assertEditorHtml( editor, - "
  • Item one

After escape

", + "
  • Item one

After escape

", ) }) diff --git a/test/browser/tests/formatting/inline_code_escape.test.js b/test/browser/tests/formatting/inline_code_escape.test.js index b28c33f63..bfb93f837 100644 --- a/test/browser/tests/formatting/inline_code_escape.test.js +++ b/test/browser/tests/formatting/inline_code_escape.test.js @@ -27,7 +27,7 @@ test.describe("Inline code escape with arrow keys", () => { await editor.send("ArrowRight") await editor.send(" more") - await assertEditorHtml(editor, "
  • item code more
") + await assertEditorHtml(editor, '
  • item code more
') }) test("right arrow escapes inline code when code is only content in paragraph", async ({ page, editor }) => { diff --git a/test/browser/tests/formatting/list_indentation.test.js b/test/browser/tests/formatting/list_indentation.test.js index d8f47d1fd..e612d581d 100644 --- a/test/browser/tests/formatting/list_indentation.test.js +++ b/test/browser/tests/formatting/list_indentation.test.js @@ -16,7 +16,7 @@ test.describe("List indentation", () => { await assertEditorHtml( editor, - '
  • First item
    • Second item
', + '
  • First item
    • Second item
', ) }) @@ -31,7 +31,7 @@ test.describe("List indentation", () => { await assertEditorHtml( editor, - '
  • First
      • Second
', + '
  • First
      • Second
', ) }) @@ -47,7 +47,7 @@ test.describe("List indentation", () => { await assertEditorHtml( editor, - '
  • First
    • Second
', + '
  • First
    • Second
', ) }) @@ -61,7 +61,7 @@ test.describe("List indentation", () => { await assertEditorHtml( editor, - "
  • First item
  • Nested item
", + '
  • First item
  • Nested item
', ) }) @@ -74,7 +74,7 @@ test.describe("List indentation", () => { await assertEditorHtml( editor, - '
  1. First item
    1. Second item
', + '
  1. First item
    1. Second item
', ) }) @@ -88,7 +88,7 @@ test.describe("List indentation", () => { await assertEditorHtml( editor, - "
  1. First item
  2. Nested item
", + '
  1. First item
  2. Nested item
', ) }) diff --git a/test/browser/tests/formatting/list_item_deletion.test.js b/test/browser/tests/formatting/list_item_deletion.test.js index 578c36514..2843db7ea 100644 --- a/test/browser/tests/formatting/list_item_deletion.test.js +++ b/test/browser/tests/formatting/list_item_deletion.test.js @@ -11,7 +11,7 @@ test.describe("List item deletion cursor position", () => { editor, }) => { // Set up a bullet list with one item - await editor.setValue("
  • Some text
") + await editor.setValue("
  • Some text
") await editor.flush() // Place cursor at the very start of "Some text" @@ -33,14 +33,14 @@ test.describe("List item deletion cursor position", () => { await editor.send("Backspace") await editor.flush() - await assertEditorHtml(editor, "


  • Some text
") + await assertEditorHtml(editor, "


  • Some text
") // Type a marker character to verify cursor position. // Cursor should be in the new paragraph, so marker appears there. await editor.send("X") await editor.flush() - await assertEditorHtml(editor, "

X

  • Some text
") + await assertEditorHtml(editor, "

X

  • Some text
") }) test("backspace on empty first list item with paragraph above converts to paragraph", async ({ @@ -48,7 +48,7 @@ test.describe("List item deletion cursor position", () => { }) => { // Set up content before the list, then a list await editor.setValue( - "

Paragraph above

  • List item text
", + "

Paragraph above

  • List item text
", ) await editor.flush() @@ -76,7 +76,7 @@ test.describe("List item deletion cursor position", () => { await assertEditorHtml( editor, - "

Paragraph above

X

  • List item text
", + "

Paragraph above

X

  • List item text
", ) }) @@ -85,7 +85,7 @@ test.describe("List item deletion cursor position", () => { }) => { // Set up a bullet list with two items await editor.setValue( - "
  • First item
  • Second item
", + "
  • First item
  • Second item
", ) await editor.flush() @@ -109,7 +109,7 @@ test.describe("List item deletion cursor position", () => { await assertEditorHtml( editor, - "
  • First item
  • Second item
", + "
  • First item
  • Second item
", ) // Type a marker to verify cursor is at the end of "First item" @@ -118,7 +118,7 @@ test.describe("List item deletion cursor position", () => { await assertEditorHtml( editor, - "
  • First itemX
  • Second item
", + "
  • First itemX
  • Second item
", ) }) }) From 07026b726a5e60277f2a6ac859812de90dc1f6b0 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Fri, 17 Apr 2026 10:22:34 +0200 Subject: [PATCH 109/199] Test coverage for indented list numbering --- test/browser/tests/formatting/block_formatting.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index f7935cbd2..b501880b0 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -100,6 +100,11 @@ test.describe("Block formatting", () => { await assertEditorHtml(editor, '
  1. First
  2. Second
') }) + test("nested ordered list numbering is calculated correctly", async ({ editor }) => { + await editor.setValue('
  1. First
    1. Nested
  2. Second
') + await assertEditorHtml(editor, '
  1. First
    1. Nested
  2. Second
') + }) + test("insert quote without selection", async ({ page, editor }) => { await editor.setValue(HELLO_EVERYONE) await page.getByRole("button", { name: "Quote" }).click() From fb215ebc5c5b31050f387e115147a9153a2a878a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 15 Apr 2026 18:02:32 +0200 Subject: [PATCH 110/199] Skip turbo:before-cache reset for editors inside turbo-permanent elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lexxy editors register a turbo:before-cache listener that calls #reset() to clean up before Turbo snapshots the page. However, editors inside [data-turbo-permanent] elements are excluded from the snapshot — they're preserved as-is across navigations. Resetting these editors destroys their contenteditable div, and since the element is never disconnected/reconnected, connectedCallback never fires to restore it. This caused the sidebar ping editor in Basecamp to become permanently locked out after entering and exiting card edit mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/elements/editor.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index 65aa83f3c..340cac0dd 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -394,7 +394,9 @@ export class LexicalEditorElement extends HTMLElement { } #handleTurboBeforeCache = (event) => { - this.#reset() + if (!this.closest("[data-turbo-permanent]")) { + this.#reset() + } } #synchronizeWithChanges() { From ec2274811cf026ab6b8cd7d2c230627bd46f9dc9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 17 Apr 2026 10:39:55 +0200 Subject: [PATCH 111/199] Cache Playwright browser binaries in CI (#970) The browser-test job downloads Playwright browsers from scratch on every run. WebKit takes ~14 minutes. Cache ~/.cache/ms-playwright keyed on the Playwright version and browser name so subsequent runs only install system apt deps. --- .github/workflows/ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf558aaf2..f17616fcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,9 +167,25 @@ jobs: - name: Install JavaScript dependencies run: yarn install --frozen-lockfile - - name: Install Playwright browsers + - name: Get Playwright version + id: playwright-version + run: echo "version=$(npx playwright --version)" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ matrix.browser }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browser + if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps ${{ matrix.browser }} + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps ${{ matrix.browser }} + - name: Run Playwright tests run: yarn test:browser:${{ matrix.browser }} From 5eb27ffd02909213863ea09a5e26b650ca5ef6c0 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 17 Apr 2026 10:56:34 +0200 Subject: [PATCH 112/199] Batch DOM reads and writes during editor initialization (#982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Batch DOM reads and writes during editor initialization Layout thrashing was forcing ~1.5s of blocking on page load when multiple editors initialized during initial render. The cost of a forced reflow scales with the amount of dirty DOM, and during initial render that's most of the page — turning individual 1-5ms reflows into 50-100ms reflows. Three init-time patterns interleaved DOM reads and writes inside loops, forcing N reflows where 1 would suffice: - editor.js #resolveColors: setProperty (write) → getComputedStyle (forced recalc) → removeProperty (write), per color value. Fires for each of ~9 highlight colors × 2 properties. - toolbar.js #compactMenu: scrollWidth/clientWidth (layout read) then prepend (DOM write), per overflowing button. Fires on connectedCallback, every ResizeObserver callback, and setEditor. - format_helper.js getComputedStyleForProperty: appendChild (write) → getComputedStyle (forced recalc) → remove (write), per allowed value. Memoized, but still thrashes on first use. All three now batch: create all elements in a DocumentFragment, attach once, read all computed values in a single pass, then clean up. This reduces N reflows to 1 per call site. The toolbar fix measures the toolbar and all button widths in a single read pass, computes which buttons overflow using math, then moves them all in a single write pass — preserving the existing behavior while eliminating the read/write interleave. * Explain the reflow batching approach in comments * Remove unused #toolbarIsOverflowing helper * Address review feedback on compactMenu and canonicalizer - Use offsetLeft + offsetWidth to compute button right edges, which accounts for the flex gap and margin-inline-end. - Move one extra button after the first overflowing one to reserve space for the overflow control without having to measure it while display: none. This matches the previous implementation's "move one more once it fits" behaviour. - Fix a pre-existing bug in StyleCanonicalizer#resolveCannonicalValue: indexOf returns -1 (truthy) when not found and 0 (falsy) on a first match, so `||=` never ran the fallback on a miss and clobbered valid zero matches. Replace with an explicit `if (index === -1)` guard. * Use a single container for #resolveColors cleanup Wraps the N resolver spans in a hidden container so cleanup is one container.remove() instead of N individual element.remove() calls. Same batched read/write behaviour as before — the container attaches once, all computed styles read in one pass, container detaches once. Keeps attachment on the editor element to preserve CSS custom property scoping from ancestors. --- src/elements/editor.js | 31 +++++++++++++++++-------- src/elements/toolbar.js | 45 ++++++++++++++++++++++-------------- src/helpers/format_helper.js | 34 ++++++++++++++++++--------- 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index 340cac0dd..c34ab30aa 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -676,19 +676,30 @@ export class LexicalEditorElement extends HTMLElement { ] } + // Builds one resolver element per CSS value inside a hidden container, attaches + // the container in a single DOM write, then reads all computed values in one pass + // — triggering at most one forced reflow. The previous implementation interleaved + // setProperty/getComputedStyle/removeProperty on the same element, forcing a style + // recalc on every iteration during editor initialization. #resolveColors(property, cssValues) { - const resolver = document.createElement("span") - resolver.style.display = "none" - this.appendChild(resolver) - - const resolved = cssValues.map(cssValue => { - resolver.style.setProperty(property, cssValue) - const value = window.getComputedStyle(resolver).getPropertyValue(property) - resolver.style.removeProperty(property) - return { name: cssValue, value } + const container = document.createElement("span") + container.style.display = "none" + + const resolvers = cssValues.map(cssValue => { + const element = document.createElement("span") + element.style.setProperty(property, cssValue) + container.appendChild(element) + return { element, name: cssValue } }) - resolver.remove() + this.appendChild(container) + + const resolved = resolvers.map(({ element, name }) => ({ + name, + value: window.getComputedStyle(element).getPropertyValue(property) + })) + + container.remove() return resolved } diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 9deadda60..be76bd6a9 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -252,12 +252,6 @@ export class LexicalToolbarElement extends HTMLElement { } } - #toolbarIsOverflowing() { - // Safari can report inconsistent clientWidth values on more than 100% window zoom level, - // that was affecting the toolbar overflow calculation. We're adding +1 to get around this issue. - return (this.scrollWidth - this.#overflow.clientWidth) > this.clientWidth + 1 - } - #refreshToolbarOverflow = () => { this.#resetToolbarOverflow() this.#compactMenu() @@ -270,29 +264,46 @@ export class LexicalToolbarElement extends HTMLElement { this.#overflowMenu.toggleAttribute("disabled", !isOverflowing) } + // Separates layout reads from DOM writes to avoid forced reflows during init. + // Measures every button's right edge in a single read pass, figures out which + // buttons overflow using math, and then moves them in a single write pass. + // The previous implementation interleaved `scrollWidth`/`clientWidth` reads with + // `prepend()` writes inside a loop, forcing one full browser reflow per button. #compactMenu() { - const buttons = this.#buttons.reverse() - let movedToOverflow = false - - for (const button of buttons) { - if (this.#toolbarIsOverflowing()) { - this.#overflowMenu.prepend(button) - movedToOverflow = true - } else { - if (movedToOverflow) this.#overflowMenu.prepend(button) + const buttons = this.#buttons + if (buttons.length === 0) return + + const availableWidth = this.clientWidth + 1 // +1 for Safari zoom rounding + const buttonRightEdges = buttons.map(button => button.offsetLeft + button.offsetWidth) + + let firstOverflowing = -1 + for (let i = 0; i < buttons.length; i++) { + if (buttonRightEdges[i] > availableWidth) { + firstOverflowing = i break } } + + if (firstOverflowing === -1) return + + // Move one extra button to reserve space for the overflow control, which is + // `display: none` until we show it — matching the previous implementation's + // "move one more after it stops overflowing" behaviour. + const overflowIndex = Math.max(0, firstOverflowing - 1) + const overflowButtons = buttons.slice(overflowIndex).reverse() + for (const button of overflowButtons) { + this.#overflowMenu.prepend(button) + } } #resetToolbarOverflow() { const items = Array.from(this.#overflowMenu.children) items.sort((a, b) => this.#itemPosition(b) - this.#itemPosition(a)) - items.forEach((item) => { + for (const item of items) { const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#overflow this.insertBefore(item, nextItem) - }) + } } #itemPosition(item) { diff --git a/src/helpers/format_helper.js b/src/helpers/format_helper.js index 7d260ee32..4e26b2d2e 100644 --- a/src/helpers/format_helper.js +++ b/src/helpers/format_helper.js @@ -71,24 +71,36 @@ export class StyleCanonicalizer { #resolveCannonicalValue(value) { let index = this.#computedAllowedValues.indexOf(value) - index ||= this.#computedAllowedValues.indexOf(getComputedStyleForProperty(this._property, value)) + if (index === -1) { + index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]) + } return index === -1 ? null : this._allowedValues[index] } get #computedAllowedValues() { - return this._computedAllowedValues ||= this._allowedValues.map( - value => getComputedStyleForProperty(this._property, value) - ) + return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues) } } -function getComputedStyleForProperty(property, value) { - const style = `${property}: ${value};` +// Separates DOM writes from layout reads to avoid forced reflows. All resolver +// elements are built inside a fragment, attached once, then read in a single pass. +// Reading `getComputedStyle` after a write forces the browser to recompute layout, +// so interleaving writes and reads inside a loop turns one reflow into N. +function computeStyleValues(property, values) { + const fragment = document.createDocumentFragment() + + const elements = values.map(value => { + const element = createElement("span", { style: `display: none; ${property}: ${value};` }) + fragment.appendChild(element) + return element + }) + + document.body.appendChild(fragment) - // the element has to be attached to the DOM have computed styles - const element = document.body.appendChild(createElement("span", { style: "display: none;" + style })) - const computedStyle = window.getComputedStyle(element).getPropertyValue(property) - element.remove() + const computed = elements.map(element => + window.getComputedStyle(element).getPropertyValue(property) + ) - return computedStyle + elements.forEach(element => element.remove()) + return computed } From c10a50a46266ad0c68fdf03954bed0c3e3135ea2 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 17 Apr 2026 11:07:48 +0200 Subject: [PATCH 113/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index edec829af..b5e3dfa8d 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.8.beta" + VERSION = "0.9.9.beta" end From a55d687546a7caa054f4f6172cda372dd3d34b8a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 17 Apr 2026 13:50:19 +0200 Subject: [PATCH 114/199] Reduce init-time style recalc: contained resolver root, content containment, and targeted guards (#984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Attach style resolvers to a strictly-contained root computeStyleValues (for StyleCanonicalizer) and the editor's internal color resolver attached their temporary span elements to document.body and to the editor host respectively. On pages with ancestor-dependent selectors (`:has()`, descendant combinators, universal sibling rules), every `appendChild` / `remove` forced the browser to re-evaluate matching across the entire body subtree. Production traces on a Basecamp chat page showed each invocation triggering a ~50ms style recalc that visited ~13,500 elements. Introduce a shared, strictly-contained root (`contain: strict`, visibility hidden, zero-sized, pointer-events none) that's attached once to document.body. Callers append their resolver elements inside it; subsequent mutations are confined to the contained subtree and never invalidate styles on the rest of the page. This targets the leak that survived adding `contain: layout style` to lexxy-editor itself — the host containment doesn't help when the mutation happens outside the editor, as computeStyleValues was doing. * Only fire set-value rAF workaround on first load The empty `editor.update(() => {})` inside `set value` exists to work around a Lexical bug where setting content on a freshly-initialized editor leaves the state inconsistent until the next update (attachments fail because no root node is detected). Subsequent `set value` calls do not hit that state — the editor is already in a valid state — so the extra reconciler cycle is pure overhead. Gate the workaround on whether the initial value has been loaded yet. * Isolate the contenteditable root with CSS containment Adds `contain: layout style` to `.lexxy-editor__content`, the contenteditable element Lexical commits its reconciler mutations to. The outer `lexxy-editor` already has containment, but the contenteditable is the hotspot for rapid, fine-grained DOM mutations during typing, selection changes, and initial state load. Containing it too ensures that ancestor-dependent selectors and sibling layout (toolbar, attachment previews, prompt menus) never re-evaluate when an update fires deep inside the content tree. * Skip editor.focus when contenteditable is already focused `editor.focus()` commits a reconciler update to position the cursor. Calling it when the contenteditable already has focus is a no-op logically but still triggers a full reconciler cycle and style recalc. Guard the call so repeat focus() invocations (e.g. after selection changes that try to restore focus to an already-focused editor) stop paying that cost. --- app/assets/stylesheets/lexxy-editor.css | 7 ++++++ src/elements/editor.js | 30 ++++++++++++++++++++----- src/helpers/format_helper.js | 12 +++++----- src/helpers/style_resolver_root.js | 28 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 src/helpers/style_resolver_root.js diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 47a9ca6f2..e0ae9b6f2 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -399,6 +399,13 @@ min-block-size: var(--lexxy-editor-rows); outline: 0; padding: var(--lexxy-editor-padding); + + /* Isolate the contenteditable root's layout and style. Lexical's reconciler + commits mutations inside this element (nodes appended, text inserted, + class flipped) on every update; containment keeps those mutations from + invalidating ancestor-dependent selectors and sibling layout elsewhere + in the editor. */ + contain: layout style; } :where(.lexxy-editor--drag-over) { diff --git a/src/elements/editor.js b/src/elements/editor.js index c34ab30aa..9961fa9d8 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -27,6 +27,7 @@ import Clipboard from "../editor/clipboard" import Extensions from "../editor/extensions" import { BrowserAdapter } from "../editor/adapters/browser_adapter" import { getHighlightStyles } from "../helpers/format_helper" +import { styleResolverRoot } from "../helpers/style_resolver_root" import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" import { exportTextNodeDOM } from "../helpers/text_node_export_helper" @@ -47,6 +48,7 @@ export class LexicalEditorElement extends HTMLElement { static observedAttributes = [ "connected", "required" ] #initialValue = "" + #initialValueLoaded = false #validationTextArea = document.createElement("textarea") #editorInitializedRafId = null #listeners = new ListenerBin() @@ -224,9 +226,19 @@ export class LexicalEditorElement extends HTMLElement { } focus() { + // `editor.focus()` commits a reconciler update to position the cursor. + // Skip if the contenteditable already owns focus — the update would be a + // no-op but still triggers a full style/layout pass on pages with large + // DOMs. + if (this.#isContentFocused) return + this.editor.focus(() => this.#onFocus()) } + get #isContentFocused() { + return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement) + } + get value() { if (!this.cachedValue) { this.editor?.getEditorState().read(() => { @@ -238,6 +250,8 @@ export class LexicalEditorElement extends HTMLElement { } set value(html) { + const wasEmpty = !this.#initialValueLoaded + this.editor.update(() => { $addUpdateTag(SKIP_DOM_SELECTION_TAG) const root = $getRoot() @@ -247,11 +261,17 @@ export class LexicalEditorElement extends HTMLElement { this.#toggleEmptyStatus() - // The first time you set the value, when the editor is empty, it seems to leave Lexical - // in an inconsistent state until, at least, you focus. You can type but adding attachments - // fails because no root node detected. This is a workaround to deal with the issue. - requestAnimationFrame(() => this.editor?.update(() => { })) + // The first time you set the value on an empty editor, Lexical can be + // left in an inconsistent state until the next update (adding attachments + // fails because no root node is detected). A no-op update works around + // it. Only fire on the first load — subsequent set value calls don't hit + // the inconsistent state and the extra reconciler cycle is pure overhead. + if (wasEmpty) { + requestAnimationFrame(() => this.editor?.update(() => { })) + } }) + + this.#initialValueLoaded = true } #parseHtmlIntoLexicalNodes(html) { @@ -692,7 +712,7 @@ export class LexicalEditorElement extends HTMLElement { return { element, name: cssValue } }) - this.appendChild(container) + styleResolverRoot().appendChild(container) const resolved = resolvers.map(({ element, name }) => ({ name, diff --git a/src/helpers/format_helper.js b/src/helpers/format_helper.js index 4e26b2d2e..8322f2d7c 100644 --- a/src/helpers/format_helper.js +++ b/src/helpers/format_helper.js @@ -1,6 +1,7 @@ import { $isRangeSelection, $isTextNode } from "lexical" import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" import { createElement } from "./html_helper" +import { styleResolverRoot } from "./style_resolver_root" export function isSelectionHighlighted(selection) { if (!$isRangeSelection(selection)) return false @@ -82,10 +83,11 @@ export class StyleCanonicalizer { } } -// Separates DOM writes from layout reads to avoid forced reflows. All resolver -// elements are built inside a fragment, attached once, then read in a single pass. -// Reading `getComputedStyle` after a write forces the browser to recompute layout, -// so interleaving writes and reads inside a loop turns one reflow into N. +// Separates DOM writes from layout reads to avoid forced reflows, and attaches +// resolver elements to a strictly-contained root (outside the normal document +// flow) so neither the attach nor the detach invalidate styles on the rest of +// the page. Without containment, appending to `document.body` triggered a +// page-wide style recalc on every canonicalization pass. function computeStyleValues(property, values) { const fragment = document.createDocumentFragment() @@ -95,7 +97,7 @@ function computeStyleValues(property, values) { return element }) - document.body.appendChild(fragment) + styleResolverRoot().appendChild(fragment) const computed = elements.map(element => window.getComputedStyle(element).getPropertyValue(property) diff --git a/src/helpers/style_resolver_root.js b/src/helpers/style_resolver_root.js new file mode 100644 index 000000000..468547760 --- /dev/null +++ b/src/helpers/style_resolver_root.js @@ -0,0 +1,28 @@ +// Shared, strictly-contained element used to attach ephemeral nodes when we +// need to read computed styles (e.g. canonicalizing style values, resolving +// CSS custom properties). The container is created once and attached to +// `document.body` once; subsequent child mutations happen *inside* the +// contained subtree so they do not invalidate style on the rest of the page. +// +// Without this, `document.body.appendChild(...)` / `element.remove()` calls +// forced the browser to re-evaluate every ancestor-dependent selector (`:has()`, +// descendant combinators, universal sibling rules) across the document on each +// invocation — a 13,000+ element style recalc per call on a typical Basecamp +// page. + +let resolverRoot = null + +export function styleResolverRoot() { + if (resolverRoot && resolverRoot.isConnected) return resolverRoot + + resolverRoot = document.createElement("div") + resolverRoot.setAttribute("aria-hidden", "true") + resolverRoot.setAttribute("data-lexxy-style-resolver", "") + // `contain: strict` (size, layout, paint, style) isolates everything. + // The root itself paints nothing (visibility hidden), has zero + // geometric impact (position fixed, intrinsic size via contain), and + // never leaks style invalidation to its ancestors. + resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;" + document.body.appendChild(resolverRoot) + return resolverRoot +} From 08e24eb6f6f9cc98fc05bec39cc13b7cfcb781b4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 17 Apr 2026 11:09:22 +0200 Subject: [PATCH 115/199] v0.9.9-beta --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d45a7c248..b8f1a5aea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@37signals/lexxy", - "version": "0.9.8-beta", + "version": "0.9.9-beta", "description": "Lexxy - A modern rich text editor for Rails.", "module": "dist/lexxy.esm.js", "type": "module", From 0bfb489c64dfe1f99acead3f60e2f905243d4844 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 17 Apr 2026 13:53:06 +0200 Subject: [PATCH 116/199] Bump version --- lib/lexxy/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lexxy/version.rb b/lib/lexxy/version.rb index b5e3dfa8d..e878ed0f5 100644 --- a/lib/lexxy/version.rb +++ b/lib/lexxy/version.rb @@ -1,3 +1,3 @@ module Lexxy - VERSION = "0.9.9.beta" + VERSION = "0.9.9.beta.preview1" end From 4620342055bb9426b61f0340294a9a1b51710b2b Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Thu, 16 Apr 2026 19:18:38 -0500 Subject: [PATCH 117/199] Helpers and config: block helpers, list-item lookup, markdown list shortcut Add src/editor/block_helpers.js with shared block-level constants and node-traversal utilities used by block-select, drag-and-drop, and the actions menu. Add src/editor/markdown/list_heading_shortcut.js to register the list-in-heading markdown transformer. Extend html_helper, lexical_helper, and storage_helper with utilities those features need, loosen dom_purify's attribute allowlist for attachment attributes, and register the eslint globals newly referenced by block-select code. --- eslint.config.js | 7 ++ src/config/dom_purify.js | 5 +- src/config/lexxy.js | 6 +- src/editor/block_helpers.js | 26 +++++ src/editor/markdown/list_heading_shortcut.js | 102 +++++++++++++++++++ src/helpers/html_helper.js | 33 +++++- src/helpers/lexical_helper.js | 6 +- src/helpers/storage_helper.js | 13 +++ 8 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 src/editor/block_helpers.js create mode 100644 src/editor/markdown/list_heading_shortcut.js diff --git a/eslint.config.js b/eslint.config.js index 188ac0805..07dbe2df9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,13 @@ export default [ Prism: "readonly", ResizeObserver: "readonly", PointerEvent: "readonly", + getComputedStyle: "readonly", + localStorage: "readonly", + NodeFilter: "readonly", + queueMicrotask: "readonly", + requestIdleCallback: "readonly", + cancelIdleCallback: "readonly", + performance: "readonly", Image: "readonly" } }, diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index 78d2971cb..2b7ab5b60 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -1,7 +1,10 @@ import DOMPurify from "dompurify" import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" -const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ] +const ALLOWED_HTML_ATTRIBUTES = [ "alt", "blob-url", "caption", "class", "content", "content-type", "contenteditable", + "data-direct-upload-id", "data-sgid", "data-collapsed", "data-caption-hidden", + "filename", "filesize", "height", "href", "presentation", + "previewable", "sgid", "src", "style", "title", "url", "width" ] const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ] diff --git a/src/config/lexxy.js b/src/config/lexxy.js index e26c591c1..98c881f15 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -5,12 +5,16 @@ const global = new Configuration({ attachmentTagName: "action-text-attachment", attachmentContentTypeNamespace: "actiontext", authenticatedUploads: false, - extensions: [] + extensions: [], + previewModal: true }) const presets = new Configuration({ default: { attachments: true, + code: { + tabSize: 2 + }, markdown: true, multiLine: true, richText: true, diff --git a/src/editor/block_helpers.js b/src/editor/block_helpers.js new file mode 100644 index 000000000..d7d835dbb --- /dev/null +++ b/src/editor/block_helpers.js @@ -0,0 +1,26 @@ +import { $isListItemNode, $isListNode } from "@lexical/list" + +// CSS class names used by block selection, drag-and-drop, and block actions. +// Centralised here to avoid stringly-typed duplication across modules. +export const BLOCK_SELECTED_CLASS = "block--selected" +export const BLOCK_FOCUSED_CLASS = "block--focused" +export const BLOCK_SELECTION_ACTIVE_CLASS = "block-selection-active" +export const NESTED_LISTITEM_CLASS = "lexxy-nested-listitem" + +// Default fallback sizes (px) when computed styles aren't available. +export const DEFAULT_HANDLE_HEIGHT = 24 +export const DEFAULT_ADD_BUTTON_WIDTH = 20 +export const DEFAULT_ROOT_PADDING = 28 + +// Positioning gaps (px) used by drag handles and drop indicators. +export const HANDLE_CONTENT_GAP = 19 +export const VIEWPORT_PADDING = 8 + +// A structural wrapper is a ListItemNode whose only children are ListNodes. +// Lexical uses these to represent nested list indentation — they contain +// no user-visible content, only the nested list structure. +export function $isStructuralWrapper(node) { + if (!$isListItemNode(node)) return false + const children = node.getChildren() + return children.length > 0 && children.every(c => $isListNode(c)) +} diff --git a/src/editor/markdown/list_heading_shortcut.js b/src/editor/markdown/list_heading_shortcut.js new file mode 100644 index 000000000..8e34be081 --- /dev/null +++ b/src/editor/markdown/list_heading_shortcut.js @@ -0,0 +1,102 @@ +import { $getNodeByKey, $getSelection, $isElementNode, $isParagraphNode, $isRangeSelection, $isTextNode } from "lexical" +import { $isListItemNode, $isListNode } from "@lexical/list" +import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text" + +// Mapping of leading patterns to block creators +const BLOCK_PATTERNS = [ + { regex: /^####\s$/, create: () => $createHeadingNode("h4") }, + { regex: /^###\s$/, create: () => $createHeadingNode("h3") }, + { regex: /^##\s$/, create: () => $createHeadingNode("h2") }, + { regex: /^#\s$/, create: () => $createHeadingNode("h1") }, + { regex: /^>\s$/, create: () => $createQuoteNode() } +] + +// Registers a listener that converts `# ` (and ##, ###, ####) and `> ` at the +// start of a list item into a wrapped block. Lexical's built-in markdown +// shortcuts only work on paragraphs — this extends them to list items. +export function registerListBlockShortcuts(editor) { + return editor.registerUpdateListener(({ tags, dirtyLeaves, editorState, prevEditorState }) => { + if (tags.has("historic") || tags.has("collaboration")) return + if (editor.isComposing()) return + + const selection = editorState.read($getSelection) + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return + + const anchorKey = selection.anchor.key + const anchorOffset = selection.anchor.offset + + if (!dirtyLeaves.has(anchorKey)) return + + editorState.read(() => { + const anchorNode = $getNodeByKey(anchorKey) + if (!$isTextNode(anchorNode)) return + + // Only trigger inside list items + const listItem = findParentListItem(anchorNode) + if (!listItem) return + + // Must be the first text node in the list item (not inside a wrapped block) + const parent = anchorNode.getParent() + if (parent && !$isListItemNode(parent) && !$isParagraphNode(parent)) return + + const textContent = anchorNode.getTextContent() + + for (const { regex, create } of BLOCK_PATTERNS) { + if (regex.test(textContent.slice(0, anchorOffset))) { + editor.update(() => { + const freshNode = $getNodeByKey(anchorKey) + if (!freshNode) return + const freshListItem = findParentListItem(freshNode) + if (!freshListItem) return + + const content = freshNode.getTextContent() + const match = content.match(/^(?:#{1,4}|>)\s/) + if (!match) return + + const block = create() + const remaining = content.slice(match[0].length) + + const children = freshListItem.getChildren() + const existingWrapped = children.find(c => + $isElementNode(c) && !$isListNode(c) && !$isParagraphNode(c) + ) + + if (existingWrapped) { + for (const child of [ ...existingWrapped.getChildren() ]) { + block.append(child) + } + existingWrapped.replace(block) + } else { + for (const child of [ ...children ]) { + if ($isListNode(child)) continue + block.append(child) + } + const firstChild = freshListItem.getFirstChild() + if (firstChild) { + firstChild.insertBefore(block) + } else { + freshListItem.append(block) + } + } + + const textNode = block.getFirstChild() + if ($isTextNode(textNode)) { + textNode.setTextContent(remaining) + } + block.selectEnd() + }) + return + } + } + }) + }) +} + +function findParentListItem(node) { + let current = node + while (current) { + if ($isListItemNode(current)) return current + current = current.getParent() + } + return null +} diff --git a/src/helpers/html_helper.js b/src/helpers/html_helper.js index df48feadf..3c2b4f383 100644 --- a/src/helpers/html_helper.js +++ b/src/helpers/html_helper.js @@ -26,8 +26,39 @@ export function createAttachmentFigure(contentType, isPreviewable, fileName) { }) } +// Extension → short label shown inside .attachment__icon. Any extension not in +// this map falls back to its uppercased form (".sql" → "SQL"). Keep in sync +// with Lexxy::AttachmentIconHelper on the Ruby side — the show-page +// _blob.html.erb partial uses the same labels. +const ICON_LABELS = { + md: "M\u2193", + markdown: "M\u2193", + png: "IMG", + jpg: "IMG", + jpeg: "IMG", + webp: "IMG", + svg: "SVG", + bmp: "IMG", + tiff: "IMG", + tif: "IMG", + ico: "IMG", + avif: "IMG", + heic: "IMG", + docx: "DOC", + xlsx: "XLS", + pptx: "PPT", + rar: "ZIP", + webm: "VID", + avi: "VID" +} + +export function attachmentIconLabel(extension) { + if (!extension) return "" + return ICON_LABELS[extension] || extension.toUpperCase() +} + export function isPreviewableImage(contentType) { - return contentType.startsWith("image/") && !contentType.includes("svg") + return contentType.startsWith("image/") } export function dispatchCustomEvent(element, name, detail) { diff --git a/src/helpers/lexical_helper.js b/src/helpers/lexical_helper.js index b21196a75..3f587d609 100644 --- a/src/helpers/lexical_helper.js +++ b/src/helpers/lexical_helper.js @@ -1,6 +1,6 @@ import { $createNodeSelection, $createParagraphNode, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isTextNode, TextNode } from "lexical" import { HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG } from "lexical" -import { ListNode } from "@lexical/list" +import { ListItemNode, ListNode } from "@lexical/list" import { $getNearestNodeOfType, $lastToFirstIterator } from "@lexical/utils" import { $wrapNodeInElement } from "@lexical/utils" import { $isAtNodeEnd } from "@lexical/selection" @@ -31,6 +31,10 @@ export function getListType(node) { return list?.getListType() ?? null } +export function getListItemNode(node) { + return $getNearestNodeOfType(node, ListItemNode) +} + export function $isAtNodeEdge(point, atStart = null) { if (atStart === null) { return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false) diff --git a/src/helpers/storage_helper.js b/src/helpers/storage_helper.js index cd97dca68..683d1f595 100644 --- a/src/helpers/storage_helper.js +++ b/src/helpers/storage_helper.js @@ -26,3 +26,16 @@ export function mimeTypeToExtension(mimeType) { const extension = mimeType.split("/")[1] return extension } + +// For playable media (video, audio, PDFs), the stored url/src is often an +// Active Storage representation URL (a thumbnail image), not the blob itself. +// Rewrite /representations/redirect/// into +// /blobs/redirect// so the actual file can be streamed. +// Returns the original URL unchanged if it's not a representation URL. +export function representationToBlobUrl(url) { + if (!url) return null + return url.replace( + /\/rails\/active_storage\/representations\/redirect\/([^/]+)\/[^/]+\/([^?]+)/, + "/rails/active_storage/blobs/redirect/$1/$2" + ) +} From 5a47e4e11ad6028d61b0f49b211fe9f2a740a741 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Thu, 16 Apr 2026 19:18:47 -0500 Subject: [PATCH 118/199] Nodes: attachment lifecycle, audio/video players, collapsed card view Extend ActionTextAttachmentNode with audio/video playback, collapsed card view, editable file captions, blob-URL preservation, and SVG re-fetch for inline rendering (ActiveStorage forces SVG downloads). Wire the upload node to forward progress/preview fields. Keep the custom attachment XSS fix by passing innerHtml through sanitize. Minor refinements to horizontal_divider, early_escape_code, image_gallery, and wrapped_table to cooperate with block-select. --- src/nodes/action_text_attachment_node.js | 292 ++++++++++++++++-- .../action_text_attachment_upload_node.js | 7 +- .../custom_action_text_attachment_node.js | 2 +- src/nodes/early_escape_code_node.js | 12 + src/nodes/horizontal_divider_node.js | 2 +- src/nodes/image_gallery_node.js | 3 +- src/nodes/wrapped_table_node.js | 40 +++ 7 files changed, 320 insertions(+), 38 deletions(-) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index dca6f58f2..5619bb56f 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -1,8 +1,8 @@ import Lexxy from "../config/lexxy" import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" -import { createAttachmentFigure, createElement, isPreviewableImage } from "../helpers/html_helper" -import { bytesToHumanSize, extractFileName } from "../helpers/storage_helper" +import { attachmentIconLabel, createAttachmentFigure, createElement, dispatch, isPreviewableImage } from "../helpers/html_helper" +import { bytesToHumanSize, extractFileName, representationToBlobUrl } from "../helpers/storage_helper" import { parseBoolean } from "../helpers/string_helper" @@ -27,6 +27,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { node: new ActionTextAttachmentNode({ sgid: attachment.getAttribute("sgid"), src: attachment.getAttribute("url"), + blobUrl: attachment.getAttribute("blob-url"), previewable: attachment.getAttribute("previewable"), altText: attachment.getAttribute("alt"), caption: attachment.getAttribute("caption"), @@ -34,7 +35,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { fileName: attachment.getAttribute("filename"), fileSize: attachment.getAttribute("filesize"), width: attachment.getAttribute("width"), - height: attachment.getAttribute("height") + height: attachment.getAttribute("height"), + collapsed: attachment.getAttribute("data-collapsed"), + captionHidden: attachment.getAttribute("data-caption-hidden") }) }), priority: 1 } @@ -80,12 +83,13 @@ export class ActionTextAttachmentNode extends DecoratorNode { return Lexxy.global.get("attachmentTagName") } - constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) { + constructor({ tagName, sgid, src, blobUrl, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, collapsed, captionHidden, uploadError }, key) { super(key) this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME this.sgid = sgid this.src = src + this.blobUrl = blobUrl || null this.previewSrc = previewSrc this.previewable = parseBoolean(previewable) this.pendingPreview = pendingPreview @@ -96,6 +100,8 @@ export class ActionTextAttachmentNode extends DecoratorNode { this.fileSize = fileSize this.width = width this.height = height + this.collapsed = parseBoolean(collapsed) + this.captionHidden = parseBoolean(captionHidden) this.uploadError = uploadError this.editor = $getEditor() @@ -107,17 +113,43 @@ export class ActionTextAttachmentNode extends DecoratorNode { const figure = this.createAttachmentFigure() - if (this.isPreviewableAttachment) { - figure.appendChild(this.#createDOMForImage()) - figure.appendChild(this.#createEditableCaption()) + if (this.isAudio) { + const previewView = createElement("div", { className: "attachment__preview-view" }) + previewView.appendChild(this.#createIconLabel()) + previewView.appendChild(this.#createFileCaption()) + previewView.appendChild(this.#createAudioPlayer()) + figure.appendChild(previewView) + + // Audio's card view is identical DOM to the preview-view header (icon + name), + // so defer creation until it's actually needed (collapsed mode). + if (this.collapsed) figure.appendChild(this.#createCardView()) } else if (this.isVideo) { - figure.appendChild(this.#createDOMForFile()) - figure.appendChild(this.#createEditableCaption()) + const previewView = createElement("div", { className: "attachment__preview-view" }) + previewView.appendChild(this.#createVideoPlayer()) + previewView.appendChild(this.#createEditableCaption()) + figure.appendChild(previewView) + + if (this.collapsed) figure.appendChild(this.#createCardView()) + } else if (this.isPreviewableAttachment) { + const previewView = createElement("div", { className: "attachment__preview-view" }) + previewView.appendChild(this.#createDOMForImage()) + previewView.appendChild(this.#createEditableCaption()) + figure.appendChild(previewView) + + // Card view is hidden by CSS until the user collapses the attachment. + // Skip creating it eagerly — significant DOM cost when rendering many + // attachments at once. updateDOM recreates it when `collapsed` flips. + if (this.collapsed) figure.appendChild(this.#createCardView()) } else { - figure.appendChild(this.#createDOMForFile()) - figure.appendChild(this.#createDOMForNotImage()) + figure.appendChild(this.#createIconLabel()) + figure.appendChild(this.#createFileCaption()) } + if (this.collapsed) figure.classList.add("attachment--collapsed") + if (this.captionHidden) figure.classList.add("attachment--caption-hidden") + + figure.addEventListener("dblclick", (event) => this.#handlePreviewClick(event)) + return figure } @@ -129,9 +161,35 @@ export class ActionTextAttachmentNode extends DecoratorNode { caption.value = this.caption } + // Lazy card-view creation: if collapsed flipped on and the card view was + // never rendered (skipped at createDOM for perf), build it now. + if (this.collapsed && this.#supportsCardView && !dom.querySelector(".attachment__card-view")) { + dom.appendChild(this.#createCardView()) + } + + // Sync file/audio attachment name display (non-image attachments). + // When captionHidden, show original filename; otherwise show caption or filename. + const displayName = this.captionHidden ? this.fileName : (this.caption || this.fileName) + for (const nameTag of dom.querySelectorAll(".attachment__name")) { + if (!nameTag.querySelector("input")) { + nameTag.textContent = displayName + } + } + + dom.classList.toggle("attachment--collapsed", this.collapsed) + dom.classList.toggle("attachment--caption-hidden", this.captionHidden) + + // Keep the figure's dataset.caption in sync so preview modal can read + // the authoritative caption regardless of the inline caption-hidden toggle. + dom.dataset.caption = this.caption || "" + return false } + get #supportsCardView() { + return this.isAudio || this.isPreviewableAttachment + } + getTextContent() { return `[${this.caption || this.fileName}]\n\n` } @@ -145,6 +203,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { sgid: this.sgid, previewable: this.previewable || null, url: this.src, + "blob-url": this.blobUrl || null, alt: this.altText, caption: this.caption, "content-type": this.contentType, @@ -152,7 +211,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { filesize: this.fileSize, width: this.width, height: this.height, - presentation: "gallery" + presentation: "gallery", + "data-collapsed": this.collapsed || null, + "data-caption-hidden": this.captionHidden || null }) return { element: attachment } @@ -165,6 +226,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { tagName: this.tagName, sgid: this.sgid, src: this.src, + blobUrl: this.blobUrl, previewable: this.previewable, altText: this.altText, caption: this.caption, @@ -172,7 +234,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { fileName: this.fileName, fileSize: this.fileSize, width: this.width, - height: this.height + height: this.height, + collapsed: this.collapsed, + captionHidden: this.captionHidden } } @@ -191,29 +255,47 @@ export class ActionTextAttachmentNode extends DecoratorNode { const figure = createAttachmentFigure(this.contentType, previewable, this.fileName) figure.draggable = true figure.dataset.lexicalNodeKey = this.__key - - const deleteButton = createElement("lexxy-node-delete-button") + figure.dataset.src = this.src || "" + figure.dataset.contentType = this.contentType || "" + figure.dataset.fileName = this.fileName || "" + figure.dataset.fileSize = this.fileSize || "" + figure.dataset.sgid = this.sgid || "" + figure.dataset.caption = this.caption || "" + if (this.blobUrl) figure.dataset.blobUrl = this.blobUrl + + const deleteButton = createElement("lexxy-attachment-controls") figure.appendChild(deleteButton) return figure } get isPreviewableAttachment() { - return this.isPreviewableImage || this.previewable + return this.isPreviewableImage || this.previewable || this.isAudio } get isPreviewableImage() { return isPreviewableImage(this.contentType) } + get isAudio() { + return this.contentType?.startsWith("audio/") + } + get isVideo() { - return this.contentType.startsWith("video/") + return this.contentType?.startsWith("video/") + } + + // For playable media, the stored url/src is often an Active Storage + // representation URL (a thumbnail image). Derive the actual blob URL so + // the inline player and modal receive the real file. + get playbackUrl() { + return this.blobUrl || representationToBlobUrl(this.src) || this.src } #createDOMForPendingPreview() { const figure = this.createAttachmentFigure(false) - figure.appendChild(this.#createDOMForFile()) - figure.appendChild(this.#createDOMForNotImage()) + figure.appendChild(this.#createIconLabel()) + figure.appendChild(this.#createFileCaption()) this.#pollForPreview(figure) return figure } @@ -226,6 +308,19 @@ export class ActionTextAttachmentNode extends DecoratorNode { img.onerror = () => this.#swapPreviewToFileDOM(img) } + // ActiveStorage forces image/svg+xml downloads (security default — SVGs + // can embed