From 2d6383284346c2488564cb6423b1c145c07c9b4b Mon Sep 17 00:00:00 2001 From: gjulivan Date: Tue, 1 Sep 2026 14:03:07 +0200 Subject: [PATCH 1/6] fix: file uploader cause form submit on rich text image dialog --- .../file-uploader-web/CHANGELOG.md | 4 + .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../design.md | 63 ++++++ .../proposal.md | 53 +++++ .../file-uploader-action-buttons/spec.md | 32 +++ .../tasks.md | 25 +++ .../file-uploader-web/openspec/config.yaml | 20 ++ .../file-uploader-action-buttons/spec.md | 38 ++++ .../src/components/ActionButton.tsx | 2 +- .../src/components/RetryButton.tsx | 1 + .../__tests__/ActionButton.spec.tsx | 59 ++++++ .../components/__tests__/RetryButton.spec.tsx | 56 ++++++ .../rich-text-web/CHANGELOG.md | 4 + .../.openspec.yaml | 2 + .../design.md | 108 ++++++++++ .../proposal.md | 61 ++++++ .../specs/rich-text-image-dialog/spec.md | 51 +++++ .../tasks.md | 50 +++++ .../specs/rich-text-image-dialog/spec.md | 50 +++++ .../toolbars/components/ImageDialog.tsx | 38 ++-- .../components/__tests__/ImageDialog.spec.tsx | 190 +++++++++++++++++- 25 files changed, 895 insertions(+), 14 deletions(-) rename packages/pluggableWidgets/file-uploader-web/openspec/{ => changes/archive/2026-06-24-fix-dropzone-messages-and-remove}/.openspec.yaml (100%) rename packages/pluggableWidgets/file-uploader-web/openspec/{ => changes/archive/2026-06-24-fix-dropzone-messages-and-remove}/design.md (100%) rename packages/pluggableWidgets/file-uploader-web/openspec/{ => changes/archive/2026-06-24-fix-dropzone-messages-and-remove}/proposal.md (100%) rename packages/pluggableWidgets/file-uploader-web/openspec/{ => changes/archive/2026-06-24-fix-dropzone-messages-and-remove}/tasks.md (100%) create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/proposal.md create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/specs/file-uploader-action-buttons/spec.md create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/tasks.md create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/config.yaml create mode 100644 packages/pluggableWidgets/file-uploader-web/openspec/specs/file-uploader-action-buttons/spec.md create mode 100644 packages/pluggableWidgets/file-uploader-web/src/components/__tests__/ActionButton.spec.tsx create mode 100644 packages/pluggableWidgets/file-uploader-web/src/components/__tests__/RetryButton.spec.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-01-fix-image-dialog-nested-submit/.openspec.yaml create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-01-fix-image-dialog-nested-submit/design.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-01-fix-image-dialog-nested-submit/proposal.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-01-fix-image-dialog-nested-submit/specs/rich-text-image-dialog/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-01-fix-image-dialog-nested-submit/tasks.md diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index 09a5decaa0..75eca040a5 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where clicking a file action button or the retry button submitted the surrounding form, causing the page to submit or a containing dialog to close unexpectedly. + ### Changed - Since version 2.5.0, removing a file with the default remove button removes the entry from the file list immediately, instead of leaving it in the list greyed out. This matches how removal already worked when custom buttons are configured. This change was missing from the 2.5.0 release notes. diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/.openspec.yaml b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/.openspec.yaml similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/.openspec.yaml rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/.openspec.yaml diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/design.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/design.md similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/design.md rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/design.md diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/proposal.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/proposal.md similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/proposal.md rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/proposal.md diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/tasks.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/tasks.md similarity index 100% rename from packages/pluggableWidgets/file-uploader-web/openspec/tasks.md rename to packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-06-24-fix-dropzone-messages-and-remove/tasks.md diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml new file mode 100644 index 0000000000..b4b3ece784 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md new file mode 100644 index 0000000000..01138661f7 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/openspec/changes/archive/2026-09-01-fix-untyped-action-buttons/design.md @@ -0,0 +1,63 @@ +## Context + +`ActionButton` renders the per-file action buttons in the files list (`.action-button`, e.g. the "add" / "remove" / custom list-action buttons). `RetryButton` renders the retry affordance on a failed upload. Both are plain ` - - + ); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx index 798e26e0f0..2a25511445 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { ReactElement } from "react"; import { EditorContext, ImageDialogConfig } from "../../../EditorContext"; import { ImageDialog } from "../ImageDialog"; @@ -143,3 +143,191 @@ describe("ImageDialog dimensions", () => { expect(screen.getByLabelText("Height (px)")).toHaveValue(200); }); }); + +describe("ImageDialog insertion isolation", () => { + // A widget placed in the image-source content slot may render a + } + }} + > + {() as ReactElement} + + ); + + fireEvent.click(screen.getByRole("button", { name: "Media Library" })); + + return { setImage, onClose, container }; + } + + const selectEntityImage = (container: HTMLElement): void => { + const dialog = container.querySelector(".image-dialog") as HTMLElement; + act(() => { + dialog.dispatchEvent( + new CustomEvent("imageSelected", { + detail: { id: "id-1234-5678", url: "https://example.com/entity.jpg" } + }) + ); + }); + }; + + it("renders no form element", () => { + const { container } = renderWithEmbeddedContent(); + + expect(container.querySelector("form")).toBeNull(); + }); + + it("does not insert or close when an untyped embedded button is clicked", () => { + const { setImage, onClose } = renderWithEmbeddedContent(); + + fireEvent.click(screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL })); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not insert or close when an untyped embedded button is clicked after an image is selected", () => { + const { setImage, onClose, container } = renderWithEmbeddedContent(); + const embedded = screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL }); + + fireEvent.click(embedded); + selectEntityImage(container); + fireEvent.click(embedded); + fireEvent.click(embedded); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not insert when Enter is pressed inside embedded content", () => { + const { setImage, onClose, container } = renderWithEmbeddedContent(); + selectEntityImage(container); + + fireEvent.keyDown(screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL }), { key: "Enter" }); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("inserts the selected entity image when the Insert button is activated", () => { + const { setImage, onClose, container } = renderWithEmbeddedContent(); + selectEntityImage(container); + + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + + expect(setImage).toHaveBeenCalledTimes(1); + const attrs = setImage.mock.calls[0][0]; + expect(attrs.src).toBe("https://example.com/entity.jpg"); + expect(attrs.dataEntity).toBe(true); + expect(attrs.dataEntityId).toBe("id-1234-5678"); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe("ImageDialog Enter to insert", () => { + function renderWithEditor(): { setImage: jest.Mock; onClose: jest.Mock } { + const setImage = jest.fn(); + const onClose = jest.fn(); + const chain = { + focus: () => chain, + setImage: (attrs: Record) => { + setImage(attrs); + return chain; + }, + run: () => true + }; + const editor = { chain: () => chain } as any; + + render( + undefined, + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + {() as ReactElement} + + ); + + return { setImage, onClose }; + } + + const fillUrl = (): void => { + fireEvent.change(screen.getByLabelText("Image URL"), { + target: { value: "https://example.com/image.jpg" } + }); + }; + + it.each(["Image URL", "Alt text (optional)", "Title (optional)", "Width (px)"])( + "inserts on Enter in the %s input", + label => { + const { setImage, onClose } = renderWithEditor(); + fillUrl(); + + fireEvent.keyDown(screen.getByLabelText(label), { key: "Enter" }); + + expect(setImage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + } + ); + + it("inserts on Enter in the Height input", () => { + const { setImage, onClose } = renderWithEditor(); + fillUrl(); + fireEvent.click(screen.getByLabelText("Maintain aspect ratio")); // enables Height + + fireEvent.keyDown(screen.getByLabelText("Height (px)"), { key: "Enter" }); + + expect(setImage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not insert on Enter while the image source is empty", () => { + const { setImage, onClose } = renderWithEditor(); + + fireEvent.keyDown(screen.getByLabelText("Image URL"), { key: "Enter" }); + + expect(setImage).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("ignores keys other than Enter", () => { + const { setImage } = renderWithEditor(); + fillUrl(); + + fireEvent.keyDown(screen.getByLabelText("Image URL"), { key: "a" }); + + expect(setImage).not.toHaveBeenCalled(); + }); +}); From e6a4a7659604aac5ed3881cef5a4757f79d0b78e Mon Sep 17 00:00:00 2001 From: gjulivan Date: Wed, 2 Sep 2026 15:30:56 +0200 Subject: [PATCH 2/6] fix(rich-text): update dialog style --- .../rich-text-web/CHANGELOG.md | 6 + .../rich-text-web/e2e/RichText.spec.js | 76 +++++++- .../.openspec.yaml | 2 + .../design.md | 130 +++++++++++++ .../proposal.md | 96 +++++++++ .../rich-text-dialog-presentation/spec.md | 176 +++++++++++++++++ .../specs/rich-text-image-dialog/spec.md | 22 +++ .../specs/rich-text-link-bubble-menu/spec.md | 27 +++ .../tasks.md | 165 ++++++++++++++++ .../rich-text-dialog-presentation/spec.md | 182 ++++++++++++++++++ .../specs/rich-text-image-dialog/spec.md | 21 ++ .../specs/rich-text-link-bubble-menu/spec.md | 17 +- .../rich-text-web/src/RichText.xml | 8 + .../src/__tests__/HelpButton.spec.tsx | 8 +- .../src/__tests__/RichText.spec.tsx | 1 + .../rich-text-web/src/components/Editor.tsx | 4 +- .../src/components/EditorContext.tsx | 11 +- .../src/components/EditorWrapper.tsx | 2 + .../toolbars/components/ConfirmDialog.tsx | 46 ++--- .../toolbars/components/Dialog.scss | 81 ++++++-- .../toolbars/components/DialogShell.tsx | 167 ++++++++++++++++ .../toolbars/components/HelpDialog.tsx | 90 +++------ .../toolbars/components/ImageDialog.tsx | 76 ++++---- .../toolbars/components/LinkDialog.tsx | 55 +++--- .../toolbars/components/VideoDialog.tsx | 57 +++--- .../__tests__/DialogPresentation.spec.tsx | 125 ++++++++++++ .../components/__tests__/DialogShell.spec.tsx | 115 +++++++++++ .../components/__tests__/ImageDialog.spec.tsx | 90 +++++++-- .../__tests__/ToolbarDefaultButton.spec.tsx | 2 + .../components/toolbars/hooks/useDropdown.ts | 47 ++++- .../rich-text-web/src/ui/TableStyle.scss | 2 +- .../rich-text-web/typings/RichTextProps.d.ts | 4 + 32 files changed, 1679 insertions(+), 232 deletions(-) create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/.openspec.yaml create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/design.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/proposal.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-dialog-presentation/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-image-dialog/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-link-bubble-menu/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/tasks.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-dialog-presentation/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 6a6cd24599..c035d940ad 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -6,10 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- We added a new "Dialog style" setting to choose how the editor's dialogs are shown: "Inline" keeps them anchored to the toolbar button, while "Focused" centers them on the page over a dimmed background. + ### Fixed - We fixed an issue where clicking a button inside the image dialog's Media Library content inserted the image and closed the dialog without pressing Insert. +- We fixed an issue where a dialog was cut off when its content was tall or when the editor was placed inside a container that clips its content, such as a pop-up page. Dialogs now stay fully on screen and scroll inside themselves, keeping the Cancel and Insert buttons always visible. + ## [5.0.0] - 2026-08-14 ### Added diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js index 056b4a462b..899cdfedb5 100644 --- a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js +++ b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js @@ -34,9 +34,8 @@ test.describe("RichText", () => { await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`bottomToolbarAdvancedMode.png`); await page.click('.mx-name-richText1 .tiptap-toolbar button[title="Insert Image"]'); - await expect(page.locator(".mx-name-richText1 .toolbar-dialog.image-dialog").first()).toHaveScreenshot( - `insertImageDialog.png` - ); + // Dialogs render in a portal on , so they are not descendants of the widget. + await expect(page.locator(".toolbar-dialog.image-dialog").first()).toHaveScreenshot(`insertImageDialog.png`); }); test("compares with a screenshot baseline and checks if toolbar advanced mode are rendered as expected", async ({ @@ -273,7 +272,8 @@ test.describe("RichText", () => { await page.keyboard.press("Backspace"); await widget.locator('.tiptap-toolbar button[title="Insert YouTube Video"]').click(); - const dialog = widget.locator(".toolbar-dialog.video-dialog").first(); + // Portalled to , so scope the dialog to the page rather than the widget. + const dialog = page.locator(".toolbar-dialog.video-dialog").first(); await expect(dialog).toBeVisible(); await dialog.locator("#video-url").fill("https://www.youtube.com/watch?v=3k66DQuU31A"); @@ -287,4 +287,72 @@ test.describe("RichText", () => { await expect(iframe).toHaveAttribute("title", /.+/); await expect(iframe).toHaveAttribute("allow", /encrypted-media/); }); + + test("a dialog taller than the viewport scrolls internally and keeps its buttons usable", async ({ page }) => { + await page.goto("/p/advanced"); + await waitForMendixApp(page); + + // Short viewport so the image dialog cannot fit: the same situation a Media Library tab + // full of thumbnails creates, without needing that many images. + await page.setViewportSize({ width: 1024, height: 420 }); + + const widget = page.locator(".mx-name-richText1"); + const editor = widget.locator(".tiptap"); + await editor.click(); + await editor.selectText(); + await page.keyboard.press("Backspace"); + + await widget.locator('.tiptap-toolbar button[title="Insert Image"]').click(); + + // Portalled to , so it is not a descendant of the widget. + const dialog = page.locator(".toolbar-dialog.image-dialog").first(); + await expect(dialog).toBeVisible(); + + // The dialog box stays inside the viewport instead of running off the bottom edge. + // Geometry is the behaviour under test here, so there is no CSS assertion to use instead. + const box = await dialog.boundingBox(); + const viewport = page.viewportSize(); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height); + + // The overflow moved into the dialog's own scroll region. + const scrolls = await dialog + .locator(".dialog-scroll") + .evaluate(element => element.scrollHeight > element.clientHeight); + expect(scrolls).toBe(true); + + // The action buttons sit outside that scroll region, so they stay reachable: this is what + // clipping used to break. + await dialog.locator("#image-src").fill("https://www.mendix.com/logo.png"); + const insert = dialog.getByRole("button", { name: "Insert", exact: true }); + await expect(insert).toBeVisible(); + await insert.click(); + + await expect(editor.locator("img")).toHaveAttribute("src", "https://www.mendix.com/logo.png"); + }); + + test("a dialog opened inside a popup page is not clipped by the popup", async ({ page }) => { + await page.goto("/"); + await waitForMendixApp(page); + + await page.click(".mx-navbar-item [title='Demo']"); + await page.click('.mx-name-customWidget1 .tiptap-toolbar button[title="Insert YouTube Video"]'); + + // A popup page centres itself with a transform, which makes it the containing block for any + // fixed-positioned descendant. The dialog escapes it by rendering in a body-level portal. + const dialog = page.locator(".toolbar-dialog.video-dialog").first(); + await expect(dialog).toBeVisible(); + await expect(page.locator(".mx-name-customWidget1 .toolbar-dialog.video-dialog")).toHaveCount(0); + + const box = await dialog.boundingBox(); + const viewport = page.viewportSize(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height); + + // Still on top of the popup and interactive. + await dialog.locator("#video-url").fill("https://www.youtube.com/watch?v=3k66DQuU31A"); + await expect(dialog.locator("#video-url")).toHaveValue(/3k66DQuU31A/); + }); }); diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/.openspec.yaml new file mode 100644 index 0000000000..b4b3ece784 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/design.md new file mode 100644 index 0000000000..ffca443ec4 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/design.md @@ -0,0 +1,130 @@ +## Context + +Five dialog surfaces exist today, on three different hand-rolled shells: + +| Component | Shell today | Portalled | Height bounded | +| --------------- | ----------------------------------------------------------- | --------- | --------------------------- | +| `ImageDialog` | `useDropdown` + `
` | no | no | +| `VideoDialog` | same | no | no | +| `LinkDialog` | same | no | no | +| `HelpDialog` | `.confirm-dialog-overlay` + own outside-click/Escape effect | no | `.help-dialog-content` only | +| `ConfirmDialog` | `.confirm-dialog-overlay` + own outside-click effect | no | no | + +The three insert dialogs share an identical wrapper: + +```tsx +
+
+ …body… +
+
+``` + +All positioning concerns live in that wrapper. Nothing in the bodies — form state, tiptap commands, tabs, dropzone, entity listener — depends on how the dialog is positioned. That is what makes a shared shell cheap. + +### Why the v4 dialogs cannot be ported + +v4.12 was Quill (`quill: ^2.0.3`); v5 is tiptap. The v4 `ImageDialog` / `LinkDialog` / `VideoDialog` bodies call Quill APIs, and v5's bodies have since gained features v4 never had (embed-code tab with generic-embed parsing and platform detection, Media Library tab, react-dropzone upload, aspect-ratio lock). Restoring v4 components would drop those features and fork the logic. + +What _is_ portable is v4's shell, which used the same `@floating-ui/react` already in `package.json` — just its modal-mode components: + +``` +FloatingPortal + ├─ FloatingOverlay lockScroll .widget-rich-text-modal-overlay .mx-underlay + └─ FloatingFocusManager + └─ .modal-dialog.mx-window.mx-window-active (centred via transform: translate(-50%, -50%)) +``` + +So "focused" mode costs no new dependency. + +## Goals / Non-Goals + +**Goals** + +- A tall dialog is always fully usable: bounded height, internal scroll, action buttons pinned. +- No ancestor styling can clip a dialog. +- One shell, five dialogs, two modes. +- Default behaviour visually unchanged for existing apps. + +**Non-Goals** + +- Restoring v4's Atlas markup (`mx-window`, `form-horizontal`, `col-sm-*`, `mx-button`). Focused mode reuses v5's existing dialog markup, centred under an overlay. Faithful v4 markup would mean dual render paths per dialog for a cosmetic difference. +- Portalling or resizing the toolbar popovers. +- Changing any dialog's fields, validation or tiptap commands. + +## Decisions + +### 1. Fixes A and B are unconditional, not gated behind `dialogStyle` + +The clipping is a bug, and `Inline` is the default. Gating the fix would leave the default configuration broken and require every affected app to change a setting to recover. So the portal and the height bound apply in both modes; `dialogStyle` only chooses presentation. + +### 2. Inline mode shrinks to fit — it does not escalate to focused + +`size()` middleware supplies `availableHeight`; the scroll region's `max-height` is set from it. A dialog with 60 Media Library images renders as tall as the space below the trigger permits and scrolls inside. It does **not** silently become a modal when it does not fit, because a mode that changes shape based on scroll position is unpredictable and untestable. + +Middleware order matters. `size()` runs last so it measures after `flip()` and `shift()` have settled placement: + +```ts +middleware: [offset(4), flip(), shift({ padding: 8 }), size({ padding: 8, apply({ availableHeight, elements }) { … } })]; +``` + +A floor is needed: with the trigger near the viewport bottom, `availableHeight` can be a few pixels. `flip()` runs first and normally moves the dialog above the trigger, but in a short viewport both sides are cramped. Apply `max-height: max(availableHeight, MIN_DIALOG_HEIGHT)` with `MIN_DIALOG_HEIGHT` around `200px`, so the dialog is never smaller than a usable scroller even if it then overhangs the viewport slightly. + +### 3. The scroll region is a new element, not `max-height` on existing regions + +v4 put `max-height` on one region (`.image-dialog-upload`). That bounds one part while the sum of title + tabs + that region + alt/title/width/height fields + actions can still exceed the viewport. Instead: + +``` +.toolbar-dialog display: flex; flex-direction: column; max-height: + ├─ h3 flex-shrink: 0 ← pinned + ├─ .dialog-scroll flex: 1; overflow-y: auto; min-height: 0 ← everything else + │ tabs, tab-content, image-dialog-entity, fields, previews, errors + └─ .dialog-actions flex-shrink: 0 ← pinned; Insert/Cancel always visible +``` + +`min-height: 0` on the scroll child is required — a flex item's default `min-height: auto` refuses to shrink below content height and would defeat the cap. + +Cost: each of the three insert dialogs wraps its middle content in one `
`. `.dialog-tabs` moves inside the scroll region (it scrolls with the content) — the alternative, pinning tabs as a third fixed row, is a nicer UX but a larger markup change; revisit if reviewers want it. + +### 4. Focused mode reuses v5 markup + +Same `.toolbar-dialog` box, wrapped in `FloatingOverlay lockScroll` + `FloatingFocusManager`, centred, `max-height: 70vh` (v4's `--max-dialog-height` default). Users get "modal over dimmed background with a focus trap", which is what the preference is actually about, without a per-dialog markup fork. + +### 5. `dialogStyle` reaches dialogs via `EditorContext` + +`EditorContext` already carries `imageConfig` from `Editor.tsx:338` to `ImageDialog` specifically to avoid drilling props through `Toolbar`. `dialogStyle` follows the same route. `LinkDialog` rendered from `LinkBubbleMenu.tsx:70` is inside the same provider, so it gets the value with no extra plumbing — which is why "focused applies to bubble-menu link editing too" costs nothing. + +Alternative considered: `ToolbarContext`, which already holds `activeDropdown`. Rejected — `LinkBubbleMenu` is not under `ToolbarContext`, so `LinkDialog`'s second entry point would miss the value. + +### 6. `useDropdown` gains `size()` as opt-in, not as default + +`useDropdown` is shared with `ColorPicker`, `ToolbarDropdown`, `ToolbarSplitButton`, `TableGridSelector` and `ConfigurationDropdown`. Some already self-bound (`Toolbar.scss:128`: `max-height: 300px; overflow-y: auto`). Adding `size()` unconditionally would silently re-cap all of them. New option, off by default; only `DialogShell` inline mode turns it on. + +### 7. One z-index for all dialogs: `10000` + +Today: inline dialogs `1000`, `.confirm-dialog-overlay` `10000`, v4 used `105`/`106` to clear Atlas's `mx-underlay`. Once portalled to `document.body`, a dialog must clear whatever Atlas modal layer the widget sits inside. `10000` is already shipping in `ConfirmDialog` without reported stacking problems, so it is the proven value. Overlay `10000`, dialog `10001`. + +### 8. Focus and selection + +`FloatingFocusManager` moves focus into the dialog, blurring ProseMirror. tiptap's stored selection survives the blur, and every insert path already runs `.chain().focus()…`, which restores it before applying the command. This is the one behaviour that could regress silently, so it gets an explicit test in both modes rather than being assumed — v4 relied on the same assumption under Quill and worked. + +Inline mode keeps `useDropdown`'s existing mousedown-outside dismissal. Focused mode uses `useDismiss` with `outsidePressEvent: "mousedown"` plus Escape, matching v4. `HelpDialog`'s current hand-rolled Escape handler uses capture phase and `stopPropagation()` so the fullscreen/editor Escape handlers do not also fire; `DialogShell` must preserve that, or fullscreen mode will exit at the same time the dialog closes. + +### 9. XML property placement + +`General > General`, after `enableStatusBar`. It is an end-user-visible presentation preference, so it belongs where it is discoverable. `Advanced > Advanced` (next to `styleDataFormat`) groups rendering-format switches and would bury it. + +Note: `inline` already exists as an enumeration key on `styleDataFormat` (`inline | class`). No technical conflict — keys are scoped per property — but the two mean unrelated things, so descriptions should be unambiguous. + +## Risks / Trade-offs + +- **Portalling breaks CSS scoping if any dialog rule is nested under `.widget-rich-text`.** Checked: `.toolbar-dialog`, `.help-dialog`, `.confirm-dialog-overlay`, `.video-dialog`, `.image-dialog` are all top-level selectors in `Dialog.scss`, so they survive the portal. The custom properties they read (`--brand-primary`, `--border-color-default`, `--spacing-medium`) come from the Atlas theme's global `:root`, not from the widget, so they survive too. To be safe, `DialogShell` still puts `widget-rich-text` on its portalled root — the same thing v4's `classNames("Dialog mx-layoutgrid widget-rich-text", …)` was doing. +- **Unit tests query a portal.** RTL's `screen` searches `document.body`, so existing `ImageDialog.spec.tsx` queries keep working; any query scoped to a `render()` container result would not. Verify rather than assume. +- **Snapshots.** `src/__tests__/__snapshots__` and `e2e/RichText.spec.js-snapshots` may shift. Dialogs are closed in the widget-level snapshots, so the expected blast radius is small. +- **Scroll lock in focused mode.** `FloatingOverlay lockScroll` locks the page. If the widget is inside an Atlas modal that also locks scroll, unlock ordering on close needs a check. + +## Open Questions + +- Should `.dialog-tabs` be pinned above the scroll region rather than scrolling with it? Better UX for the Media Library case; larger markup change. Deferred, not blocked. +- Should the toolbar popovers get the same portal treatment in a follow-up? They share defect B (a transformed ancestor clips them), just less visibly because they are short. +- Pre-existing, out of scope: `src/ui/RichText.scss:11` nests `:root` inside `.widget-rich-text`, compiling to `.widget-rich-text :root`, which matches nothing. Every `--white` / `--gray-*` / `--shadow` declaration in that block is dead and all `var()` reads of them silently use their fallbacks. Worth its own change. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/proposal.md new file mode 100644 index 0000000000..2d79e6f2e1 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/proposal.md @@ -0,0 +1,96 @@ +## Why + +Rich Text toolbar dialogs get clipped when their content is tall. The reported case: the image dialog's Media Library tab renders `imageSourceContent` — an app-developer-configured list of images. With enough images the dialog grows past the viewport, and the Insert / Cancel buttons at the bottom of `.dialog-actions` become unreachable. The user cannot complete the action at all. + +Two independent defects produce this: + +**A. Content height is unbounded.** No dialog region caps its height or scrolls. `Dialog.scss` has: + +```scss +.image-dialog-entity { + margin: 0 var(--spacing-medium, 16px); + min-height: 100px; // no max-height, no overflow +} +``` + +v4.12 bounded the equivalent region (`.image-dialog-upload { max-height: var(--max-dialog-height, 70vh); overflow-y: auto; }`); v5 dropped it. `.help-dialog-content` still uses that pattern (`max-height: 60vh; overflow-y: auto`), so the fix is established in this codebase — it just was never applied to the insert dialogs. + +**B. Dialogs cannot escape their container.** `useDropdown` positions with `strategy: "fixed"` and `offset/flip/shift`, but the dialog is rendered as a DOM descendant of the toolbar and there is **no portal** and **no `size()` middleware**: + +``` +.widget-rich-text.form-control { overflow: hidden } ← src/ui/RichText.scss:40 + └─ Toolbar + └─
← Dialog.tsx:20 + └─ dialog (position: fixed) +``` + +`position: fixed` escapes ancestor `overflow: hidden` only while no ancestor establishes a containing block. Any ancestor with `transform`, `filter`, `will-change` or `contain` — including a Mendix popup page, whose `.mx-window` is transform-centred — demotes the dialog to that ancestor's coordinate space, where `overflow: hidden` clips it. `shift({ padding: 8 })` can only translate the dialog; when its height exceeds the viewport, both edges overflow and there is no scroll container, so `flip()` cannot help either. + +`.confirm-dialog-overlay` (used by `ConfirmDialog` and `HelpDialog`) is `position: fixed` and also not portalled, so it has defect B as well. + +Separately, some users prefer v4's presentation — a centred modal over a dimmed, scroll-locked page — over v5's button-anchored popover. That is a preference, not the bug, so it ships as an opt-in property rather than as the fix. + +## What Changes + +### Unconditional fixes (both dialog styles, all dialogs) + +- Render every dialog through a single shared `DialogShell` that portals to `document.body`. No ancestor `overflow`, `transform` or `contain` can clip a dialog. +- Bound dialog height to the viewport and scroll internally. `.toolbar-dialog` becomes a flex column: the title and `.dialog-actions` stay pinned, a new scroll region between them takes the overflow. Insert / Cancel are always reachable. +- Inline dialogs **shrink to fit**: `size()` middleware feeds `availableHeight` into the scroll region's `max-height`, so the dialog is as tall as the space below (or above) the trigger allows, and scrolls beyond that. No auto-escalation to a modal. +- Unify dialog stacking on the value `ConfirmDialog` already ships with (`z-index: 10000`), replacing today's mix of `1000` (inline dialogs) and `10000` (overlay dialogs). + +### New property + +- `dialogStyle` enumeration in `RichText.xml`, `Inline | Focused`, default `Inline` (current behaviour — non-breaking). + - **Inline**: anchored to the trigger, no overlay, outside click closes. Today's behaviour plus the fixes above. + - **Focused**: dimmed scroll-locked overlay, centred dialog, focus trap, Escape closes, `aria-modal`. Reinstates the v4.12 presentation. +- Applies to the three content-insert dialogs: Image, Video, Link. +- `HelpDialog` and `ConfirmDialog` are always focused and ignore the property; they move onto `DialogShell` purely to inherit the portal and sizing fixes. + +### Explicitly not changed + +- Toolbar popovers — `ColorPicker`, `ToolbarDropdown`, `ToolbarSplitButton`, `TableGridSelector`, `ConfigurationDropdown` — are menus, not dialogs. They keep `useDropdown`'s current behaviour and are not portalled by this change. +- `LinkBubbleMenu` itself stays an anchored floating bubble. Only the `LinkDialog` it opens follows `dialogStyle`. +- `CodeView` is an in-editor view, not a dialog. + +## Capabilities + +### New Capabilities + +- `rich-text-dialog-presentation`: how Rich Text dialogs are positioned, sized, layered and dismissed; the `dialogStyle` property; which dialogs honour it. + +### Modified Capabilities + +- `rich-text-image-dialog`: the Media Library region and the dialog body scroll instead of growing past the viewport. +- `rich-text-link-bubble-menu`: the existing "Dialog anchored to the link" scenario becomes conditional on `dialogStyle` being `Inline`; under `Focused` the dialog is centred. + +## Impact + +**Files affected**: + +- `src/RichText.xml` — new `dialogStyle` enumeration property under `General > General`. +- `typings/RichTextProps.d.ts` — regenerated from XML (build output; not hand-edited). +- `src/components/toolbars/components/DialogShell.tsx` — **new**. Portal, mode switch, sizing, overlay, focus trap. +- `src/components/toolbars/hooks/useDropdown.ts` — optional `size()` limiter, opt-in so the popovers are unaffected. +- `src/components/toolbars/components/ImageDialog.tsx`, `VideoDialog.tsx`, `LinkDialog.tsx` — replace the `useDropdown` + positioning wrapper with `DialogShell`; wrap the middle content in the scroll region. No change to form logic or tiptap commands. +- `src/components/toolbars/components/HelpDialog.tsx`, `ConfirmDialog.tsx` — replace the hand-rolled overlay / outside-click / Escape code with `DialogShell` in focused mode. +- `src/components/toolbars/components/Dialog.scss` — flex-column dialog, scroll region, overlay, centred modal, unified z-index. +- `src/components/EditorContext.tsx`, `src/components/Editor.tsx` — carry `dialogStyle` to the dialogs via the existing editor context (same route as `imageConfig`). +- `CHANGELOG.md` — one entry for the clipping fix, one for the new property. + +**User-facing changes**: + +- A tall dialog (large Media Library, long embed code) now scrolls internally; Insert and Cancel stay visible. +- Dialogs no longer get cut off when the widget sits inside a popup page or an `overflow: hidden` container. +- New "Dialog style" property. Existing apps keep the current look with no action. + +**Testing scope**: + +- Inline mode: a dialog whose content exceeds the space below the trigger caps its height, scrolls, and keeps the action buttons visible. +- Inline mode: outside click still closes; no overlay is rendered. +- Focused mode: overlay rendered, page scroll locked, dialog centred, Escape closes, Tab cycles within the dialog, `aria-modal="true"`. +- Both modes: dialog markup is not a descendant of `.widget-rich-text`'s widget node (portalled). +- Selection is preserved: caret mid-paragraph, open dialog, insert — content lands at the caret in both modes. +- Help and Confirm dialogs stay focused with `dialogStyle` set to `Inline`. +- Popovers (colour picker, dropdowns, table grid) keep anchored, non-portalled behaviour. +- E2E: one spec per mode covering the Media Library scroll case. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-dialog-presentation/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-dialog-presentation/spec.md new file mode 100644 index 0000000000..08cc047557 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-dialog-presentation/spec.md @@ -0,0 +1,176 @@ +## ADDED Requirements + +### Requirement: Dialog style configuration property + +The Rich Text widget SHALL expose a `dialogStyle` enumeration property with the values `inline` and `focused`, defaulting to `inline`. The property SHALL select the presentation of the Image, Video and Link dialogs. Existing apps that do not set the property SHALL keep the anchored (inline) presentation. + +#### Scenario: Default configuration + +- **WHEN** a widget instance does not set `dialogStyle` +- **THEN** the effective value is `inline` +- **AND** the Image, Video and Link dialogs are presented anchored to their trigger + +#### Scenario: Focused style selected + +- **WHEN** `dialogStyle` is set to `focused` +- **THEN** the Image, Video and Link dialogs are presented as centred modal dialogs over a dimmed overlay + +#### Scenario: Property reaches dialogs opened outside the toolbar + +- **WHEN** `dialogStyle` is set to `focused` and the user activates Edit on the link bubble menu +- **THEN** the link dialog is presented as a centred modal dialog rather than anchored to the link + +### Requirement: Dialogs render outside the widget's DOM subtree + +Every Rich Text dialog — Image, Video, Link, keyboard-shortcuts help, and confirmation — SHALL be rendered through a portal into the document body rather than as a DOM descendant of the widget node. This SHALL hold in both dialog styles. No ancestor `overflow`, `transform`, `filter`, `will-change` or `contain` declaration SHALL be able to clip a dialog. + +#### Scenario: Widget container clips its own content + +- **WHEN** the widget node has `overflow: hidden` and the user opens a dialog whose content extends beyond the widget's bounds +- **THEN** the dialog is rendered in full and is not clipped by the widget node + +#### Scenario: Transformed ancestor establishes a containing block + +- **WHEN** the widget is placed inside an ancestor that establishes a containing block through a transform, such as a Mendix popup page, and the user opens a dialog +- **THEN** the dialog is rendered in full and is not clipped by that ancestor + +#### Scenario: Dialog is not a descendant of the widget node + +- **WHEN** any dialog is open in either dialog style +- **THEN** the dialog element is not a descendant of the widget's rendered root element + +### Requirement: Dialog height is bounded with internal scrolling + +Every Rich Text dialog SHALL bound its own height so that it fits the space available to it, and SHALL scroll its content internally rather than growing past that bound. The dialog title and the dialog action controls SHALL remain visible while the content between them scrolls. The dialog SHALL NOT render at a height that places its action controls outside the viewport. + +#### Scenario: Media Library renders many images + +- **WHEN** the image dialog's Media Library tab renders embedded content taller than the space available to the dialog +- **THEN** the dialog's height is capped +- **AND** the embedded content area scrolls +- **AND** the Insert and Cancel controls remain visible without scrolling the dialog + +#### Scenario: Long embed code in the video dialog + +- **WHEN** the video dialog's embed-code tab, its detection status and its warning together exceed the space available to the dialog +- **THEN** the dialog content scrolls and the Insert and Cancel controls remain visible + +#### Scenario: Content fits + +- **WHEN** a dialog's content is shorter than the space available to it +- **THEN** no internal scrollbar is shown and the dialog is sized to its content + +### Requirement: Inline dialog style + +In the `inline` dialog style, a dialog SHALL be positioned relative to the element that opened it, SHALL flip and shift to stay within the viewport, and SHALL close when the user presses outside both the dialog and its trigger. The inline style SHALL NOT render an overlay, SHALL NOT dim the page, SHALL NOT lock page scrolling, and SHALL NOT trap focus. An inline dialog SHALL shrink to fit the space available at its resolved placement and scroll internally; it SHALL NOT convert itself into a centred modal when space is short. + +#### Scenario: Anchored to its trigger + +- **WHEN** `dialogStyle` is `inline` and the user activates the Insert Image toolbar button +- **THEN** the dialog is positioned relative to that button + +#### Scenario: Shrink to fit available space + +- **WHEN** `dialogStyle` is `inline` and the space at the dialog's resolved placement is smaller than its content height +- **THEN** the dialog's scrollable region is capped to the available height +- **AND** the dialog remains anchored to its trigger +- **AND** no overlay is rendered + +#### Scenario: No overlay rendered + +- **WHEN** an inline dialog is open +- **THEN** no dimmed overlay element is present +- **AND** page scrolling is not locked + +#### Scenario: Outside press closes + +- **WHEN** an inline dialog is open and the user presses outside both the dialog and its trigger +- **THEN** the dialog closes + +### Requirement: Focused dialog style + +In the `focused` dialog style, a dialog SHALL be rendered centred in the viewport above a dimmed overlay that locks page scrolling. The dialog SHALL expose `role="dialog"` with `aria-modal="true"`, SHALL move focus into itself on open, SHALL confine Tab and Shift+Tab navigation to its own focusable elements while open, SHALL close on Escape, and SHALL close when the user presses the overlay. Escape handling SHALL be scoped to the dialog so that editor-level Escape handlers, including the fullscreen exit handler, do not also fire. + +#### Scenario: Overlay and centring + +- **WHEN** `dialogStyle` is `focused` and the user opens the Image dialog +- **THEN** a dimmed overlay is rendered behind the dialog +- **AND** the dialog is centred in the viewport +- **AND** page scrolling is locked while the dialog is open + +#### Scenario: Focus trap + +- **WHEN** a focused dialog is open and the user presses Tab from its last focusable element +- **THEN** focus moves to the dialog's first focusable element and does not leave the dialog + +#### Scenario: Escape closes only the dialog + +- **WHEN** a focused dialog is open while the editor is in fullscreen mode and the user presses Escape +- **THEN** the dialog closes +- **AND** the editor remains in fullscreen mode + +#### Scenario: Overlay press closes + +- **WHEN** a focused dialog is open and the user presses the overlay outside the dialog +- **THEN** the dialog closes + +#### Scenario: Accessible modal semantics + +- **WHEN** a focused dialog is open +- **THEN** the dialog element exposes `role="dialog"` and `aria-modal="true"` and is labelled by its title + +### Requirement: Help and confirmation dialogs are always focused + +The keyboard-shortcuts help dialog and the confirmation dialog SHALL always use the focused presentation regardless of the `dialogStyle` value. They SHALL obtain the portal, height bounding, focus trap, Escape handling and stacking behaviour from the shared dialog presentation rather than from their own implementations. + +#### Scenario: Help dialog under the inline style + +- **WHEN** `dialogStyle` is `inline` and the user opens the keyboard-shortcuts help dialog +- **THEN** the dialog is rendered centred above a dimmed overlay with a focus trap + +#### Scenario: Confirmation dialog under the inline style + +- **WHEN** `dialogStyle` is `inline` and a confirmation dialog is shown +- **THEN** the dialog is rendered centred above a dimmed overlay with a focus trap + +### Requirement: Consistent dialog stacking + +All Rich Text dialogs and dialog overlays SHALL use a single stacking scale that places them above Atlas modal layers, so a dialog opened from a widget inside a popup page is not obscured by that page. Dialog overlays SHALL sit directly beneath their dialog. + +#### Scenario: Dialog above a host popup layer + +- **WHEN** the widget is placed inside a Mendix popup page and the user opens a dialog +- **THEN** the dialog is painted above the popup and its underlay + +#### Scenario: Overlay beneath its dialog + +- **WHEN** a focused dialog is open +- **THEN** the overlay is painted beneath the dialog and above the page + +### Requirement: Editor selection preserved across dialog interaction + +Opening a dialog, interacting with its controls, and confirming it SHALL insert content at the selection that was active in the editor when the dialog opened. This SHALL hold in both dialog styles, including when the focused style moves focus out of the editor into a focus trap. + +#### Scenario: Insert at a caret mid-paragraph + +- **WHEN** the caret is inside a paragraph, the user opens the Image dialog, enters a URL and confirms +- **THEN** the image is inserted at that caret position + +#### Scenario: Insert over a selection in focused style + +- **WHEN** `dialogStyle` is `focused`, text is selected, and the user opens the Link dialog and confirms +- **THEN** the link is applied to the previously selected text + +### Requirement: Toolbar popovers keep anchored presentation + +Toolbar popover controls — colour pickers, toolbar dropdowns, split-button menus, the table grid selector and configuration dropdowns — are not dialogs. They SHALL keep their anchored, non-portalled presentation and SHALL NOT be affected by `dialogStyle` or by the dialog height-bounding behaviour. + +#### Scenario: Colour picker under the focused style + +- **WHEN** `dialogStyle` is `focused` and the user opens the text colour picker +- **THEN** the picker is presented anchored to its toolbar button with no overlay + +#### Scenario: Table grid selector unchanged + +- **WHEN** `dialogStyle` is `focused` and the user opens the insert-table grid selector +- **THEN** the selector is presented anchored to its toolbar button with no overlay diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-image-dialog/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-image-dialog/spec.md new file mode 100644 index 0000000000..d06b3a50f4 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-image-dialog/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Image dialog content scrolls instead of overflowing + +The Rich Text image dialog SHALL bound its height and scroll its content internally, so that embedded Media Library content of any height cannot push the Insert and Cancel controls out of reach. The Media Library region SHALL scroll within the dialog's scrollable area rather than expanding the dialog past the space available to it. This SHALL hold in both dialog styles. + +#### Scenario: Media Library with many images + +- **WHEN** the Media Library tab is active and `imageSourceContent` renders a list taller than the space available to the dialog +- **THEN** the dialog height is capped to the available space +- **AND** the Media Library region scrolls +- **AND** the Insert and Cancel controls remain visible without scrolling the dialog + +#### Scenario: Alt text and dimension fields remain reachable + +- **WHEN** the Media Library tab is active with tall embedded content +- **THEN** the Alt text, Title, Width and Height inputs are reachable by scrolling the dialog's content region + +#### Scenario: Short embedded content + +- **WHEN** the Media Library tab is active and `imageSourceContent` is shorter than the space available to the dialog +- **THEN** the dialog is sized to its content and shows no internal scrollbar diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-link-bubble-menu/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-link-bubble-menu/spec.md new file mode 100644 index 0000000000..945f4daac4 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/specs/rich-text-link-bubble-menu/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: Edit an existing link + +The Edit action SHALL select the entire link range before opening the link dialog, so the dialog is prefilled with the link's current attributes and updates the existing link in place rather than inserting a duplicate. The dialog opened by the Edit action SHALL follow the widget's `dialogStyle` configuration: anchored to the link's DOM element under the `inline` style, centred above a dimmed overlay under the `focused` style. The bubble menu itself SHALL remain an anchored floating layer in both styles. + +#### Scenario: Edit from a bare caret inside a link + +- **WHEN** the caret is inside a link with no text selected and the user activates Edit +- **THEN** the full link range is selected and the link dialog opens prefilled with the current URL, text, title, and target +- **AND** submitting the dialog updates the existing link without duplicating its text + +#### Scenario: Dialog anchored to the link under the inline style + +- **WHEN** `dialogStyle` is `inline` and the Edit action opens the link dialog +- **THEN** the dialog is positioned relative to the link's DOM element and remains positioned there while editing + +#### Scenario: Dialog centred under the focused style + +- **WHEN** `dialogStyle` is `focused` and the Edit action opens the link dialog +- **THEN** the dialog is centred in the viewport above a dimmed overlay +- **AND** the dialog is not positioned relative to the link's DOM element + +#### Scenario: Bubble menu presentation is unchanged + +- **WHEN** the caret is inside a link and `dialogStyle` is `focused` +- **THEN** the bubble menu is still presented anchored to the link, without an overlay diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/tasks.md new file mode 100644 index 0000000000..b51bf42fda --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-02-rich-text-dialog-presentation/tasks.md @@ -0,0 +1,165 @@ +## 1. Add the `dialogStyle` property + +- [x] 1.1 Add a `dialogStyle` enumeration property to `src/RichText.xml` under `General > General`, after `enableStatusBar`, `defaultValue="inline"`, values `inline` ("Inline") and `focused` ("Focused") +- [x] 1.2 Write a description that distinguishes it from `styleDataFormat`'s unrelated `inline` key — name the behaviour ("anchored to the toolbar button" vs "centred modal over a dimmed page"), not just the label +- [x] 1.3 Run a build so `typings/RichTextProps.d.ts` regenerates with `DialogStyleEnum`; do not hand-edit the generated file +- [x] 1.4 Confirm `src/RichText.editorConfig.ts` needs no visibility rule for the new property (it is never conditionally hidden) + +Note on 1.4: `getProperties` only hides properties that become irrelevant under some configuration. `dialogStyle` stays relevant in every configuration — even with `toolbarLocation: "hide"` the link bubble menu can still open the link dialog — so no rule is added. + +## 2. Plumb `dialogStyle` through the editor context + +- [x] 2.1 Add `dialogStyle: DialogStyleEnum` to `EditorContextValue` and to `EditorContextProvider`'s props in `src/components/EditorContext.tsx` +- [x] 2.2 Pass `dialogStyle` from container props at the `EditorContextProvider` call site in `src/components/Editor.tsx` (~line 346), alongside `imageConfig` +- [x] 2.3 Verify `LinkBubbleMenu` renders inside the same provider, so `LinkDialog`'s bubble-menu entry point receives the value with no extra plumbing + +Note on 2.2: the prop needed two more hops than the task assumed — `dialogStyle` added to `EditorProps`' `Pick`, destructured in `Editor` with an `"inline"` default, and forwarded from `EditorWrapper` (which destructures container props explicitly rather than spreading). + +## 3. Add the `size()` limiter to `useDropdown` as an opt-in + +- [x] 3.1 Add an option to `UseDropdownOptions` in `src/components/toolbars/hooks/useDropdown.ts` that enables the `size()` middleware (default off) — `trackAvailableHeight` +- [x] 3.2 Append `size({ padding: 8, apply })` **after** `offset`, `flip` and `shift` so it measures the settled placement +- [x] 3.3 In `apply`, expose the resolved available height to the caller (return it from the hook, or set a CSS custom property on the floating element) rather than writing `max-height` on the floating wrapper directly — returned as `availableHeight` +- [x] 3.4 Clamp with a minimum usable height (~200px) so a trigger near the viewport edge does not collapse the dialog to a sliver — exported `MIN_AVAILABLE_HEIGHT` +- [x] 3.5 Verify `ColorPicker`, `ToolbarDropdown`, `ToolbarSplitButton`, `TableGridSelector` and `ConfigurationDropdown` pass the new option nowhere, so their behaviour is byte-identical + +Note on 3.3: `availableHeight` is derived from trigger/viewport geometry, not from the floating element's own height, so feeding it back into the element's `max-height` cannot oscillate. `Math.floor` quantises subpixel jitter so the state setter bails out on unchanged values instead of re-rendering on every `autoUpdate` tick. + +## 4. Create the shared `DialogShell` + +- [x] 4.1 Create `src/components/toolbars/components/DialogShell.tsx` accepting `{ mode, referenceElement, onClose, className, children }`, where `mode` is `"inline" | "focused"` +- [x] 4.2 Wrap both modes in `FloatingPortal` from `@floating-ui/react` (already a dependency — no new package) +- [x] 4.3 Put `widget-rich-text` on the portalled root so widget-scoped styling and custom properties still resolve outside the widget subtree (this is what v4's `classNames("Dialog mx-layoutgrid widget-rich-text", …)` was for) +- [x] 4.4 Inline mode: `useDropdown` with the `size()` option enabled, anchored to `referenceElement`, keeping the existing mousedown-outside dismissal; render no overlay and do not lock scroll +- [x] 4.5 Focused mode: `FloatingOverlay lockScroll` + `FloatingFocusManager` + `useDismiss({ outsidePressEvent: "mousedown" })` + `useRole`, centred, with `role="dialog"`, `aria-modal="true"` and `aria-labelledby` wired to the dialog title +- [x] 4.6 Focused mode Escape: close the dialog, and stop propagation in the capture phase so the editor's fullscreen-exit Escape handler does not also fire — preserve the behaviour `HelpDialog` implements today +- [x] 4.7 Render `children` inside `.toolbar-dialog` together with the caller-supplied `className`, so `.image-dialog` / `.video-dialog` / `.help-dialog` styling keeps applying +- [x] 4.8 Apply the resolved max-height to the dialog box: available height in inline mode, `70vh` in focused mode + +Notes: + +- 4.1 gained two props the task did not anticipate. `dialogRef` forwards a ref onto `.toolbar-dialog`, because `ImageDialog` registers the `imageSelected` listener on that exact node — app-developer JS actions dispatch the event at it, so replacing the node would break an app-visible contract. `ariaLabelledBy` carries `HelpDialog`'s existing `TITLE_ID`. +- 4.5 sets `escapeKey: false` on `useDismiss` so it does not compete with the capture-phase handler from 4.6, and centres via flexbox on the overlay rather than v4's `translate(-50%, -50%)` — a transform on the dialog would make it the containing block for its own descendants, the very mechanism this change exists to avoid. +- 4.6 is defence in depth rather than a live bug: `Fullscreen`'s Escape is a ProseMirror keymap, so it only fires while the editor DOM holds focus, which the focus trap prevents. Kept because it is the behaviour `HelpDialog` documented and relied on. + +## 5. Restructure the dialog box for internal scrolling + +- [x] 5.1 In `Dialog.scss`, make `.toolbar-dialog` a flex column with the resolved `max-height` +- [x] 5.2 Add `.dialog-scroll` with `flex: 1; overflow-y: auto; min-height: 0` — `min-height: 0` is required, since a flex item's default `min-height: auto` refuses to shrink below content height and would defeat the cap +- [x] 5.3 Give `h3` and `.dialog-actions` `flex-shrink: 0` so they stay pinned +- [x] 5.4 Remove the now-redundant `min-height: 100px` growth path on `.image-dialog-entity`; keep its margin +- [x] 5.5 Replace `.confirm-dialog-overlay`'s ad-hoc rules with the shared overlay class used by `DialogShell` +- [x] 5.6 Unify stacking: overlay `10000`, dialog `10001`, replacing the current mix of `1000` (inline dialogs) and `10000` (overlay dialogs); define both as SCSS variables in one place +- [x] 5.7 Keep `.help-dialog-content`'s own `max-height: 60vh` or drop it in favour of the shared scroll region — pick one so the help dialog does not end up with two nested scrollers + +Note on 5.7: dropped the `max-height: 60vh`. `.help-dialog-content` is now matched by the shared scroll-child rule, so the shell's `max-height` on `.toolbar-dialog` is the single bound. + +## 6. Move the three insert dialogs onto `DialogShell` + +- [x] 6.1 `ImageDialog.tsx`: drop the `useDropdown` call and the `
` wrapper; wrap the return in `` +- [x] 6.2 `ImageDialog.tsx`: wrap tabs, tab content, `.image-dialog-entity`, previews and the alt/title/width/height fields in `
`, leaving `h3` and `.dialog-actions` outside it +- [x] 6.3 `VideoDialog.tsx`: same shell swap and scroll-region wrap; keep the `
` and all tab/detection logic untouched +- [x] 6.4 `LinkDialog.tsx`: same shell swap and scroll-region wrap +- [x] 6.5 Read `dialogStyle` from `useCurrentEditor()` in each dialog; `referenceElement` stays in the props signature and is simply unused in focused mode +- [x] 6.6 Confirm no dialog body logic changed — no edits to tiptap commands, validation, tab state, dropzone or the `imageSelected` listener + +Notes: + +- The wrapper each dialog already had between `.toolbar-dialog` and its content — a `` for video and link, an intentional non-`` `
` for image — carries `.dialog-layout`, so the column layout survives that extra element. +- Each dialog gained a `TITLE_ID` and `

` so the shell can wire `aria-labelledby` in focused mode. +- `ImageDialog` keeps its own `dialogRef` and passes it to the shell; `LinkDialog`'s `dialogRef` was only there for `useDropdown` outside-click and is gone. `urlInputRef` (autofocus) stays. +- Existing `EditorContext.Provider` fixtures in `ImageDialog.spec.tsx`, `ToolbarDefaultButton.spec.tsx`, `RichText.spec.tsx` and `HelpButton.spec.tsx` gained `dialogStyle: "inline"` — the context value and container props are now non-optional, so a typecheck fails without it. + +## 7. Move Help and Confirm dialogs onto `DialogShell` + +- [x] 7.1 `HelpDialog.tsx`: replace `.confirm-dialog-overlay`, the outside-click effect, the Escape effect and the manual `dialogRef.current?.focus()` with ``; keep `TITLE_ID` wiring +- [x] 7.2 `ConfirmDialog.tsx`: replace `.confirm-dialog-overlay` and its outside-click effect with `` +- [x] 7.3 Verify both ignore `dialogStyle` — hardcode `mode="focused"`, do not read it from context +- [x] 7.4 Verify the code-view exit confirmation still behaves the same, including that Escape does not leak to the fullscreen handler + +Notes: + +- `ConfirmDialog` now closes on Escape (mapped to `onCancel`), which it did not before. Consistent with every other dialog, and the capture-phase handler keeps the keystroke away from the fullscreen extension. +- `ConfirmDialog`'s message sits in a `.dialog-scroll` region so a long confirmation cannot push its buttons out of view either. +- Portalling changed mount ordering: a portal's children mount one commit after the dialog, so `HelpDialog`'s old synchronous `focus()` is gone (`FloatingFocusManager` handles it, and its focus lands in a microtask — `HelpButton.spec.tsx`'s focus assertion now awaits), and `ImageDialog` holds the `.toolbar-dialog` node in state instead of a ref so the `imageSelected` listener still attaches. `LinkDialog`'s mount-time `urlInputRef.current?.focus()` was removed for the same reason; the input's `autoFocus` already covers it. + +## 8. Unit tests + +- [x] 8.1 `DialogShell` inline mode: renders no overlay, does not lock body scroll, closes on outside mousedown +- [x] 8.2 `DialogShell` focused mode: renders the overlay, locks body scroll, sets `role="dialog"` and `aria-modal="true"`, moves focus into the dialog, closes on Escape and on overlay press +- [x] 8.3 `DialogShell` focused mode: Escape does not propagate to a spy handler registered on `document` in the capture phase — the regression guard for fullscreen exiting alongside the dialog +- [x] 8.4 Both modes: the dialog element is not a descendant of the widget's rendered root (portal assertion) +- [x] 8.5 `ImageDialog`: with `imageSourceContent` taller than the available height, the `.dialog-scroll` region carries the height cap and the Insert / Cancel controls are outside it +- [x] 8.6 `ImageDialog` / `VideoDialog` / `LinkDialog`: `dialogStyle: "focused"` renders an overlay; `"inline"` does not +- [x] 8.7 `HelpDialog` and `ConfirmDialog`: render focused even with `dialogStyle: "inline"` in context +- [x] 8.8 Selection preservation: caret mid-paragraph, open dialog, insert — the insert command runs against the stored selection, in both modes +- [x] 8.9 Verify existing `src/components/toolbars/components/__tests__/ImageDialog.spec.tsx` queries still resolve through the portal (RTL `screen` searches `document.body`; any query scoped to the `render()` container result needs updating) +- [x] 8.10 Check `src/__tests__/__snapshots__` for drift and update only where the change is intended +- [x] 8.11 Popover regression guard: `ColorPicker` and `TableGridSelector` render anchored with no overlay while `dialogStyle` is `focused` + +Notes: + +- New specs: `DialogShell.spec.tsx` (8.1–8.4) and `DialogPresentation.spec.tsx` (8.6–8.8, 8.11). 8.5 lives in `ImageDialog.spec.tsx` as "ImageDialog scroll region". +- 8.3 spies on `document.body` in the capture phase rather than on `document`: the shell's own handler is on `document`, so a same-target spy would be ordered by registration and prove nothing. A listener one level down is exactly what `stopPropagation` has to block. +- 8.5 asserts the bound on `.toolbar-dialog` (which owns `max-height`) with `.dialog-scroll` as the overflow region, not a cap on the scroll region itself. +- 8.8 checks that `chain().focus()` runs before the insert command — `focus()` is what restores the selection the editor held when the dialog opened. +- The three container-scoped queries in `ImageDialog.spec.tsx` moved to `document` (8.9); the "renders no form element" assertion now scopes to `.image-dialog form` so it still means something. +- 8.10: the six existing snapshots render the editor without any dialog open, so there is no drift to review. +- `DialogShell` gained an inline fallback `max-height` of `70vh` while no measured height is available — including the case where there is no anchor at all, which is otherwise an unbounded dialog again. + +## 9. E2E tests + +- [x] 9.1 Extend `e2e/RichText.spec.js` (or add a spec) covering the reported case: image dialog, Media Library tab with enough images to overflow, assert the Insert button is visible and clickable without the dialog being clipped +- [ ] 9.2 Cover the same case with `dialogStyle: "focused"` — **blocked on 9.5** +- [ ] 9.3 Focused mode: overlay visible, Escape closes, Tab cycles inside the dialog — **blocked on 9.5** +- [x] 9.4 Follow `docs/requirements/e2e-test-guidelines.md`; check `e2e/RichText.spec.js-snapshots` for drift +- [ ] 9.5 Add the test-project page configuration needed for both modes — **blocked: needs Studio Pro** + +Notes: + +- Portalling broke two existing widget-scoped dialog locators, which had to be fixed before anything + else: `.mx-name-richText1 .toolbar-dialog.image-dialog` (the `insertImageDialog.png` screenshot) and + `widget.locator(".toolbar-dialog.video-dialog")` in the YouTube test. Both now resolve at page level. + The popup test already used a page-level locator. +- 9.1 reproduces the overflow with a 1024×420 viewport instead of a Media Library full of thumbnails: + no existing test-project page configures an image source (the `insertImageDialog.png` baseline shows + only the URL and Upload tabs), and the short viewport exercises the same code path — the shell's + measured `max-height`, the `.dialog-scroll` overflow and the pinned action row. The test asserts the + dialog box is inside the viewport, that `.dialog-scroll` actually overflows, and that Insert still + inserts. +- A second test covers the transformed-ancestor case (10.2) in CI: a dialog opened from the widget + inside the Demo popup page is not a descendant of the widget subtree, sits fully inside the viewport + and stays interactive. +- 9.4: no baseline drift expected — the dialogs are unchanged below `70vh`, and no new screenshot + assertions were added (the new tests assert geometry, which a baseline image cannot express). Still + to be confirmed on the first CI run. +- 9.2, 9.3 and 9.5 need a test-project page with `dialogStyle: "focused"`. The project lives in + `mendix/testProjects` (branch `rich-text-web`) as a binary `.mpr`, so the page cannot be added from + the repo — it needs Studio Pro. Focused-mode overlay, Escape and focus-trap behaviour is covered by + `DialogShell.spec.tsx` and `DialogPresentation.spec.tsx` in the meantime. + +## 10. Manual verification in Studio Pro + +- [ ] 10.1 Widget inside an `overflow: hidden` container: tall image dialog is not clipped, in both modes +- [ ] 10.2 Widget inside a Mendix popup page (transformed ancestor): dialog is not clipped and paints above the popup underlay, in both modes +- [ ] 10.3 Trigger near the viewport bottom, inline mode: dialog flips or shrinks and stays usable +- [ ] 10.4 Focused mode inside a popup page that also locks scroll: page scroll is restored correctly after the dialog closes +- [ ] 10.5 Link editing from the bubble menu in both modes +- [ ] 10.6 Fullscreen mode: Escape closes only the dialog + +Note: section 10 is human verification in Studio Pro and cannot be done from the repo. 10.2 is partly +covered in CI by the popup-page E2E test added in 9.1's commit; 10.6 by `DialogShell.spec.tsx`'s +Escape-containment guard. The rest — an `overflow: hidden` container, a trigger near the viewport +bottom, scroll restore after a focused dialog closes inside a scroll-locking popup, and link editing +from the bubble menu — still need a manual pass. + +## 11. Changelog + +- [x] 11.1 Add a `Fixed` entry to `CHANGELOG.md`: dialogs no longer get clipped when their content is tall or when the widget sits in a container that clips overflow; tall dialogs now scroll internally +- [x] 11.2 Add an `Added` entry: "Dialog style" property to choose between the anchored inline dialogs and centred focused dialogs +- [x] 11.3 Keep both entries behavioural — no component names, no mention of the portal or middleware +- [x] 11.4 Do not bump the package version; that happens at release time + +Note: both entries went under the existing `[Unreleased]` heading, alongside the Media Library submit +fix. The clipping entry names the pop-up page as the example an app developer would recognise, without +mentioning transforms, portals or Floating UI. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-dialog-presentation/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-dialog-presentation/spec.md new file mode 100644 index 0000000000..f8d32c2905 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-dialog-presentation/spec.md @@ -0,0 +1,182 @@ +# Rich Text Dialog Presentation Specification + +## Purpose + +Shared presentation behaviour for every Rich Text widget dialog — Image, Video, Link, keyboard-shortcuts help, and confirmation. Defines the `dialogStyle` configuration property (anchored `inline` versus centred `focused`), portal rendering outside the widget's DOM subtree, viewport-bounded height with internal scrolling, focus and Escape handling, stacking order, and selection preservation. Toolbar popovers are explicitly out of scope and keep their anchored presentation. + +## Requirements + +### Requirement: Dialog style configuration property + +The Rich Text widget SHALL expose a `dialogStyle` enumeration property with the values `inline` and `focused`, defaulting to `inline`. The property SHALL select the presentation of the Image, Video and Link dialogs. Existing apps that do not set the property SHALL keep the anchored (inline) presentation. + +#### Scenario: Default configuration + +- **WHEN** a widget instance does not set `dialogStyle` +- **THEN** the effective value is `inline` +- **AND** the Image, Video and Link dialogs are presented anchored to their trigger + +#### Scenario: Focused style selected + +- **WHEN** `dialogStyle` is set to `focused` +- **THEN** the Image, Video and Link dialogs are presented as centred modal dialogs over a dimmed overlay + +#### Scenario: Property reaches dialogs opened outside the toolbar + +- **WHEN** `dialogStyle` is set to `focused` and the user activates Edit on the link bubble menu +- **THEN** the link dialog is presented as a centred modal dialog rather than anchored to the link + +### Requirement: Dialogs render outside the widget's DOM subtree + +Every Rich Text dialog — Image, Video, Link, keyboard-shortcuts help, and confirmation — SHALL be rendered through a portal into the document body rather than as a DOM descendant of the widget node. This SHALL hold in both dialog styles. No ancestor `overflow`, `transform`, `filter`, `will-change` or `contain` declaration SHALL be able to clip a dialog. + +#### Scenario: Widget container clips its own content + +- **WHEN** the widget node has `overflow: hidden` and the user opens a dialog whose content extends beyond the widget's bounds +- **THEN** the dialog is rendered in full and is not clipped by the widget node + +#### Scenario: Transformed ancestor establishes a containing block + +- **WHEN** the widget is placed inside an ancestor that establishes a containing block through a transform, such as a Mendix popup page, and the user opens a dialog +- **THEN** the dialog is rendered in full and is not clipped by that ancestor + +#### Scenario: Dialog is not a descendant of the widget node + +- **WHEN** any dialog is open in either dialog style +- **THEN** the dialog element is not a descendant of the widget's rendered root element + +### Requirement: Dialog height is bounded with internal scrolling + +Every Rich Text dialog SHALL bound its own height so that it fits the space available to it, and SHALL scroll its content internally rather than growing past that bound. The dialog title and the dialog action controls SHALL remain visible while the content between them scrolls. The dialog SHALL NOT render at a height that places its action controls outside the viewport. + +#### Scenario: Media Library renders many images + +- **WHEN** the image dialog's Media Library tab renders embedded content taller than the space available to the dialog +- **THEN** the dialog's height is capped +- **AND** the embedded content area scrolls +- **AND** the Insert and Cancel controls remain visible without scrolling the dialog + +#### Scenario: Long embed code in the video dialog + +- **WHEN** the video dialog's embed-code tab, its detection status and its warning together exceed the space available to the dialog +- **THEN** the dialog content scrolls and the Insert and Cancel controls remain visible + +#### Scenario: Content fits + +- **WHEN** a dialog's content is shorter than the space available to it +- **THEN** no internal scrollbar is shown and the dialog is sized to its content + +### Requirement: Inline dialog style + +In the `inline` dialog style, a dialog SHALL be positioned relative to the element that opened it, SHALL flip and shift to stay within the viewport, and SHALL close when the user presses outside both the dialog and its trigger. The inline style SHALL NOT render an overlay, SHALL NOT dim the page, SHALL NOT lock page scrolling, and SHALL NOT trap focus. An inline dialog SHALL shrink to fit the space available at its resolved placement and scroll internally; it SHALL NOT convert itself into a centred modal when space is short. + +#### Scenario: Anchored to its trigger + +- **WHEN** `dialogStyle` is `inline` and the user activates the Insert Image toolbar button +- **THEN** the dialog is positioned relative to that button + +#### Scenario: Shrink to fit available space + +- **WHEN** `dialogStyle` is `inline` and the space at the dialog's resolved placement is smaller than its content height +- **THEN** the dialog's scrollable region is capped to the available height +- **AND** the dialog remains anchored to its trigger +- **AND** no overlay is rendered + +#### Scenario: No overlay rendered + +- **WHEN** an inline dialog is open +- **THEN** no dimmed overlay element is present +- **AND** page scrolling is not locked + +#### Scenario: Outside press closes + +- **WHEN** an inline dialog is open and the user presses outside both the dialog and its trigger +- **THEN** the dialog closes + +### Requirement: Focused dialog style + +In the `focused` dialog style, a dialog SHALL be rendered centred in the viewport above a dimmed overlay that locks page scrolling. The dialog SHALL expose `role="dialog"` with `aria-modal="true"`, SHALL move focus into itself on open, SHALL confine Tab and Shift+Tab navigation to its own focusable elements while open, SHALL close on Escape, and SHALL close when the user presses the overlay. Escape handling SHALL be scoped to the dialog so that editor-level Escape handlers, including the fullscreen exit handler, do not also fire. + +#### Scenario: Overlay and centring + +- **WHEN** `dialogStyle` is `focused` and the user opens the Image dialog +- **THEN** a dimmed overlay is rendered behind the dialog +- **AND** the dialog is centred in the viewport +- **AND** page scrolling is locked while the dialog is open + +#### Scenario: Focus trap + +- **WHEN** a focused dialog is open and the user presses Tab from its last focusable element +- **THEN** focus moves to the dialog's first focusable element and does not leave the dialog + +#### Scenario: Escape closes only the dialog + +- **WHEN** a focused dialog is open while the editor is in fullscreen mode and the user presses Escape +- **THEN** the dialog closes +- **AND** the editor remains in fullscreen mode + +#### Scenario: Overlay press closes + +- **WHEN** a focused dialog is open and the user presses the overlay outside the dialog +- **THEN** the dialog closes + +#### Scenario: Accessible modal semantics + +- **WHEN** a focused dialog is open +- **THEN** the dialog element exposes `role="dialog"` and `aria-modal="true"` and is labelled by its title + +### Requirement: Help and confirmation dialogs are always focused + +The keyboard-shortcuts help dialog and the confirmation dialog SHALL always use the focused presentation regardless of the `dialogStyle` value. They SHALL obtain the portal, height bounding, focus trap, Escape handling and stacking behaviour from the shared dialog presentation rather than from their own implementations. + +#### Scenario: Help dialog under the inline style + +- **WHEN** `dialogStyle` is `inline` and the user opens the keyboard-shortcuts help dialog +- **THEN** the dialog is rendered centred above a dimmed overlay with a focus trap + +#### Scenario: Confirmation dialog under the inline style + +- **WHEN** `dialogStyle` is `inline` and a confirmation dialog is shown +- **THEN** the dialog is rendered centred above a dimmed overlay with a focus trap + +### Requirement: Consistent dialog stacking + +All Rich Text dialogs and dialog overlays SHALL use a single stacking scale that places them above Atlas modal layers, so a dialog opened from a widget inside a popup page is not obscured by that page. Dialog overlays SHALL sit directly beneath their dialog. + +#### Scenario: Dialog above a host popup layer + +- **WHEN** the widget is placed inside a Mendix popup page and the user opens a dialog +- **THEN** the dialog is painted above the popup and its underlay + +#### Scenario: Overlay beneath its dialog + +- **WHEN** a focused dialog is open +- **THEN** the overlay is painted beneath the dialog and above the page + +### Requirement: Editor selection preserved across dialog interaction + +Opening a dialog, interacting with its controls, and confirming it SHALL insert content at the selection that was active in the editor when the dialog opened. This SHALL hold in both dialog styles, including when the focused style moves focus out of the editor into a focus trap. + +#### Scenario: Insert at a caret mid-paragraph + +- **WHEN** the caret is inside a paragraph, the user opens the Image dialog, enters a URL and confirms +- **THEN** the image is inserted at that caret position + +#### Scenario: Insert over a selection in focused style + +- **WHEN** `dialogStyle` is `focused`, text is selected, and the user opens the Link dialog and confirms +- **THEN** the link is applied to the previously selected text + +### Requirement: Toolbar popovers keep anchored presentation + +Toolbar popover controls — colour pickers, toolbar dropdowns, split-button menus, the table grid selector and configuration dropdowns — are not dialogs. They SHALL keep their anchored, non-portalled presentation and SHALL NOT be affected by `dialogStyle` or by the dialog height-bounding behaviour. + +#### Scenario: Colour picker under the focused style + +- **WHEN** `dialogStyle` is `focused` and the user opens the text colour picker +- **THEN** the picker is presented anchored to its toolbar button with no overlay + +#### Scenario: Table grid selector unchanged + +- **WHEN** `dialogStyle` is `focused` and the user opens the insert-table grid selector +- **THEN** the selector is presented anchored to its toolbar button with no overlay diff --git a/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-dialog/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-dialog/spec.md index bd1335c486..7b528e2826 100644 --- a/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-dialog/spec.md +++ b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-dialog/spec.md @@ -126,3 +126,24 @@ The image dialog's `imageSelected` listener SHALL NOT depend on state captured f - **WHEN** the user switches tabs and then an `imageSelected` event is dispatched on the dialog element - **THEN** the image source and selected entity image are set from the event detail - **AND** the Media Library tab is the active tab + +### Requirement: Image dialog content scrolls instead of overflowing + +The Rich Text image dialog SHALL bound its height and scroll its content internally, so that embedded Media Library content of any height cannot push the Insert and Cancel controls out of reach. The Media Library region SHALL scroll within the dialog's scrollable area rather than expanding the dialog past the space available to it. This SHALL hold in both dialog styles. + +#### Scenario: Media Library with many images + +- **WHEN** the Media Library tab is active and `imageSourceContent` renders a list taller than the space available to the dialog +- **THEN** the dialog height is capped to the available space +- **AND** the Media Library region scrolls +- **AND** the Insert and Cancel controls remain visible without scrolling the dialog + +#### Scenario: Alt text and dimension fields remain reachable + +- **WHEN** the Media Library tab is active with tall embedded content +- **THEN** the Alt text, Title, Width and Height inputs are reachable by scrolling the dialog's content region + +#### Scenario: Short embedded content + +- **WHEN** the Media Library tab is active and `imageSourceContent` is shorter than the space available to the dialog +- **THEN** the dialog is sized to its content and shows no internal scrollbar diff --git a/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-link-bubble-menu/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-link-bubble-menu/spec.md index d14fd851e0..9a2e186d2c 100644 --- a/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-link-bubble-menu/spec.md +++ b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-link-bubble-menu/spec.md @@ -27,7 +27,7 @@ The rich text editor SHALL display a floating bubble menu whenever the caret is ### Requirement: Edit an existing link -The Edit action SHALL select the entire link range before opening the link dialog, so the dialog is prefilled with the link's current attributes and updates the existing link in place rather than inserting a duplicate. +The Edit action SHALL select the entire link range before opening the link dialog, so the dialog is prefilled with the link's current attributes and updates the existing link in place rather than inserting a duplicate. The dialog opened by the Edit action SHALL follow the widget's `dialogStyle` configuration: anchored to the link's DOM element under the `inline` style, centred above a dimmed overlay under the `focused` style. The bubble menu itself SHALL remain an anchored floating layer in both styles. #### Scenario: Edit from a bare caret inside a link @@ -35,11 +35,22 @@ The Edit action SHALL select the entire link range before opening the link dialo - **THEN** the full link range is selected and the link dialog opens prefilled with the current URL, text, title, and target - **AND** submitting the dialog updates the existing link without duplicating its text -#### Scenario: Dialog anchored to the link +#### Scenario: Dialog anchored to the link under the inline style -- **WHEN** the Edit action opens the link dialog +- **WHEN** `dialogStyle` is `inline` and the Edit action opens the link dialog - **THEN** the dialog is positioned relative to the link's DOM element and remains positioned there while editing +#### Scenario: Dialog centred under the focused style + +- **WHEN** `dialogStyle` is `focused` and the Edit action opens the link dialog +- **THEN** the dialog is centred in the viewport above a dimmed overlay +- **AND** the dialog is not positioned relative to the link's DOM element + +#### Scenario: Bubble menu presentation is unchanged + +- **WHEN** the caret is inside a link and `dialogStyle` is `focused` +- **THEN** the bubble menu is still presented anchored to the link, without an overlay + ### Requirement: Remove a link The Remove action SHALL strip the link mark across its entire range, converting the linked text back into normal text. diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.xml b/packages/pluggableWidgets/rich-text-web/src/RichText.xml index 8a93722fb0..2f8758f423 100644 --- a/packages/pluggableWidgets/rich-text-web/src/RichText.xml +++ b/packages/pluggableWidgets/rich-text-web/src/RichText.xml @@ -220,6 +220,14 @@ Class + + Dialog style + How the insert image, insert video and insert link dialogs are presented. Inline anchors the dialog to the toolbar button it was opened from. Focused shows the dialog centered on the screen over a dimmed page, keeping the keyboard inside it until it is closed. + + Inline + Focused + + diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/HelpButton.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/HelpButton.spec.tsx index 183471100c..30728be060 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/HelpButton.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/HelpButton.spec.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps"; import RichText from "../RichText"; @@ -37,6 +37,7 @@ function buildProps(overrides: Partial = {}): RichTextCo tabIndex: 0, onChangeType: "onLeave", enableStatusBar: true, + dialogStyle: "inline", statusBarContent: "wordCount" as StatusBarContentEnum, spellCheck: true, minHeightUnit: "none", @@ -129,11 +130,12 @@ describe("Rich Text help button", () => { expect(dialog).toHaveAttribute("aria-modal", "true"); }); - it("moves focus into the dialog on open", () => { + // The focus trap moves focus in a microtask, so this has to wait rather than assert inline. + it("moves focus into the dialog on open", async () => { render(); fireEvent.click(getHelpButton()!); const dialog = screen.getByRole("dialog", { name: "Keyboard shortcuts" }); - expect(dialog).toHaveFocus(); + await waitFor(() => expect(dialog).toHaveFocus()); }); }); diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx index 32a27159d9..34c7da7c34 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx @@ -38,6 +38,7 @@ describe("Rich Text", () => { tabIndex: 0, onChangeType: "onLeave", enableStatusBar: true, + dialogStyle: "inline", statusBarContent: "wordCount" as StatusBarContentEnum, spellCheck: true, minHeightUnit: "none", diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx index 9b16cce30b..d8d10f1ad7 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx @@ -41,6 +41,7 @@ export interface EditorProps extends Pick< | "imageSource" | "imageSourceContent" | "enableDefaultUpload" + | "dialogStyle" | "preset" | "toolbarConfig" | "toolbarLocation" @@ -175,6 +176,7 @@ const Editor = forwardRef((props, ref) => { imageSource, imageSourceContent, enableDefaultUpload, + dialogStyle = "inline", ...others } = props; const actionRef = useMemo( @@ -343,7 +345,7 @@ const Editor = forwardRef((props, ref) => { return ( - + diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorContext.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorContext.tsx index 78e9e61bd4..63cc70a0db 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/EditorContext.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorContext.tsx @@ -1,6 +1,7 @@ import { Editor } from "@tiptap/react"; import { html as beautifyHtml } from "js-beautify"; import { createContext, useContext, useReducer, ReactNode, ReactElement, Dispatch } from "react"; +import { DialogStyleEnum } from "../../typings/RichTextProps"; // Code view state export interface CodeViewState { @@ -86,6 +87,12 @@ interface EditorContextValue { codeViewState: CodeViewState; codeViewDispatch: Dispatch; imageConfig: ImageDialogConfig; + /** + * Presentation of the image/video/link dialogs. Carried on the editor context rather than + * drilled through the toolbar because the link dialog has a second entry point outside the + * toolbar tree (the link bubble menu), which sits inside this provider too. + */ + dialogStyle: DialogStyleEnum; } export const EditorContext = createContext(undefined); @@ -93,16 +100,18 @@ export const EditorContext = createContext(undef export function EditorContextProvider({ editor, imageConfig, + dialogStyle, children }: { editor: Editor | null; imageConfig: ImageDialogConfig; + dialogStyle: DialogStyleEnum; children: ReactNode; }): ReactElement { const [codeViewState, codeViewDispatch] = useReducer(codeViewReducer, initialCodeViewState); return ( - + {children} ); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx index b99cba9814..9742024f03 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx @@ -19,6 +19,7 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement { imageSource, imageSourceContent, enableDefaultUpload, + dialogStyle, preset, toolbarConfig, history, @@ -121,6 +122,7 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement { imageSource={imageSource} imageSourceContent={imageSourceContent} enableDefaultUpload={enableDefaultUpload} + dialogStyle={dialogStyle} preset={preset} toolbarConfig={toolbarConfig} toolbarGroups={{ diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfirmDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfirmDialog.tsx index b55845af5d..e7dd147d1c 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfirmDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfirmDialog.tsx @@ -1,7 +1,12 @@ -import { ReactElement, useRef, useEffect } from "react"; +import { ReactElement } from "react"; +import { DialogShell } from "./DialogShell"; import { ConfirmDialogProps } from "../helpers/toolbarTypes"; import "./Dialog.scss"; +/** + * Always focused, whatever the widget's "Dialog style" is set to: it asks a blocking question and + * has no toolbar button to anchor to. + */ export function ConfirmDialog({ title, message, @@ -10,33 +15,22 @@ export function ConfirmDialog({ onConfirm, onCancel }: ConfirmDialogProps): ReactElement { - const dialogRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent): void => { - if (dialogRef.current && !dialogRef.current.contains(event.target as Node)) { - onCancel(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onCancel]); - return ( -
-
- {title &&

{title}

} - {message &&

{message}

} -
- - + + {title &&

{title}

} + {message && ( +
+

{message}

+ )} +
+ +
-
+ ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss index 23c1d33e67..4bc0e0ffdd 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/Dialog.scss @@ -1,3 +1,27 @@ +// Single stacking scale for every dialog. Dialogs are portalled to the document body, so they must +// clear whatever Atlas modal layer the widget itself is sitting inside. +$rte-dialog-overlay-z: 10000; +$rte-dialog-z: 10001; + +// Positioned wrapper for an inline dialog, portalled to the body. +.widget-rich-text-dialog-layer { + z-index: $rte-dialog-z; +} + +// Dimmed, scroll-locked backdrop for a focused dialog. Position and inset come from Floating UI's +// FloatingOverlay; centering, dimming and stacking come from here. +.widget-rich-text-dialog-overlay { + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + z-index: $rte-dialog-overlay-z; + + > .toolbar-dialog { + z-index: $rte-dialog-z; + } +} + .toolbar-dialog, .link-dialog { background: white; @@ -7,11 +31,38 @@ min-width: 320px; max-width: 400px; + // The title and the actions stay pinned while the region between them scrolls, so a tall + // dialog — a Media Library listing many images, say — can never push Insert/Cancel out of + // reach. `max-height` is supplied by DialogShell: available space when inline, 70vh when + // focused. + display: flex; + flex-direction: column; + h3 { padding: 8px 16px; font-size: 16px; font-weight: 600; border-bottom: 1px solid var(--border-color-default, #ced0d3); + flex-shrink: 0; + } + + // Dialogs that wrap their content in a (or, for the image dialog, a plain div) put this + // class on that wrapper, so the column layout survives the extra element. + .dialog-layout { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + } + + // Scrollable middle region. `min-height: 0` is required: a flex item defaults to + // `min-height: auto`, which refuses to shrink below its content height and would defeat the + // dialog's max-height entirely. + .dialog-scroll, + .help-dialog-content { + flex: 1; + min-height: 0; + overflow-y: auto; } .dialog-mode-selector { @@ -105,9 +156,11 @@ } } + // No min-height: the embedded image-source widget is app-developer content of arbitrary height, + // and forcing it to grow is what used to push the dialog past the viewport. It scrolls with + // `.dialog-scroll` instead. .image-dialog-entity { margin: 0 var(--spacing-medium, 16px); - min-height: 100px; } .dialog-field { @@ -185,6 +238,7 @@ gap: 8px; padding: 8px 16px; border-top: 1px solid var(--border-color-default, #ced0d3); + flex-shrink: 0; button { padding: 6px 16px; @@ -299,9 +353,9 @@ max-width: 480px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + // Height comes from the shell's max-height on `.toolbar-dialog`; the scroll behaviour comes from + // the shared `.help-dialog-content` rule above. Capping it again here would nest two scrollers. .help-dialog-content { - max-height: 60vh; - overflow-y: auto; padding: 8px 16px; } @@ -350,22 +404,11 @@ } } -.confirm-dialog-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - - .confirm-dialog { - max-width: 400px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); - } +// Overlay, centering and stacking now come from `.widget-rich-text-dialog-overlay`; only the +// dialog's own box styling is left here. +.confirm-dialog { + max-width: 400px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } // Video dialog and Image dialog enhancements diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx new file mode 100644 index 0000000000..09728c27bf --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/DialogShell.tsx @@ -0,0 +1,167 @@ +import { + FloatingFocusManager, + FloatingOverlay, + FloatingPortal, + useDismiss, + useFloating, + useInteractions, + useMergeRefs, + useRole +} from "@floating-ui/react"; +import classNames from "classnames"; +import { ReactElement, ReactNode, Ref, useEffect, useRef } from "react"; +import { DialogStyleEnum } from "../../../../typings/RichTextProps"; +import { useDropdown } from "../hooks/useDropdown"; +import "./Dialog.scss"; + +export interface DialogShellProps { + /** + * `inline` anchors the dialog to `referenceElement`; `focused` centers it over a dimmed, + * scroll-locked overlay with a focus trap. + */ + mode: DialogStyleEnum; + onClose: () => void; + /** Anchor for `inline` mode. Unused in `focused` mode. */ + referenceElement?: HTMLElement | null; + /** Dialog-specific class, e.g. `image-dialog`, applied next to `toolbar-dialog`. */ + className?: string; + /** Id of the element labelling the dialog. */ + ariaLabelledBy?: string; + /** + * Ref onto the `.toolbar-dialog` element. `ImageDialog` needs it: app-developer JS actions + * dispatch the `imageSelected` custom event at that element, so it must stay the same node. + */ + dialogRef?: Ref; + children: ReactNode; +} + +/** + * Bound for `focused` mode, and the inline fallback until the measured available height arrives (or + * for good, if there is no anchor to measure against). Matches v4's `--max-dialog-height` default. + */ +const DEFAULT_MAX_HEIGHT = "70vh"; + +/** + * Shared shell for every Rich Text dialog. + * + * Both modes portal to the document body. Rendering in place is what allowed a tall dialog to be + * clipped: the widget node sets `overflow: hidden`, and any ancestor with a transform — a Mendix + * popup page, for one — turns it into the containing block for `position: fixed`, so the dialog + * could no longer escape it. Both modes also bound their own height and expect the caller to mark + * the region that should scroll (`.dialog-scroll`), which keeps `.dialog-actions` reachable. + */ +export function DialogShell({ + mode, + onClose, + referenceElement, + className, + ariaLabelledBy, + dialogRef, + children +}: DialogShellProps): ReactElement { + const isInline = mode === "inline"; + + // Inline positioning. `trackAvailableHeight` reports the room left at the resolved placement so + // the dialog shrinks to fit instead of overflowing the viewport. + const { + refs: inlineRefs, + floatingStyles, + availableHeight + } = useDropdown({ + isOpen: isInline, + onClose, + referenceElement, + trackAvailableHeight: true + }); + + // Focused mode interactions. Hooks run in both modes — React requires an unconditional call — + // but stay inert while `open` is false. + const { refs: focusedRefs, context } = useFloating({ + open: !isInline, + onOpenChange: open => { + if (!open) { + onClose(); + } + } + }); + // Escape is handled below in the capture phase, so `useDismiss` must not also claim it. + const dismiss = useDismiss(context, { outsidePressEvent: "mousedown", escapeKey: false }); + const role = useRole(context, { role: "dialog" }); + const { getFloatingProps } = useInteractions([dismiss, role]); + + // Focus target when the dialog itself opens with nothing already focused inside it. A dialog + // whose first field carries `autoFocus` keeps that focus: FloatingFocusManager leaves focus + // alone when it is already inside the floating element. + const focusTargetRef = useRef(null); + const focusedDialogRef = useMergeRefs([focusedRefs.setFloating, focusTargetRef, dialogRef ?? null]); + + // Escape closes the dialog, and stops there. Without the capture phase and `stopPropagation`, + // an Escape meant for the dialog also reaches the editor, whose Fullscreen extension exits + // fullscreen on Escape — closing the dialog and leaving fullscreen in one keystroke. + useEffect(() => { + if (isInline) { + return; + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") { + return; + } + event.preventDefault(); + event.stopPropagation(); + onClose(); + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isInline, onClose]); + + const dialogClassName = classNames("toolbar-dialog", className); + + if (isInline) { + return ( + + {/* `widget-rich-text` travels with the portalled node so widget-scoped styling and + custom properties still resolve outside the widget's own subtree. */} +
+
+ {children} +
+
+
+ ); + } + + return ( + + + +
+ {children} +
+
+
+
+ ); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx index 656ddc10d0..97b9622431 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/HelpDialog.tsx @@ -1,4 +1,5 @@ -import { ReactElement, useEffect, useRef } from "react"; +import { ReactElement } from "react"; +import { DialogShell } from "./DialogShell"; import { useT } from "../../../utils/i18n"; import { SHORTCUT_CATEGORIES } from "../helpers/shortcuts"; import "./Dialog.scss"; @@ -11,73 +12,36 @@ const TITLE_ID = "rich-text-help-dialog-title"; /** * Centered modal listing the editor's keyboard shortcuts (TinyMCE-style help). - * Mirrors the ConfirmDialog pattern: overlay, click-outside to close, Escape to - * close. Escape is scoped to this dialog and its propagation is stopped so the - * fullscreen/editor Escape handlers don't also fire while the dialog is open. + * Always focused, whatever the widget's "Dialog style" is set to: it is a reference panel with no + * anchor to attach to. Overlay, click-outside, Escape (whose propagation is stopped so the + * fullscreen/editor Escape handlers don't also fire) and focus handling all come from DialogShell. */ export function HelpDialog({ onClose }: HelpDialogProps): ReactElement { - const dialogRef = useRef(null); const t = useT(); - useEffect(() => { - const handleClickOutside = (event: MouseEvent): void => { - if (dialogRef.current && !dialogRef.current.contains(event.target as Node)) { - onClose(); - } - }; - - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKeyDown, true); - - // Move focus into the dialog on open. - dialogRef.current?.focus(); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKeyDown, true); - }; - }, [onClose]); - return ( -
-
-

{t("help.title")}

-
- {SHORTCUT_CATEGORIES.map(category => ( -
-

{t(category.titleKey)}

-
    - {category.shortcuts.map(shortcut => ( -
  • - {t(shortcut.labelKey)} - {shortcut.keys} -
  • - ))} -
-
- ))} -
-
- -
+ +

{t("help.title")}

+
+ {SHORTCUT_CATEGORIES.map(category => ( +
+

{t(category.titleKey)}

+
    + {category.shortcuts.map(shortcut => ( +
  • + {t(shortcut.labelKey)} + {shortcut.keys} +
  • + ))} +
+
+ ))} +
+
+
-
+ ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx index 481862a873..5f5da0f595 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx @@ -1,12 +1,14 @@ import classNames from "classnames"; -import { ReactElement, useState, useRef, useEffect, KeyboardEvent } from "react"; +import { ReactElement, useState, useEffect, KeyboardEvent } from "react"; import { useDropzone } from "react-dropzone"; +import { DialogShell } from "./DialogShell"; import { useT, TranslateFn } from "../../../utils/i18n"; import { useCurrentEditor } from "../../EditorContext"; import { ImageDialogProps, EntityImage, ImageSourceMode, MAX_FILE_SIZE } from "../helpers/toolbarTypes"; -import { useDropdown } from "../hooks/useDropdown"; import "./Dialog.scss"; +const TITLE_ID = "rich-text-image-dialog-title"; + const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -32,7 +34,7 @@ const validateFile = (file: File, t: TranslateFn): string | null => { }; export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): ReactElement { - const { editor, imageConfig } = useCurrentEditor(); + const { editor, imageConfig, dialogStyle } = useCurrentEditor(); const { imageSourceContent, enableDefaultUpload, hasImageSource } = imageConfig; const t = useT(); const [activeTab, setActiveTab] = useState("url"); @@ -45,13 +47,11 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re const [uploadedFile, setUploadedFile] = useState(null); const [selectedEntityImage, setSelectedEntityImage] = useState(null); const [dragError, setDragError] = useState(""); - const dialogRef = useRef(null); - - const { refs, floatingStyles } = useDropdown({ - isOpen: true, - onClose, - referenceElement - }); + // The `imageSelected` event target: app-developer JS actions dispatch that event at the + // `.toolbar-dialog` node, so `DialogShell` forwards this ref onto it rather than owning it. + // Held in state, not a ref: the dialog is portalled, and a portal's children mount one commit + // after the dialog itself, so a mount-time effect would still see `null`. + const [dialogNode, setDialogNode] = useState(null); const handleTabChange = (newTab: ImageSourceMode): void => { setActiveTab(newTab); @@ -183,25 +183,33 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re useEffect(() => { // event listener for image selection triggered from custom widgets JS Action - const imgRef = dialogRef.current; - - if (imgRef !== null) { - imgRef.addEventListener("imageSelected", handleImageSelected); + if (dialogNode === null) { + return; } + + dialogNode.addEventListener("imageSelected", handleImageSelected); return () => { - imgRef?.removeEventListener("imageSelected", handleImageSelected); + dialogNode.removeEventListener("imageSelected", handleImageSelected); }; - // Registered once on mount. The handler only uses state setters, so it reads no stale state. - }, []); + // Registered once per dialog node. The handler only uses state setters, so it reads no + // stale state. + }, [dialogNode]); return ( -
-
- {/* Intentionally not a : `imageSourceContent` is app-developer content, and a - descendant
+
- {/* Action Buttons */} -
- - -
+ {/* Action Buttons */} +
+ +
-
+ ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx index e08486fee3..cfb9245afa 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx @@ -1,13 +1,15 @@ -import { ReactElement, useState, FormEvent, useRef, useEffect } from "react"; +import { ReactElement, useState, FormEvent, useRef } from "react"; +import { DialogShell } from "./DialogShell"; import { isSafeLinkUrl } from "../../../utils/helpers"; import { useT } from "../../../utils/i18n"; import { useCurrentEditor } from "../../EditorContext"; import { LinkDialogProps } from "../helpers/toolbarTypes"; -import { useDropdown } from "../hooks/useDropdown"; import "./Dialog.scss"; +const TITLE_ID = "rich-text-link-dialog-title"; + export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): ReactElement { - const { editor } = useCurrentEditor(); + const { editor, dialogStyle } = useCurrentEditor(); const t = useT(); // Get initial values from editor state @@ -23,19 +25,10 @@ export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): Reac (existingLink.target === "_blank" ? "_blank" : "_self") as "_self" | "_blank" ); + // Only used to return focus after a rejected URL. Focus on open comes from the input's + // `autoFocus`: the dialog is portalled, so a mount-time effect here would run before the input + // exists. const urlInputRef = useRef(null); - const dialogRef = useRef(null); - - const { refs, floatingStyles } = useDropdown({ - isOpen: true, - onClose, - referenceElement - }); - - useEffect(() => { - // Focus URL input when dialog opens - urlInputRef.current?.focus(); - }, []); const handleSubmit = (e: FormEvent): void => { e.preventDefault(); @@ -89,11 +82,11 @@ export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): Reac }; return ( -
-
- -

{existingLink.href ? t("link.editTitle") : t("link.insertTitle")}

+ + +

{existingLink.href ? t("link.editTitle") : t("link.insertTitle")}

+
- -
- - -
- -
-
+
+ +
+ + +
+ +
); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx index 2987b19062..829407171c 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx @@ -1,16 +1,18 @@ -import { ReactElement, useState, useRef, FormEvent, useEffect } from "react"; +import { ReactElement, useState, FormEvent, useEffect } from "react"; +import { DialogShell } from "./DialogShell"; import { parseEmbedCode } from "../../../utils/embedCodeParser"; import { useT } from "../../../utils/i18n"; import { matchPattern } from "../../../utils/videoUrlPattern"; import { useCurrentEditor } from "../../EditorContext"; import { VideoDialogProps } from "../helpers/toolbarTypes"; -import { useDropdown } from "../hooks/useDropdown"; import "./Dialog.scss"; type TabMode = "url" | "embed"; +const TITLE_ID = "rich-text-video-dialog-title"; + export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): ReactElement { - const { editor } = useCurrentEditor(); + const { editor, dialogStyle } = useCurrentEditor(); const t = useT(); const [activeTab, setActiveTab] = useState("url"); const [urlInput, setUrlInput] = useState(""); @@ -19,13 +21,6 @@ export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): Re const [height, setHeight] = useState("480"); const [detectedPlatform, setDetectedPlatform] = useState(null); const [validationError, setValidationError] = useState(null); - const dialogRef = useRef(null); - - const { refs, floatingStyles } = useDropdown({ - isOpen: true, - onClose, - referenceElement - }); // Handle URL input change const handleUrlChange = (value: string): void => { @@ -176,11 +171,17 @@ export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): Re (activeTab === "embed" && (!embedCodeInput.trim() || !!validationError)); return ( -
-
-
-

{t("video.title")}

- + + +

{t("video.title")}

+ +
{/* Tab Navigation */}
)} - - {/* Actions */} -
- - -
- -
-
+

+ + {/* Actions */} +
+ + +
+ + ); } diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx new file mode 100644 index 0000000000..95b548b8d5 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogPresentation.spec.tsx @@ -0,0 +1,125 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ReactElement, ReactNode } from "react"; +import { DialogStyleEnum } from "../../../../../typings/RichTextProps"; +import { EditorContext } from "../../../EditorContext"; +import { ColorPicker } from "../ColorPicker"; +import { ConfirmDialog } from "../ConfirmDialog"; +import { HelpDialog } from "../HelpDialog"; +import { ImageDialog } from "../ImageDialog"; +import { LinkDialog } from "../LinkDialog"; +import { TableGridSelector } from "../TableGridSelector"; +import { VideoDialog } from "../VideoDialog"; + +const OVERLAY_SELECTOR = ".widget-rich-text-dialog-overlay"; + +function chainSpy(): { chain: any; calls: string[] } { + const calls: string[] = []; + const chain: any = new Proxy( + {}, + { + get: (_target, prop: string) => () => { + calls.push(prop); + return prop === "run" ? true : chain; + } + } + ); + return { chain, calls }; +} + +function withEditor(children: ReactNode, dialogStyle: DialogStyleEnum, editor: any = null): ReactElement { + return ( + undefined, + dialogStyle, + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + {children as ReactElement} + + ); +} + +const noop = (): void => undefined; + +const INSERT_DIALOGS: Array<[string, ReactElement]> = [ + ["ImageDialog", ], + ["VideoDialog", ], + ["LinkDialog", ] +]; + +describe.each(INSERT_DIALOGS)("%s presentation", (_name, dialog) => { + it("renders no overlay when the dialog style is inline", () => { + render(withEditor(dialog, "inline")); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.querySelector(".toolbar-dialog")).not.toBeNull(); + }); + + it("renders a modal dialog over an overlay when the dialog style is focused", () => { + render(withEditor(dialog, "focused")); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); +}); + +describe("dialogs that are always focused", () => { + it("HelpDialog renders an overlay even with the inline dialog style in context", () => { + render(withEditor(, "inline")); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(screen.getByRole("dialog", { name: "Keyboard shortcuts" })).toBeInTheDocument(); + }); + + it("ConfirmDialog renders an overlay even with the inline dialog style in context", () => { + render( + withEditor(, "inline") + ); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); +}); + +// The toolbar popovers keep their pre-existing anchored behaviour: the new property is about +// dialogs only. +describe("popovers are unaffected by the dialog style", () => { + it("ColorPicker renders anchored with no overlay while the style is focused", () => { + render(withEditor(, "focused")); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.querySelector(".color-picker-dropdown")).not.toBeNull(); + }); + + it("TableGridSelector renders anchored with no overlay while the style is focused", () => { + const { chain } = chainSpy(); + const editor = { chain: () => chain } as any; + + render(withEditor(, "focused")); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.querySelector(".table-grid-selector")).not.toBeNull(); + }); +}); + +// `chain().focus()` is what restores the selection the editor held when the dialog opened, so it +// has to run before the insert command in both presentations. +describe.each(["inline", "focused"] as const)("selection preservation (%s)", dialogStyle => { + it("focuses the editor before inserting an image", () => { + const { chain, calls } = chainSpy(); + const editor = { chain: () => chain } as any; + + render(withEditor(, dialogStyle, editor)); + + fireEvent.change(screen.getByLabelText("Image URL"), { + target: { value: "https://example.com/image.jpg" } + }); + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + + expect(calls).toEqual(["focus", "setImage", "run"]); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx new file mode 100644 index 0000000000..d18f8e0fd7 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/DialogShell.spec.tsx @@ -0,0 +1,115 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { DialogShell } from "../DialogShell"; + +const OVERLAY_SELECTOR = ".widget-rich-text-dialog-overlay"; + +function renderShell(mode: "inline" | "focused", onClose = jest.fn()): { onClose: jest.Mock } { + const reference = document.createElement("button"); + document.body.appendChild(reference); + + render( + +

Shell title

+
+ +
+
+ ); + + return { onClose }; +} + +describe("DialogShell inline mode", () => { + it("renders no overlay and does not lock body scroll", () => { + renderShell("inline"); + + expect(document.querySelector(OVERLAY_SELECTOR)).toBeNull(); + expect(document.body.style.overflow).not.toBe("hidden"); + }); + + it("closes on outside mousedown", () => { + const { onClose } = renderShell("inline"); + + fireEvent.mouseDown(document.body); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close on mousedown inside the dialog", () => { + const { onClose } = renderShell("inline"); + + fireEvent.mouseDown(screen.getByRole("button", { name: "Close" })); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe("DialogShell focused mode", () => { + it("renders the overlay, locks body scroll and exposes modal dialog semantics", () => { + renderShell("focused"); + + expect(document.querySelector(OVERLAY_SELECTOR)).not.toBeNull(); + expect(document.body).toHaveStyle({ overflow: "hidden" }); + + const dialog = screen.getByRole("dialog", { name: "Shell title" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("moves focus into the dialog", async () => { + renderShell("focused"); + + const dialog = screen.getByRole("dialog", { name: "Shell title" }); + await waitFor(() => expect(dialog).toHaveFocus()); + }); + + it("closes on Escape", () => { + const { onClose } = renderShell("focused"); + + fireEvent.keyDown(screen.getByRole("dialog", { name: "Shell title" }), { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes on overlay mousedown", () => { + const { onClose } = renderShell("focused"); + + fireEvent.mouseDown(document.querySelector(OVERLAY_SELECTOR) as HTMLElement); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // Regression guard: without capture-phase handling plus stopPropagation, one Escape both closes + // the dialog and exits the editor's fullscreen mode. + it("stops Escape before it reaches other listeners", () => { + const spy = jest.fn(); + document.body.addEventListener("keydown", spy, true); + + try { + const { onClose } = renderShell("focused"); + fireEvent.keyDown(screen.getByRole("dialog", { name: "Shell title" }), { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalled(); + } finally { + document.body.removeEventListener("keydown", spy, true); + } + }); +}); + +describe("DialogShell portalling", () => { + // The widget node clips overflow, and a transformed ancestor makes it the containing block for + // `position: fixed`, so a dialog rendered in place can be clipped in both modes. + it.each(["inline", "focused"] as const)("renders %s outside the widget's own subtree", mode => { + const onClose = jest.fn(); + const { container } = render( + +

Shell title

+
+ ); + + const dialog = document.querySelector(".toolbar-dialog"); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx index 2a25511445..7771dec2de 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ImageDialog.spec.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { ReactElement } from "react"; +import { DialogStyleEnum } from "../../../../../typings/RichTextProps"; import { EditorContext, ImageDialogConfig } from "../../../EditorContext"; import { ImageDialog } from "../ImageDialog"; @@ -11,6 +12,7 @@ function renderWithConfig(imageConfig: ImageDialogConfig): ReturnType undefined, + dialogStyle: "inline", imageConfig }} > @@ -70,6 +72,7 @@ describe("ImageDialog dimensions", () => { editor, codeViewState: { isCodeView: false, htmlCode: "", showConfirm: false }, codeViewDispatch: () => undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: false, hasImageSource: false } }} > @@ -152,7 +155,6 @@ describe("ImageDialog insertion isolation", () => { function renderWithEmbeddedContent(): { setImage: jest.Mock; onClose: jest.Mock; - container: HTMLElement; } { const setImage = jest.fn(); const onClose = jest.fn(); @@ -166,12 +168,13 @@ describe("ImageDialog insertion isolation", () => { }; const editor = { chain: () => chain } as any; - const { container } = render( + render( undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: false, hasImageSource: true, @@ -186,11 +189,12 @@ describe("ImageDialog insertion isolation", () => { fireEvent.click(screen.getByRole("button", { name: "Media Library" })); - return { setImage, onClose, container }; + return { setImage, onClose }; } - const selectEntityImage = (container: HTMLElement): void => { - const dialog = container.querySelector(".image-dialog") as HTMLElement; + // The dialog is portalled to the body, so it is not inside `render`'s container. + const selectEntityImage = (): void => { + const dialog = document.querySelector(".image-dialog") as HTMLElement; act(() => { dialog.dispatchEvent( new CustomEvent("imageSelected", { @@ -201,9 +205,10 @@ describe("ImageDialog insertion isolation", () => { }; it("renders no form element", () => { - const { container } = renderWithEmbeddedContent(); + renderWithEmbeddedContent(); - expect(container.querySelector("form")).toBeNull(); + // Portalled, so this looks at the whole document rather than the render container. + expect(document.querySelector(".image-dialog form")).toBeNull(); }); it("does not insert or close when an untyped embedded button is clicked", () => { @@ -216,11 +221,11 @@ describe("ImageDialog insertion isolation", () => { }); it("does not insert or close when an untyped embedded button is clicked after an image is selected", () => { - const { setImage, onClose, container } = renderWithEmbeddedContent(); + const { setImage, onClose } = renderWithEmbeddedContent(); const embedded = screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL }); fireEvent.click(embedded); - selectEntityImage(container); + selectEntityImage(); fireEvent.click(embedded); fireEvent.click(embedded); @@ -229,8 +234,8 @@ describe("ImageDialog insertion isolation", () => { }); it("does not insert when Enter is pressed inside embedded content", () => { - const { setImage, onClose, container } = renderWithEmbeddedContent(); - selectEntityImage(container); + const { setImage, onClose } = renderWithEmbeddedContent(); + selectEntityImage(); fireEvent.keyDown(screen.getByRole("button", { name: EMBEDDED_BUTTON_LABEL }), { key: "Enter" }); @@ -239,8 +244,8 @@ describe("ImageDialog insertion isolation", () => { }); it("inserts the selected entity image when the Insert button is activated", () => { - const { setImage, onClose, container } = renderWithEmbeddedContent(); - selectEntityImage(container); + const { setImage, onClose } = renderWithEmbeddedContent(); + selectEntityImage(); fireEvent.click(screen.getByRole("button", { name: "Insert" })); @@ -273,6 +278,7 @@ describe("ImageDialog Enter to insert", () => { editor, codeViewState: { isCodeView: false, htmlCode: "", showConfirm: false }, codeViewDispatch: () => undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: false, hasImageSource: false } }} > @@ -331,3 +337,61 @@ describe("ImageDialog Enter to insert", () => { expect(setImage).not.toHaveBeenCalled(); }); }); + +// A Media Library listing many images used to grow the dialog past the viewport and push Insert / +// Cancel out of reach. The tall content now lives in a bounded scroll region instead. +describe("ImageDialog scroll region", () => { + function renderWithTallImageSource(dialogStyle: DialogStyleEnum): void { + render( + undefined, + dialogStyle, + imageConfig: { + enableDefaultUpload: false, + hasImageSource: true, + imageSourceContent: ( +
    + {Array.from({ length: 60 }, (_, index) => ( +
  • Image {index}
  • + ))} +
+ ) + } + }} + > + {( undefined} referenceElement={null} />) as ReactElement} +
+ ); + + fireEvent.click(screen.getByRole("button", { name: "Media Library" })); + } + + it("keeps the tall image source inside the scroll region and the actions outside it", () => { + renderWithTallImageSource("inline"); + + const scroll = document.querySelector(".dialog-scroll") as HTMLElement; + const actions = document.querySelector(".dialog-actions") as HTMLElement; + + expect(scroll).not.toBeNull(); + expect(scroll.querySelector(".image-dialog-entity")).not.toBeNull(); + expect(scroll.contains(actions)).toBe(false); + expect(document.querySelector(".dialog-scroll h3")).toBeNull(); + }); + + it("bounds the dialog height in focused mode", () => { + renderWithTallImageSource("focused"); + + expect(screen.getByRole("dialog")).toHaveStyle({ maxHeight: "70vh" }); + }); + + it("bounds the dialog height in inline mode", () => { + renderWithTallImageSource("inline"); + + // With no anchor to measure against, the shell falls back to its default bound rather than + // leaving the dialog unbounded. + expect(document.querySelector(".toolbar-dialog")).toHaveStyle({ maxHeight: "70vh" }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx index 4b55742127..01ae7d37d7 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/ToolbarDefaultButton.spec.tsx @@ -13,6 +13,7 @@ function renderWithEditor(children: ReactNode, isCodeView = false): ReturnType undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: true, hasImageSource: false } }} > @@ -32,6 +33,7 @@ function renderInCodeViewEditable(children: ReactNode): ReturnType undefined, + dialogStyle: "inline", imageConfig: { enableDefaultUpload: true, hasImageSource: false } }} > diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts index c6b17c3b6b..6202d5ba95 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/useDropdown.ts @@ -1,5 +1,12 @@ -import { useFloating, offset, flip, shift, autoUpdate, Placement } from "@floating-ui/react"; -import { useEffect, useRef, RefObject } from "react"; +import { useFloating, offset, flip, shift, size, autoUpdate, Placement } from "@floating-ui/react"; +import { useEffect, useRef, useState, RefObject } from "react"; + +/** + * Smallest height an auto-sized floating element is allowed to shrink to. Without a floor, a + * trigger near the viewport edge yields an `availableHeight` of a few pixels and the element + * collapses into an unusable sliver. + */ +export const MIN_AVAILABLE_HEIGHT = 200; export interface UseDropdownOptions { isOpen: boolean; @@ -7,6 +14,12 @@ export interface UseDropdownOptions { placement?: Placement; offsetValue?: number; referenceElement?: HTMLElement | null; + /** + * Measure the space left at the resolved placement and report it as `availableHeight`, so the + * caller can cap its own scroll region. Off by default: the toolbar popovers already bound + * themselves in CSS and must keep their current behaviour. + */ + trackAvailableHeight?: boolean; } export interface UseDropdownReturn { @@ -21,6 +34,11 @@ export interface UseDropdownReturn { top: number; left: number; }; + /** + * Height available at the resolved placement, floored at `MIN_AVAILABLE_HEIGHT`. Only produced + * when `trackAvailableHeight` is set; `undefined` otherwise. + */ + availableHeight?: number; } /** @@ -32,14 +50,32 @@ export function useDropdown({ onClose, placement = "bottom-start", offsetValue = 4, - referenceElement + referenceElement, + trackAvailableHeight = false }: UseDropdownOptions): UseDropdownReturn { const ignoreClickRef = useRef(null); + const [availableHeight, setAvailableHeight] = useState(undefined); const { x, y, strategy, refs } = useFloating({ placement, strategy: "fixed", - middleware: [offset(offsetValue), flip(), shift({ padding: 8 })], + // `size` runs last on purpose: it must measure the placement `flip` and `shift` settled on, + // not the requested one. + middleware: [ + offset(offsetValue), + flip(), + shift({ padding: 8 }), + ...(trackAvailableHeight + ? [ + size({ + padding: 8, + apply({ availableHeight: available }) { + setAvailableHeight(Math.max(Math.floor(available), MIN_AVAILABLE_HEIGHT)); + } + }) + ] + : []) + ], whileElementsMounted: autoUpdate, open: isOpen }); @@ -91,6 +127,7 @@ export function useDropdown({ position: strategy, top: y ?? 0, left: x ?? 0 - } + }, + availableHeight }; } diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss b/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss index 1b910d84a1..8708ec6f37 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/TableStyle.scss @@ -8,7 +8,7 @@ td, th { border-width: 1px; - border-color: var(--gray-ligher, rgba(61, 37, 20, 0.12)); + border-color: var(--gray-lighter, rgba(61, 37, 20, 0.12)); border-style: dotted; box-sizing: border-box; min-width: 1em; diff --git a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts index 392e504a72..546ad55dee 100644 --- a/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts +++ b/packages/pluggableWidgets/rich-text-web/typings/RichTextProps.d.ts @@ -33,6 +33,8 @@ export type StatusBarContentEnum = "wordCount" | "characterCount" | "characterCo export type StyleDataFormatEnum = "inline" | "class"; +export type DialogStyleEnum = "inline" | "focused"; + export type ToolbarConfigEnum = "basic" | "advanced"; export type CtItemTypeEnum = @@ -119,6 +121,7 @@ export interface RichTextContainerProps { enableDefaultUpload: boolean; statusBarContent: StatusBarContentEnum; styleDataFormat: StyleDataFormatEnum; + dialogStyle: DialogStyleEnum; toolbarConfig: ToolbarConfigEnum; history: boolean; fontStyle: boolean; @@ -170,6 +173,7 @@ export interface RichTextPreviewProps { enableDefaultUpload: boolean; statusBarContent: StatusBarContentEnum; styleDataFormat: StyleDataFormatEnum; + dialogStyle: DialogStyleEnum; toolbarConfig: ToolbarConfigEnum; history: boolean; fontStyle: boolean; From 029ec6faa5aca67aee48c9c005b6e01a254a5dc9 Mon Sep 17 00:00:00 2001 From: gjulivan Date: Thu, 3 Sep 2026 11:11:50 +0200 Subject: [PATCH 3/6] fix(richtext): header toolbar name incorrectly mapped --- packages/pluggableWidgets/rich-text-web/CHANGELOG.md | 2 ++ .../src/components/toolbars/ToolbarConfig.ts | 2 +- .../toolbars/helpers/__tests__/ToolbarConfig.spec.ts | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index c035d940ad..460345a604 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where a dialog was cut off when its content was tall or when the editor was placed inside a container that clips its content, such as a pop-up page. Dialogs now stay fully on screen and scroll inside themselves, keeping the Cancel and Insert buttons always visible. +- We fixed an issue where header toolbar not showing when selected on custom mode. + ## [5.0.0] - 2026-08-14 ### Added diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts index 62a887e8c1..672c173f96 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts @@ -675,7 +675,7 @@ const CT_ITEM_TO_BUTTON_MAP: Record = { size: "fontSize", color: "textColor", background: "backgroundColor", - header: "header", + header: "textFormat", fullscreen: "fullscreen", clean: "clearFormatting", tableBetter: "insertTable" diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts new file mode 100644 index 0000000000..d919ac7550 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/__tests__/ToolbarConfig.spec.ts @@ -0,0 +1,11 @@ +import { buildAdvancedToolbar } from "../../ToolbarConfig"; + +describe("buildAdvancedToolbar", () => { + it("maps the header item to the text format dropdown", () => { + const groups = buildAdvancedToolbar([{ ctItemType: "header" }]); + + expect(groups).toHaveLength(1); + expect(groups[0].buttons).toHaveLength(1); + expect(groups[0].buttons[0].name).toBe("textFormat"); + }); +}); From 74b99a80023453b84b7c2a5ab0c82bdd1e4953ce Mon Sep 17 00:00:00 2001 From: gjulivan Date: Thu, 3 Sep 2026 12:19:31 +0200 Subject: [PATCH 4/6] fix(richtext): allow drop and paste image and backward compatibility of image width and height --- .../rich-text-web/CHANGELOG.md | 6 + .../.openspec.yaml | 2 + .../2026-09-03-add-image-paste-drop/design.md | 224 +++++++++++++ .../proposal.md | 63 ++++ .../specs/rich-text-image-paste-drop/spec.md | 123 +++++++ .../2026-09-03-add-image-paste-drop/tasks.md | 68 ++++ .../fix-v4-image-size-compat/.openspec.yaml | 2 + .../fix-v4-image-size-compat/design.md | 76 +++++ .../fix-v4-image-size-compat/proposal.md | 51 +++ .../specs/rich-text-image-sizing/spec.md | 84 +++++ .../changes/fix-v4-image-size-compat/tasks.md | 42 +++ .../specs/rich-text-image-paste-drop/spec.md | 129 +++++++ .../rich-text-web/src/RichText.xml | 2 +- .../rich-text-web/src/components/Editor.tsx | 63 +++- .../src/components/ImageResize.tsx | 22 +- .../components/__tests__/ImageResize.spec.tsx | 53 +++ .../toolbars/components/ImageDialog.tsx | 44 +-- .../toolbars/helpers/toolbarTypes.ts | 5 - .../src/extensions/ImagePasteDrop.ts | 230 +++++++++++++ .../src/extensions/ImageResize.ts | 16 +- .../__tests__/ImagePasteDrop.spec.ts | 314 ++++++++++++++++++ .../extensions/__tests__/ImageResize.spec.ts | 85 +++++ .../rich-text-web/src/ui/RichText.scss | 24 ++ .../src/utils/__tests__/imageFiles.spec.ts | 80 +++++ .../src/utils/__tests__/imageSize.spec.ts | 40 +++ .../rich-text-web/src/utils/imageFiles.ts | 63 ++++ .../rich-text-web/src/utils/imageSize.ts | 56 ++++ 27 files changed, 1912 insertions(+), 55 deletions(-) create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/.openspec.yaml create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/design.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/proposal.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/specs/rich-text-image-paste-drop/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/tasks.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/.openspec.yaml create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/design.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/proposal.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/specs/rich-text-image-sizing/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/tasks.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-paste-drop/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/__tests__/ImageResize.spec.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 460345a604..7423d8bc49 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -10,8 +10,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We added a new "Dialog style" setting to choose how the editor's dialogs are shown: "Inline" keeps them anchored to the toolbar button, while "Focused" centers them on the page over a dimmed background. +- We added support for adding images by dragging & dropping image files into the editor, or by pasting them from the clipboard. Files that are too large or are not images are not inserted and the reason is shown below the editor. Both work only when "Enable default upload" is on. + ### Fixed +- We fixed an issue where dropping a file onto the editor made the browser navigate away from the page. + +- We fixed an issue where images that were resized in Rich Text version 4 lost their size after upgrading to version 5 and were shown at their original size. + - We fixed an issue where clicking a button inside the image dialog's Media Library content inserted the image and closed the dialog without pressing Insert. - We fixed an issue where a dialog was cut off when its content was tall or when the editor was placed inside a container that clips its content, such as a pop-up page. Dialogs now stay fully on screen and scroll inside themselves, keeping the Cancel and Insert buttons always visible. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/.openspec.yaml new file mode 100644 index 0000000000..9696e00f11 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/design.md new file mode 100644 index 0000000000..fc56435d07 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/design.md @@ -0,0 +1,224 @@ +## Context + +The Rich Text widget builds its TipTap editor in `src/components/Editor.tsx`. Image insertion today has exactly one code path per source, all of them inside `ImageDialog.tsx`: + +- **URL tab** — user-typed URL. +- **Upload tab** — `react-dropzone` inside the dialog; `FileReader.readAsDataURL` produces a base64 `data:` URI. Validation lives in dialog-private `validateFile`/`formatFileSize` (`ImageDialog.tsx:26-35`), with `MAX_FILE_SIZE = 5 * 1024 * 1024` in `toolbarTypes.ts:109`. +- **Media Library tab** — `imageSource` datasource plus the `imageSourceContent` widget, wired through an `imageSelected` CustomEvent dispatched at the dialog node. + +`ImageResize` is configured with `allowBase64: true` (`Editor.tsx`), so base64 `src` values already survive parse and serialization. + +The editor body itself has no file handling: `grep` for `handleDrop|handlePaste|handleDOMEvents|dataTransfer|clipboardData` across `src/` returns only unrelated dropdown handlers. `WordPaste` is the sole paste-adjacent extension and only hooks `transformPastedHTML`. + +Two ProseMirror facts drive this design (both read from `prosemirror-view@1.42.2`): + +```js +// dispatchEvent — custom handlers run BEFORE the editable gate +if (!runCustomHandler(view, event) && handlers[event.type] && (view.editable || !(event.type in editHandlers))) + handlers[event.type](view, event); + +// handleDrop — no preventDefault when nothing could be parsed +if (!slice) return; + +// dragover/dragenter are prevented only for editable views +editHandlers.dragover = editHandlers.dragenter = (_, e) => e.preventDefault(); +``` + +## Goals / Non-Goals + +**Goals:** + +- Drop and paste of image files insert base64 images, matching the Upload tab byte-for-byte in validation and output. +- One shared validation/read helper for the dialog and the new path. +- The page can never navigate away because of a dropped file — regardless of `enableDefaultUpload` or read-only state. +- Rejections are visible to the user. + +**Non-Goals:** + +- Uploading to a Mendix entity (`mx.data.saveDocument` + a create-object action, as `file-uploader-web` does with `createImageAction`). That needs new XML properties and async placeholder nodes; it is a separate future change. Drop/paste stays base64-only, consistent with the existing Upload tab. +- Changing `MAX_FILE_SIZE` (stays 5MB) or the Upload tab's UI. +- New XML properties. `enableDefaultUpload` is reused as the gate. +- New translation keys. Existing `image.error*` keys cover every rejection reason. +- E2E coverage of body drops (see Test Cases — deferred, not blocking). + +## Decisions + +### Use `handleDOMEvents`, not `handleDrop`/`handlePaste` + +`handleDrop` and `handlePaste` are reached through `handlers[event.type]`, which is gated on `view.editable`. A read-only editor therefore never sees them — and because `editHandlers.dragover` is behind the same gate, `dragover` is not prevented either, so on a read-only editor the `drop` event may not even fire and the browser navigates. + +`handleDOMEvents` runs in `runCustomHandler`, ahead of the gate, for every view state. So the plugin registers: + +| Event | Behavior | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `dragover`, `dragenter` | `preventDefault()` when the drag carries image files — makes `drop` fire even on read-only views. Return `false` otherwise. | +| `dragleave` | Clear the drag-over affordance. | +| `drop` | See decision below. | +| `paste` | Same pipeline, insert at selection. | + +**Rationale:** one registration point that also covers the read-only and upload-disabled cases, which are exactly the cases where the current bug is worst (nothing to insert, so nothing stops the browser). + +**Alternative considered:** `handleDrop` + a separate `dragover` DOM listener on `.tiptap-wrapper`. Rejected — two mechanisms, and `handleDrop` still cannot cover read-only. + +### Gate resolution: swallow, never fall through + +Decision table, evaluated in order: + +``` +event has image/* in dataTransfer.files / clipboardData.files ? + no -> return false (ProseMirror's normal path: HTML slices, + dragged from another tab, Word paste) + yes -> preventDefault() (always — this is the navigation fix) + !isEnabled() || !editable ? + yes -> return true (insert nothing, no error message) + no -> validate + read + insert, return true +``` + +`enableDefaultUpload: false` is silent, not an error: the app developer turned the feature off, so a message would blame the end user for a configuration choice. + +### `enableDefaultUpload` and `readOnly` come from a live ref, not from `configure()` + +`Editor.tsx` memoizes its extension array on `[styleDataFormat]` and calls `useEditor(..., [])`, so extension options are frozen at editor creation. Both `enableDefaultUpload` and `readOnly` can change at runtime when Studio Pro re-renders the widget, and a `configure({ enabled })` value would stay stuck at the first render's value. + +The extension therefore takes function options resolved at event time: + +```ts +ImagePasteDrop.configure({ + isEnabled: () => configRef.current.enableDefaultUpload, + isEditable: () => configRef.current.editable +}); +``` + +`configRef` is the same `useMemo`-held mutable ref pattern already used for `actionRef` (`Editor.tsx:172`), refreshed on every render. + +**Alternative considered:** add `enableDefaultUpload` to the extensions `useMemo` deps. Rejected — the editor instance is created once with `[]` deps and does not re-read extensions, so the new array would be ignored while adding a misleading dependency. + +### Async read vs synchronous handler: clamp the captured position + +`handleDOMEvents.drop` must return synchronously, but `FileReader` is async. So the handler captures the target position up front and inserts on resolve: + +``` +drop -> pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos +paste -> pos = view.state.selection.from + -> preventDefault(); return true + ...FileReader resolves... + -> insert at Math.min(pos, view.state.doc.content.size) +``` + +Between capture and insert the document can change (another user action, an external `setContent`). Clamping keeps the insert in-bounds; it may land slightly off if the doc shifted, which is acceptable for a local base64 read that resolves in milliseconds. + +**Alternative considered:** a decoration-backed placeholder whose position is remapped by every transaction (the standard async-upload pattern). Rejected as unnecessary here — there is no network round-trip. It becomes the right design if entity upload is added later. + +Multiple files are read in sequence and inserted in order, each after the previous insert, so document order matches drop order. + +### Error reporting via CustomEvent on the editor DOM + +The extension lives outside React and cannot call `useT()`. It dispatches a CustomEvent on `view.dom` carrying the translation key and its argument: + +``` +extension -> view.dom.dispatchEvent( + new CustomEvent("richtextImageDropError", + { detail: { key: "image.errorTooLarge", arg: "12.4 MB" } })) +EditorInner -> listens, holds it in state, renders it with useT(), auto-clears on a timer +``` + +This mirrors the `imageSelected` CustomEvent already used between app-developer JS actions and `ImageDialog` (`ImageDialog.tsx`), and keeps translation entirely inside React where the provider lives. + +**Alternative considered:** an `onError` callback option threaded through `configRef`. Workable, but the error must be rendered by `EditorInner` (inside `TranslationProvider`) while the ref is owned by `Editor` — the callback would have to be forwarded down anyway. The event keeps the two sides decoupled. + +Rejections report the specific reason (`image.errorTooLarge` with the formatted size, `image.errorNotImage`, `image.errorReadFailed`). When several files in one drop fail, one message is shown for the first failure; valid files in the same drop are still inserted. + +### Drag-over affordance uses an enter/leave counter + +`dragenter`/`dragleave` fire for every descendant element the pointer crosses, so a naive toggle flickers as the cursor moves over text nodes. The plugin keeps an integer counter (increment on `dragenter`, decrement on `dragleave`, class applied while `> 0`) and resets it to zero on `drop`. The class goes on the `.tiptap-wrapper` ancestor of `view.dom`, styled in `src/ui/RichText.scss` with a dashed outline. The dialog's `.image-dialog-dropzone` classes stay dialog-scoped and are not reused. + +### Inserted images use natural size + +No `width`/`height` attributes are applied. The dialog's `250` width default belongs to the dialog's explicit dimension fields; drop/paste is the fast path, and `ImageResize` already provides handles for resizing afterwards. + +## Test Cases + +### Shared helpers — `src/utils/__tests__/imageFiles.spec.ts` + +- `validateImageFile` rejects oversized files (unit) + - **Given**: a `File` with `size` above `MAX_FILE_SIZE` and `type: "image/png"` + - **When**: validated + - **Then**: returns the `image.errorTooLarge` key with the formatted size as its argument + +- `validateImageFile` rejects non-images (unit) + - **Given**: a `File` with `type: "application/pdf"` under the size limit + - **When**: validated + - **Then**: returns the `image.errorNotImage` key + +- `validateImageFile` accepts a valid image (unit) + - **Given**: a `File` with `type: "image/jpeg"` under the size limit + - **When**: validated + - **Then**: returns no error + +- `pickImageFiles` filters a mixed file list (unit) + - **Given**: a file list containing two `image/*` files and one `text/plain` file + - **When**: filtered + - **Then**: only the two image files are returned, in their original order + +### Drop/paste handling — `src/extensions/__tests__/ImagePasteDrop.spec.ts` + +The event handling is exported as a pure function taking `(files, ctx)` so it is testable without jsdom's stub `DataTransfer`; the extension file stays registration-only, like `WordPaste.ts`. + +- Valid image drop inserts at the drop position (unit) + - **Given**: an enabled, editable editor and a drop carrying one valid `image/png` + - **When**: the drop is handled + - **Then**: the event is `preventDefault`ed, the handler reports handled, and one `image` node with a `data:` `src` is inserted at the drop position + +- Multiple images preserve order (unit) + - **Given**: a drop carrying three valid images + - **When**: handled + - **Then**: three `image` nodes appear in the document in drop order + +- Oversized file is rejected with a message (unit) + - **Given**: a drop carrying a 12MB image + - **When**: handled + - **Then**: no node is inserted, the event is `preventDefault`ed, and an `image.errorTooLarge` error is reported + +- Non-image drop passes through (unit) + - **Given**: a drop whose `dataTransfer` carries no `image/*` file + - **When**: handled + - **Then**: the handler reports not-handled and does not call `preventDefault` + +- Upload disabled swallows the event silently (unit) + - **Given**: `isEnabled()` returns `false` and a drop carrying a valid image + - **When**: handled + - **Then**: `preventDefault` is called, nothing is inserted, and no error is reported + +- Read-only editor swallows the event silently (unit) + - **Given**: `isEditable()` returns `false` and a drop carrying a valid image + - **When**: handled + - **Then**: `preventDefault` is called and nothing is inserted + +- `dragover` is prevented for image drags (unit) + - **Given**: a `dragover` whose `dataTransfer` advertises a file + - **When**: handled + - **Then**: `preventDefault` is called — this is what makes `drop` fire on read-only views + +- Paste inserts at the selection (unit) + - **Given**: an enabled editor with a collapsed selection mid-paragraph and a paste carrying an image file + - **When**: handled + - **Then**: the image is inserted at the selection position + +- Insert position is clamped when the document shrank (unit) + - **Given**: a captured drop position beyond the document size at read-resolve time + - **When**: the read resolves + - **Then**: the insert happens at the document end instead of throwing + +- Drag-over class clears on drop (unit) + - **Given**: two `dragenter` events followed by one `drop` + - **When**: handled + - **Then**: the drag-over class is removed (counter reset, not left at 1) + +### Regression — existing suites + +- `WordPaste` HTML paste still routes through `transformPastedHTML` (a paste with `text/html` and no files is not intercepted). +- `ImageDialog.spec.tsx` upload-tab validation cases still pass after validation moves to `utils/imageFiles.ts`. + +### Manual / deferred + +- Playwright body-drop coverage needs a hand-built `DataTransfer` inside `page.evaluate` (`setInputFiles` cannot reach a body drop). Deferred; drop/paste is covered by manual test steps in `tasks.md`. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/proposal.md new file mode 100644 index 0000000000..d96f691b4a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/proposal.md @@ -0,0 +1,63 @@ +## Why + +Dragging an image file onto the editor, or pasting a screenshot from the clipboard, does nothing — and dropping is actively harmful: the browser navigates the whole tab away to the dropped file, discarding any unsaved form data on the page. + +Root cause: no `handleDrop`/`handlePaste`/`handleDOMEvents` prop exists anywhere in `src/`. ProseMirror's default `handleDrop` builds a slice from `text/plain` + `text/html` only; a file drop supplies neither, so `parseFromClipboard` yields nothing and the handler returns **before** calling `event.preventDefault()` (`prosemirror-view/dist/index.js`, `handleDrop`: `if (!slice) return;`). The browser default then handles the drop. Clipboard image paste is the quieter half of the same gap: nothing is inserted, nothing is reported. + +This is also a regression. The pre-TipTap (Quill) widget supported it — CHANGELOG 2.1.3: "Added 1MB file size limit for pasted and dropped images." + +The Upload tab of the image dialog already accepts a file and inserts it as a base64 `data:` URI, and `ImageResize` is already configured with `allowBase64: true`. Drop and paste reuse that exact path, so no new widget properties and no backend work are needed. + +## What Changes + +- New `ImagePasteDrop` TipTap extension registers a ProseMirror plugin with `handleDOMEvents` for `drop`, `paste`, `dragover`, `dragenter`, and `dragleave`. +- Dropped/pasted `image/*` files are validated (same rules and 5MB `MAX_FILE_SIZE` as the Upload tab), read as base64 `data:` URIs, and inserted as `image` nodes — at the drop coordinates for drops, at the selection for pastes. +- Multiple dropped images are inserted in document order; invalid files are skipped and reported. +- Gated on the existing `enableDefaultUpload` property. When it is `false`, or the editor is read-only, the event is swallowed (`preventDefault`, nothing inserted) so the browser cannot navigate the page away. +- Validation and file-reading helpers move out of `ImageDialog.tsx` into a shared `src/utils/imageFiles.ts` used by both the dialog and the extension. +- Editor shows a dashed drop-target highlight while a file is dragged over it, and a transient inline error message when a file is rejected (reusing the existing `image.errorTooLarge` / `image.errorNotImage` / `image.errorReadFailed` translations). +- Non-image drops and pastes are passed through untouched, so existing behavior (dragging an `` from another browser tab, pasting Word HTML) is unchanged. +- No new XML properties, no new translation keys, no data migration. + +## Capabilities + +### New Capabilities + +- `rich-text-image-paste-drop`: images dropped onto or pasted into the editor body are inserted as base64 images, gated on `enableDefaultUpload`, with file validation, drop-position insertion, drag-over affordance, and error reporting. + +### Modified Capabilities + + + +## Impact + +**Files affected**: + +- `src/utils/imageFiles.ts` — **new**: `MAX_FILE_SIZE`, `formatFileSize`, `validateImageFile`, `readFileAsDataUrl`, `pickImageFiles` +- `src/extensions/ImagePasteDrop.ts` — **new**: plugin registration + option types +- `src/components/Editor.tsx` — register `ImagePasteDrop`; live config ref for `enableDefaultUpload`/`readOnly`; render transient error + drag-over state in `EditorInner` +- `src/components/toolbars/components/ImageDialog.tsx` — use shared helpers, delete private `formatFileSize`/`validateFile` +- `src/components/toolbars/helpers/toolbarTypes.ts` — `MAX_FILE_SIZE` moves to `utils/imageFiles.ts` (re-exported here if other call sites remain) +- `src/ui/RichText.scss` — drag-over highlight + inline error message styles +- `src/RichText.xml` — fill the empty `enableDefaultUpload` description to state that it also controls drop/paste +- `CHANGELOG.md` — user-facing entry +- New unit tests: `src/utils/__tests__/imageFiles.spec.ts`, `src/extensions/__tests__/ImagePasteDrop.spec.ts` + +**User-facing changes**: + +- Drag & drop an image file into the editor → image inserted at the drop position. +- Paste an image from the clipboard (e.g. a screenshot) → image inserted at the cursor. +- Files over 5MB or non-image files are rejected with a visible message instead of being silently ignored. +- Dropping an image no longer navigates the page away — including on read-only editors. +- With "Enable default upload" off, drop and paste of image files insert nothing (and still cannot navigate the page). + +**Testing scope**: + +- Valid single image drop, multi-image drop (order preserved), oversized file, non-image file, unreadable file +- `enableDefaultUpload: false` → no insert; read-only editor → no insert; both cases `preventDefault`ed +- Paste image file; paste Word HTML must still route through `WordPaste` unchanged +- Drag an `` from another tab (HTML slice) must still insert via ProseMirror's own path +- Drop position: inserted at drop coordinates, not at the previous cursor position +- Drag-over highlight appears on drag enter, clears on leave and on drop (no stuck highlight) diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/specs/rich-text-image-paste-drop/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/specs/rich-text-image-paste-drop/spec.md new file mode 100644 index 0000000000..3ee2c11b91 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/specs/rich-text-image-paste-drop/spec.md @@ -0,0 +1,123 @@ +## ADDED Requirements + +### Requirement: Dropped image files are inserted as base64 images + +When one or more `image/*` files are dropped onto the Rich Text editor body, each valid file SHALL be inserted into the document as an image node whose `src` is a base64 `data:` URI. Insertion SHALL happen at the document position under the drop coordinates, not at the position of the selection before the drop. When several valid image files are dropped at once, they SHALL be inserted in the order they appear in the drop, at that position. Inserted images SHALL NOT be given `width` or `height` attributes; they keep their natural size and remain resizable afterwards. + +#### Scenario: Single image dropped into the editor + +- **WHEN** the user drops a valid image file onto the editor body +- **THEN** an image node with a base64 `data:` URI `src` is inserted at the drop position +- **AND** the browser does not navigate away from the page + +#### Scenario: Image dropped away from the cursor + +- **WHEN** the selection is in the first paragraph and the user drops a valid image over the third paragraph +- **THEN** the image is inserted at the third paragraph, not at the first + +#### Scenario: Multiple images dropped at once + +- **WHEN** the user drops three valid image files in a single drop +- **THEN** three image nodes are inserted in the same order as the dropped files + +#### Scenario: Dropped image keeps its natural size + +- **WHEN** a valid image file is dropped +- **THEN** the inserted image node has no `width` or `height` attribute + +### Requirement: Pasted image files are inserted as base64 images + +When the clipboard contains one or more `image/*` files (for example a screenshot), pasting into the Rich Text editor SHALL insert each valid file as an image node whose `src` is a base64 `data:` URI, at the current selection. The same validation, ordering, and sizing rules as dropped images SHALL apply. + +#### Scenario: Screenshot pasted into the editor + +- **WHEN** the clipboard holds an image file and the user pastes into the editor +- **THEN** an image node with a base64 `data:` URI `src` is inserted at the selection + +#### Scenario: Pasted image replaces the selected range + +- **WHEN** a range of text is selected and an image file is pasted +- **THEN** the image is inserted at that range's position + +### Requirement: Image drop and paste are gated on default upload and edit state + +Image drop and paste SHALL be available only when the `enableDefaultUpload` property is `true` and the editor is editable. When `enableDefaultUpload` is `false`, or the editor is read-only, an image file drop or paste SHALL insert nothing and SHALL NOT show an error message. In every case where an image file drop is detected — including the disabled and read-only cases — the browser's default handling SHALL be suppressed, so dropping a file can never navigate the page away or discard unsaved form data. + +#### Scenario: Default upload disabled + +- **WHEN** `enableDefaultUpload` is `false` and the user drops a valid image file onto the editor +- **THEN** nothing is inserted +- **AND** no error message is shown +- **AND** the browser does not navigate away from the page + +#### Scenario: Read-only editor + +- **WHEN** the editor is read-only and the user drops a valid image file onto it +- **THEN** nothing is inserted +- **AND** the browser does not navigate away from the page + +#### Scenario: Paste while default upload is disabled + +- **WHEN** `enableDefaultUpload` is `false` and the user pastes an image file +- **THEN** nothing is inserted and no error message is shown + +### Requirement: Dropped and pasted files use the same validation as the Upload tab + +Files arriving by drop or paste SHALL be validated with the same rules and the same maximum file size as the image dialog's Upload tab, from a single shared implementation used by both paths. A file whose size exceeds the maximum SHALL be rejected. A file that is not an image SHALL be rejected. A file that cannot be read SHALL be rejected. Rejected files SHALL NOT be inserted, and valid files in the same drop SHALL still be inserted. + +#### Scenario: Oversized image is rejected + +- **WHEN** the user drops an image file larger than the maximum file size +- **THEN** nothing is inserted +- **AND** a message stating the file is too large, including the file's size, is shown + +#### Scenario: Unreadable file is rejected + +- **WHEN** reading a dropped image file fails +- **THEN** nothing is inserted for that file +- **AND** a read-failure message is shown + +#### Scenario: Valid and invalid files in one drop + +- **WHEN** the user drops one valid image and one oversized image together +- **THEN** the valid image is inserted +- **AND** a rejection message is shown for the oversized one + +### Requirement: Non-image drops and pastes are unaffected + +A drop or paste that carries no `image/*` file SHALL be left entirely to the editor's default handling. Dragging an image element from another page or browser tab (which arrives as HTML rather than as a file) SHALL keep inserting through the editor's normal HTML handling, and pasted HTML SHALL keep passing through existing paste processing such as Word paste cleanup. + +#### Scenario: Non-image file dropped + +- **WHEN** the user drops a file that is not an image, such as a PDF +- **THEN** image drop handling does not claim the event + +#### Scenario: Image dragged from another browser tab + +- **WHEN** the user drags an image element from another tab into the editor +- **THEN** it is inserted by the editor's normal HTML handling, as before this change + +#### Scenario: Pasted Word content + +- **WHEN** the user pastes HTML content copied from Microsoft Word +- **THEN** the existing Word paste cleanup still applies + +### Requirement: Editor shows drop-target and rejection feedback + +While a drag carrying files is over the editor, the editor SHALL show a drop-target affordance, and that affordance SHALL be removed when the drag leaves the editor or the drop completes. When a dropped or pasted file is rejected, the editor SHALL show the reason inline, using the widget's existing translated image error messages, and that message SHALL disappear on its own after a short delay. + +#### Scenario: Drop-target affordance during a drag + +- **WHEN** the user drags a file over the editor body +- **THEN** the editor shows a drop-target affordance + +#### Scenario: Affordance cleared after drop + +- **WHEN** the user drops the file, or drags it back out of the editor +- **THEN** the drop-target affordance is removed + +#### Scenario: Rejection message is translated and transient + +- **WHEN** a file is rejected +- **THEN** the reason is shown using the widget's translated image error text +- **AND** the message clears by itself after a short delay diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/tasks.md new file mode 100644 index 0000000000..ff29df5a81 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-add-image-paste-drop/tasks.md @@ -0,0 +1,68 @@ +## 1. Extract shared image-file helpers + +- [x] 1.1 Create `src/utils/imageFiles.ts` with `MAX_FILE_SIZE` (5MB, moved from `toolbarTypes.ts`), `formatFileSize`, `validateImageFile`, `readFileAsDataUrl`, `pickImageFiles` +- [x] 1.2 Make `validateImageFile` return a translation key + optional argument (`{ key: "image.errorTooLarge", arg: "12.4 MB" }`) instead of an already-translated string, so it works outside React +- [x] 1.3 Update `src/components/toolbars/helpers/toolbarTypes.ts`: remove the `MAX_FILE_SIZE` definition, re-export from `utils/imageFiles` if any other call site still imports it from there +- [x] 1.4 Update `src/components/toolbars/components/ImageDialog.tsx` to use the shared helpers; delete its private `formatFileSize` and `validateFile` +- [x] 1.5 Add `src/utils/__tests__/imageFiles.spec.ts` (oversized, non-image, valid, `pickImageFiles` filtering/order) +- [x] 1.6 Verify `src/components/toolbars/components/__tests__/ImageDialog.spec.tsx` still passes unchanged + +## 2. ImagePasteDrop extension + +- [x] 2.1 Create `src/extensions/ImagePasteDrop.ts` as an `Extension.create` with options `isEnabled: () => boolean`, `isEditable: () => boolean`, `wrapperSelector`, `dragOverClass` +- [x] 2.2 Keep the extension registration-only (like `WordPaste.ts`); put the decision logic in exported pure functions taking `(files, ctx)` so tests need no jsdom `DataTransfer` +- [x] 2.3 Register a ProseMirror `Plugin` with `props.handleDOMEvents` for `drop`, `paste`, `dragover`, `dragenter`, `dragleave` +- [x] 2.4 `drop`/`paste`: return `false` immediately when `pickImageFiles` finds no `image/*` file (leaves HTML slices, cross-tab `` drags and Word paste to ProseMirror) +- [x] 2.5 When image files are present: always `event.preventDefault()`, then return `true` without inserting if `!isEnabled() || !isEditable()` +- [x] 2.6 Capture the target position up front — `view.posAtCoords({left: clientX, top: clientY})?.pos` for drop, `view.state.selection.from` for paste +- [x] 2.7 Validate each file; on failure dispatch the error event and skip that file, continuing with the rest +- [x] 2.8 Read each valid file with `readFileAsDataUrl` and insert sequentially via `insertContentAt`, clamping the position with `Math.min(pos, view.state.doc.content.size)` +- [x] 2.9 `dragover`/`dragenter`: `preventDefault()` when the drag advertises files (required for `drop` to fire on read-only views); maintain an enter/leave counter and toggle `dragOverClass` on the `.tiptap-wrapper` ancestor +- [x] 2.10 `dragleave` decrements the counter; `drop` resets it to `0` so the highlight cannot get stuck +- [x] 2.11 Dispatch rejections as a `richtextImageDropError` CustomEvent on `view.dom` with `{ key, arg }` detail + +## 3. Wire into the editor + +- [x] 3.1 In `src/components/Editor.tsx`, add a `configRef` (same `useMemo` mutable-ref pattern as `actionRef`) holding the current `enableDefaultUpload` and editable state, refreshed each render +- [x] 3.2 Register `ImagePasteDrop.configure({ isEnabled, isEditable, wrapperSelector: ".tiptap-wrapper", dragOverClass })` in the extensions array next to `WordPaste` +- [x] 3.3 In `EditorInner`, listen for `richtextImageDropError` on `editor.view.dom`, hold the detail in state, and clear it on a timer (clear the timer on unmount) +- [x] 3.4 Render the message with `useT()` so `image.errorTooLarge`'s `###` placeholder receives the formatted size +- [x] 3.5 Add drop-target highlight and inline error message styles to `src/ui/RichText.scss` (widget-prefixed; do not reuse the dialog's `.image-dialog-dropzone` classes) + +## 4. Extension tests + +- [x] 4.1 `src/extensions/__tests__/ImagePasteDrop.spec.ts`: valid drop inserts base64 image at the drop position +- [x] 4.2 Multi-file drop inserts in drop order +- [x] 4.3 Oversized file: nothing inserted, `preventDefault` called, `image.errorTooLarge` reported with formatted size +- [x] 4.4 Non-image drop: not handled, `preventDefault` not called +- [x] 4.5 `isEnabled() === false`: `preventDefault` called, nothing inserted, no error reported +- [x] 4.6 `isEditable() === false`: `preventDefault` called, nothing inserted +- [x] 4.7 `dragover` with files calls `preventDefault` +- [x] 4.8 Paste inserts at the selection +- [x] 4.9 Position clamped when the captured position exceeds the document size at resolve time +- [x] 4.10 Drag-over class removed after `drop` following two `dragenter`s +- [x] 4.11 Mixed valid + oversized drop: valid file inserted, rejection reported + +## 5. Manual testing + +- [x] 5.1 Drop a PNG mid-document → inserted at the drop point, not at the previous cursor +- [x] 5.2 Paste a screenshot (Cmd/Ctrl+V) → inserted at the cursor +- [x] 5.3 Drop a >5MB photo → nothing inserted, "too large" message shows the actual size, then fades +- [x] 5.4 Drop a PDF → no image inserted, page does not navigate +- [x] 5.5 Drop three images at once → all three appear, in order +- [x] 5.6 Set "Enable default upload" to false → drop and paste insert nothing, no message, page does not navigate +- [x] 5.7 Read-only editor → drop inserts nothing and the page does not navigate +- [x] 5.8 Drag an image from another browser tab → still inserts (HTML path unchanged) +- [x] 5.9 Paste Word content → Word paste cleanup still applies +- [x] 5.10 Drag over the editor then back out → highlight appears and clears, no stuck outline +- [x] 5.11 Resize a dropped image with the existing handles → works as for dialog-inserted images +- [x] 5.12 Drop while the widget sits inside a Mendix form → no implicit form submit, no navigation + +## 6. Documentation + +- [x] 6.1 Fill the empty `enableDefaultUpload` description in `src/RichText.xml` to state it also controls drag & drop and clipboard paste of images +- [x] 6.2 Add `CHANGELOG.md` entry: images can be dragged & dropped or pasted into the editor; oversized and non-image files are rejected with a message + +## 7. E2E (optional, deferred) + +- [ ] 7.1 Playwright spec building a `DataTransfer` inside `page.evaluate` and dispatching `dragover`/`drop` at the editor body (`setInputFiles` cannot drive a body drop) diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/.openspec.yaml new file mode 100644 index 0000000000..9696e00f11 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/design.md new file mode 100644 index 0000000000..04224faa91 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/design.md @@ -0,0 +1,76 @@ +## Context + +Three places touch an image node's dimensions today: + +1. `src/extensions/ImageResize.ts` — `parseHTML` reads the `width`/`height` HTML attribute, falling back to `element.style.width` / `element.style.height`; `renderHTML` writes the stored value back as a `width`/`height` HTML attribute, verbatim. +2. `src/components/ImageResize.tsx` — the node view. Seeds local `size` state from `node.attrs.width || "auto"`, puts `size` into the `style` of both `.image-container` and the ``, and on drag-end writes `${Math.round(px)}px` back through `updateAttributes`. +3. `src/components/toolbars/components/ImageDialog.tsx` — `toPixelValue` turns the Width/Height inputs into `"300px"` before `setImage`. + +So the stored attribute is a **CSS length string** everywhere in v5, and a **bare number** in v4 content. Only (2) is broken for v4, because a bare number is not a CSS length. + +Value shapes that can reach the node attribute: + +| Origin | Stored value | +| ---------------------------------------------- | ------------ | +| v4 resize / v4 dialog | `"300"` | +| v5 dialog (`toPixelValue`) | `"300px"` | +| v5 resize handles | `"300px"` | +| pasted external HTML with `style="width:50%"` | `"50%"` | +| pasted external HTML with `style="width:20em"` | `"20em"` | +| no size | `null` | + +## Goals / Non-Goals + +Goals: + +- A v4-sized image renders at its stored size in v5, in the editor and in read-only mode. +- One serialized format for image dimensions, valid per HTML. +- No rewrite of existing content beyond what a normal save already does. + +Non-Goals: + +- Migrating stored data in bulk, or touching the attribute at parse time. Parsing stays as-is so a load-without-edit round-trip cannot alter customer data. +- Moving image dimensions to inline `style`. Rejected: a `style` attribute needs `style-src 'unsafe-inline'`, and the widget's `styleDataFormat: "class"` mode exists precisely to support strict CSP. `width`/`height` attributes are CSP-safe and are what both v4 and the HTML spec use for images. +- Changing the image dialog, its Width/Height inputs, or the resize interaction. +- Videos and embeds. `GenericEmbed` parses its dimensions with `parseInt` and stores numbers, and React appends `px` to numeric style values, so they are unaffected. + +## Decisions + +### Normalize at the boundaries, not in the stored attribute + +Two conversions live in `src/utils/imageSize.ts`: + +```ts +/** Stored dimension to a CSS length. v4 wrote bare numbers, which are invalid CSS. */ +toCssLength(value): string | undefined +/** Stored dimension to a valid HTML width/height attribute value. */ +toHtmlDimension(value): string | undefined +``` + +`toCssLength`: `null`/`""`/`undefined` → `undefined`; digits only (`"300"`, `"300.5"`) → `"300px"`; anything else (`"300px"`, `"50%"`, `"20em"`, `"auto"`) → unchanged. + +`toHtmlDimension`: `null`/`""`/`undefined` → `undefined`; digits only → unchanged; digits + `px` → digits; `%` → unchanged (browsers legacy-parse a percentage dimension attribute, and dropping it would silently resize the image); any other unit or keyword → `undefined`, because the attribute cannot express it and a bogus attribute value is worse than none. + +Alternative considered: normalize in `parseHTML` (`"300"` → `"300px"`). Rejected — it rewrites v4 content on the first save even when the user changed nothing else, and it puts the compatibility shim in the one place that also runs for every paste. + +### The node view is the only CSS consumer + +`ImageResize.tsx` passes every dimension through `toCssLength` — the `useState` seed, the effect that syncs from `node.attrs`, and both `style` objects. The resize drag path already measures with `getBoundingClientRect()` and writes `px`, so it needs no change: once the initial render is correct, dragging a v4 image starts from its real rendered size and stores a v5-shaped value. + +`"auto"` stays the local fallback for "no stored size" and stays out of the attribute. + +### Serialization is spec-valid and version-neutral + +`renderHTML` passes each dimension through `toHtmlDimension`. Consequences, deliberate: + +- v4 content loaded and saved with no size edit serializes to `width="300"` — identical to its input. +- v5 content previously stored as `width="300px"` serializes to `width="300"` on the next save. Rendering does not change: browsers legacy-parse both to 300. +- A `%` width from pasted HTML keeps its `%`. +- An unrepresentable value (`"20em"`) is dropped from the attribute rather than emitted as a value browsers would mis-parse (`20em` legacy-parses to 20 — twenty pixels, a silent shrink). The image falls back to natural size, which is the honest outcome. + +### Test cases + +- `toCssLength`: `"300"` → `"300px"`, `"300.5"` → `"300.5px"`, `"300px"` → `"300px"`, `"50%"` → `"50%"`, `"auto"` → `"auto"`, `null`/`""` → `undefined`. +- `toHtmlDimension`: `"300"` → `"300"`, `"300px"` → `"300"`, `"50%"` → `"50%"`, `"20em"` → `undefined`, `"auto"` → `undefined`, `null` → `undefined`. +- Round-trip through a real `Editor`: v4 `` parses to `"300"`/`"200"` and serializes back to `width="300" height="200"`; v5 `` serializes to `width="300"`; `` serializes to `width="300"`. +- Node view render (RTL): a node with `width: "300"` renders an `` whose inline style width is `300px`; a node with `width: "300px"` likewise; a node with no width renders `auto`. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/proposal.md new file mode 100644 index 0000000000..390a596b70 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/proposal.md @@ -0,0 +1,51 @@ +## Why + +Images sized in Rich Text v4 lose their size when the content is opened in v5. They render at natural size in both the editor and the read-only view, so existing app content visibly changes on upgrade — a 300px thumbnail becomes a full-bleed photo. + +The stored data is not lost. v4 wrote the size as unitless HTML attributes, because the Quill resize module was configured with `attribute: ["width", "height"]` and no unit (`src/utils/modules/resize.ts` before the TipTap rewrite: `if (limit.unit) { res.width = width + "px" } else { res.width = width }`). So v4 content is ``, and `ImageResize.ts` already parses those attributes into the node's `width`/`height` attributes as `"300"` / `"200"`. + +The break is in the node view. `src/components/ImageResize.tsx` feeds the attribute value straight into CSS: + +```tsx +
+ +``` + +`width: 300` is not valid CSS, so React drops the declaration. The node view never sets the `width`/`height` HTML attributes on its `` either, so nothing constrains the image and it renders at natural size. v5's own values carry a unit (`"300px"`, written by the image dialog's `toPixelValue` and by the resize handles) and therefore work. Read-only mode uses the same node view, so both edit and read panel are affected. + +Verified round-trip in the current code: + +| Input HTML | node attrs | `getHTML()` | +| ------------------------------------- | ----------------- | -------------------------- | +| `` (v4) | `"300"` / `"200"` | `width="300" height="200"` | +| `` (v5) | `"300px"` | `width="300px"` | +| `` | `"300px"` | `width="300px"` | + +The third row is the second, smaller problem: `` is defined as a valid non-negative integer, so `width="300px"` is invalid HTML. Browsers apply the legacy "rules for parsing dimension values" and land on 300, so it renders correctly today, but it means v4 and v5 write two different formats for the same thing and v5's output is not spec-valid. + +## What Changes + +- New `src/utils/imageSize.ts` with two conversions: a stored dimension to a CSS length (unitless `300` becomes `300px`), and a stored dimension to a valid HTML dimension attribute value (`300px` becomes `300`). +- `src/components/ImageResize.tsx` applies the CSS conversion wherever it puts a dimension into `style`, so v4 unitless values render at their stored size. +- `src/extensions/ImageResize.ts` `renderHTML` emits the HTML-attribute conversion, so serialized images use one spec-valid format regardless of which version wrote them. v4 content that is loaded and saved untouched serializes byte-identically to its input. +- Image dimensions stay in `width`/`height` **attributes**, not inline `style`. Attributes survive a strict CSP, which is the point of the existing `styleDataFormat: "class"` mode. +- No data migration, no new widget properties, no new translation keys, no change to the image dialog or to the resize interaction. + +## Capabilities + +### New Capabilities + +- `rich-text-image-sizing`: how an image node's stored `width`/`height` are interpreted when rendering and how they are serialized back to HTML, including v4 unitless attribute values. + +### Modified Capabilities + + + +## Impact + +- Affected code: `src/utils/imageSize.ts` (new), `src/components/ImageResize.tsx`, `src/extensions/ImageResize.ts`. +- User-facing: images sized in v4 keep that size after upgrading to v5, in edit and read-only mode. Serialized `width`/`height` values lose the `px` suffix v5 was writing; rendering is unaffected, since browsers parsed both to the same number. +- Not affected: videos and embeds. `GenericEmbed` and `YouTubeResize` store dimensions as numbers, and React appends `px` to numeric style values, so they never hit this bug. +- Testing: unit tests for the two conversions, parse/serialize round-trip tests for v4 unitless, v5 pixel-string, and inline-style input, and a node view render test asserting the applied inline style. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/specs/rich-text-image-sizing/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/specs/rich-text-image-sizing/spec.md new file mode 100644 index 0000000000..9c95c037c1 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/specs/rich-text-image-sizing/spec.md @@ -0,0 +1,84 @@ +## ADDED Requirements + +### Requirement: A stored image dimension is rendered as a CSS length + +The Rich Text editor SHALL render an image at its stored `width`/`height` regardless of whether the stored value carries a CSS unit. A value consisting of digits only SHALL be treated as pixels. A value that already carries a unit or a percentage SHALL be used as it is. This SHALL apply in the editor and in read-only mode, both of which use the same image node view. + +#### Scenario: Image sized by Rich Text v4 + +- **GIVEN** content saved by Rich Text v4 containing `` +- **WHEN** the content is opened in the Rich Text widget +- **THEN** the image renders 300 pixels wide and 200 pixels tall +- **AND** it renders at that same size when the widget is read-only + +#### Scenario: Image sized by Rich Text v5 + +- **GIVEN** content containing `` +- **WHEN** the content is opened in the Rich Text widget +- **THEN** the image renders 300 pixels wide + +#### Scenario: Image sized with a percentage + +- **GIVEN** content containing an image whose stored width is `50%` +- **WHEN** the content is opened in the Rich Text widget +- **THEN** the image renders at 50% of its container width + +#### Scenario: Image with no stored size + +- **WHEN** an image node has no stored `width` or `height` +- **THEN** the image renders at its natural size + +### Requirement: Image dimensions are serialized as valid HTML attribute values + +The widget SHALL serialize an image's dimensions as `width` and `height` HTML attributes, not as inline `style`, so that generated content is usable under a strict Content Security Policy. A pixel dimension SHALL be written as a bare number without the `px` suffix, matching the HTML definition of a dimension attribute. A percentage SHALL be written as it is. A dimension that a `width`/`height` attribute cannot express SHALL be omitted rather than written in a form a browser would mis-parse. + +#### Scenario: Pixel dimension serialized + +- **GIVEN** an image node whose stored width is `300px` +- **WHEN** the content is serialized +- **THEN** the output contains `width="300"` +- **AND** the output contains no inline `style` for the image's size + +#### Scenario: Percentage dimension serialized + +- **GIVEN** an image node whose stored width is `50%` +- **WHEN** the content is serialized +- **THEN** the output contains `width="50%"` + +#### Scenario: Dimension that an attribute cannot express + +- **GIVEN** an image node whose stored width is `20em` +- **WHEN** the content is serialized +- **THEN** no `width` attribute is written for that image + +#### Scenario: No stored size + +- **WHEN** an image node has no stored `width` or `height` +- **THEN** no `width` or `height` attribute is written for that image + +### Requirement: Loading and saving v4 content does not change its stored size + +Opening content written by Rich Text v4 and saving it without editing any image SHALL leave each image's `width`/`height` attribute value unchanged. Parsing SHALL NOT rewrite the stored dimension; unit normalization happens only when rendering to CSS and when serializing. + +#### Scenario: v4 content saved untouched + +- **GIVEN** content containing `` +- **WHEN** the content is opened and saved without resizing the image +- **THEN** the saved image still has `width="300"` and `height="200"` + +#### Scenario: Inline style input is not preserved as style + +- **GIVEN** content containing `` +- **WHEN** the content is opened and saved +- **THEN** the saved image carries `width="300"` and `height="200"` as attributes + +### Requirement: A v4-sized image resizes from its rendered size + +Resizing an image that was sized by Rich Text v4 SHALL start from the size it currently renders at, not from the image's natural size, and SHALL store the new dimensions in the same form as any other v5 resize. + +#### Scenario: Dragging a handle on a v4-sized image + +- **GIVEN** an image whose stored width is `300` and which renders 300 pixels wide +- **WHEN** the user drags a resize handle 50 pixels outward +- **THEN** the image ends up approximately 350 pixels wide, not 50 pixels wider than its natural size +- **AND** the stored width is a pixel dimension that serializes to `width="350"` diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/tasks.md new file mode 100644 index 0000000000..9c9c4aa857 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/fix-v4-image-size-compat/tasks.md @@ -0,0 +1,42 @@ +## 1. Dimension conversion helpers + +- [x] 1.1 Create `src/utils/imageSize.ts` with `toCssLength(value: string | number | null | undefined): string | undefined` — digits only becomes `${value}px`, values with a unit or `%` pass through, empty/null becomes `undefined` +- [x] 1.2 Add `toHtmlDimension(value: string | number | null | undefined): string | undefined` — digits only passes through, digits + `px` drops the suffix, `%` passes through, any other unit or keyword becomes `undefined` +- [x] 1.3 Add `src/utils/__tests__/imageSize.spec.ts` covering the table in `design.md` for both functions, including `"300.5"`, `"auto"`, `"20em"`, `null` and `""` + +## 2. Node view renders a CSS length + +- [x] 2.1 In `src/components/ImageResize.tsx`, pass the `useState` seed for `size` through `toCssLength`, keeping `"auto"` as the fallback when there is no stored value +- [x] 2.2 Pass the `node.attrs` sync effect's `nextSize` through `toCssLength` as well, so an external attribute update (undo, `setContent`) also renders correctly +- [x] 2.3 Pass the `.image-container` and `` `style` dimensions through `toCssLength` (or use the already-converted `size` state, if 2.1/2.2 cover every path) +- [x] 2.4 Leave the resize drag path unchanged — it measures with `getBoundingClientRect()` and already writes `px` + +## 3. Serialization + +- [x] 3.1 In `src/extensions/ImageResize.ts`, run the `width` `renderHTML` value through `toHtmlDimension` and omit the attribute when the result is `undefined` +- [x] 3.2 Do the same for `height` +- [x] 3.3 Leave `parseHTML` untouched, so loading content never rewrites the stored value + +## 4. Tests + +- [x] 4.1 Add `src/extensions/__tests__/ImageResize.spec.ts` building a real `Editor` with `StarterKit` + `ImageResize` +- [x] 4.2 Test: v4 `` parses to `"300"`/`"200"` and serializes back to `width="300" height="200"` +- [x] 4.3 Test: v5 `` serializes to `width="300"` +- [x] 4.4 Test: `` serializes to `width="300" height="200"` and no inline `style` +- [x] 4.5 Test: a `50%` width survives as `width="50%"`; a `20em` width produces no `width` attribute +- [x] 4.6 Test: an image with no size produces neither attribute +- [x] 4.7 Add a node view test (RTL, rendering the `ImageResize` component with a stubbed `NodeViewProps`) asserting the `` inline style width is `300px` for a stored `"300"`, for a stored `"300px"`, and `auto` when unset +- [x] 4.8 Run `pnpm run test` and confirm no existing suite regresses + +## 5. Manual testing + +- [ ] 5.1 Open a page whose attribute holds v4 content with a resized image — image keeps its v4 size in the editor +- [ ] 5.2 Same content with the widget read-only — image keeps its v4 size +- [ ] 5.3 Resize that v4 image — drag starts from the rendered size, result looks right, size persists after a page reload +- [ ] 5.4 Insert a new image via the dialog with Width 300 — renders 300px, and the saved HTML has `width="300"` +- [ ] 5.5 Open existing v5 content with `width="300px"` — unchanged on screen, `width="300"` after the next save +- [ ] 5.6 Check the read-only ("Read panel") mode and fullscreen mode for the same content + +## 6. Documentation + +- [x] 6.1 Add `CHANGELOG.md` entry under `## [Unreleased]` → `### Fixed`: images resized in Rich Text 4 keep their size after upgrading to version 5 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-paste-drop/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-paste-drop/spec.md new file mode 100644 index 0000000000..2f32a55ac4 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/specs/rich-text-image-paste-drop/spec.md @@ -0,0 +1,129 @@ +# rich-text-image-paste-drop Specification + +## Purpose + +TBD - created by archiving change add-image-paste-drop. Update Purpose after archive. + +## Requirements + +### Requirement: Dropped image files are inserted as base64 images + +When one or more `image/*` files are dropped onto the Rich Text editor body, each valid file SHALL be inserted into the document as an image node whose `src` is a base64 `data:` URI. Insertion SHALL happen at the document position under the drop coordinates, not at the position of the selection before the drop. When several valid image files are dropped at once, they SHALL be inserted in the order they appear in the drop, at that position. Inserted images SHALL NOT be given `width` or `height` attributes; they keep their natural size and remain resizable afterwards. + +#### Scenario: Single image dropped into the editor + +- **WHEN** the user drops a valid image file onto the editor body +- **THEN** an image node with a base64 `data:` URI `src` is inserted at the drop position +- **AND** the browser does not navigate away from the page + +#### Scenario: Image dropped away from the cursor + +- **WHEN** the selection is in the first paragraph and the user drops a valid image over the third paragraph +- **THEN** the image is inserted at the third paragraph, not at the first + +#### Scenario: Multiple images dropped at once + +- **WHEN** the user drops three valid image files in a single drop +- **THEN** three image nodes are inserted in the same order as the dropped files + +#### Scenario: Dropped image keeps its natural size + +- **WHEN** a valid image file is dropped +- **THEN** the inserted image node has no `width` or `height` attribute + +### Requirement: Pasted image files are inserted as base64 images + +When the clipboard contains one or more `image/*` files (for example a screenshot), pasting into the Rich Text editor SHALL insert each valid file as an image node whose `src` is a base64 `data:` URI, at the current selection. The same validation, ordering, and sizing rules as dropped images SHALL apply. + +#### Scenario: Screenshot pasted into the editor + +- **WHEN** the clipboard holds an image file and the user pastes into the editor +- **THEN** an image node with a base64 `data:` URI `src` is inserted at the selection + +#### Scenario: Pasted image replaces the selected range + +- **WHEN** a range of text is selected and an image file is pasted +- **THEN** the image is inserted at that range's position + +### Requirement: Image drop and paste are gated on default upload and edit state + +Image drop and paste SHALL be available only when the `enableDefaultUpload` property is `true` and the editor is editable. When `enableDefaultUpload` is `false`, or the editor is read-only, an image file drop or paste SHALL insert nothing and SHALL NOT show an error message. In every case where an image file drop is detected — including the disabled and read-only cases — the browser's default handling SHALL be suppressed, so dropping a file can never navigate the page away or discard unsaved form data. + +#### Scenario: Default upload disabled + +- **WHEN** `enableDefaultUpload` is `false` and the user drops a valid image file onto the editor +- **THEN** nothing is inserted +- **AND** no error message is shown +- **AND** the browser does not navigate away from the page + +#### Scenario: Read-only editor + +- **WHEN** the editor is read-only and the user drops a valid image file onto it +- **THEN** nothing is inserted +- **AND** the browser does not navigate away from the page + +#### Scenario: Paste while default upload is disabled + +- **WHEN** `enableDefaultUpload` is `false` and the user pastes an image file +- **THEN** nothing is inserted and no error message is shown + +### Requirement: Dropped and pasted files use the same validation as the Upload tab + +Files arriving by drop or paste SHALL be validated with the same rules and the same maximum file size as the image dialog's Upload tab, from a single shared implementation used by both paths. A file whose size exceeds the maximum SHALL be rejected. A file that is not an image SHALL be rejected. A file that cannot be read SHALL be rejected. Rejected files SHALL NOT be inserted, and valid files in the same drop SHALL still be inserted. + +#### Scenario: Oversized image is rejected + +- **WHEN** the user drops an image file larger than the maximum file size +- **THEN** nothing is inserted +- **AND** a message stating the file is too large, including the file's size, is shown + +#### Scenario: Unreadable file is rejected + +- **WHEN** reading a dropped image file fails +- **THEN** nothing is inserted for that file +- **AND** a read-failure message is shown + +#### Scenario: Valid and invalid files in one drop + +- **WHEN** the user drops one valid image and one oversized image together +- **THEN** the valid image is inserted +- **AND** a rejection message is shown for the oversized one + +### Requirement: Non-image drops and pastes are unaffected + +A drop or paste that carries no `image/*` file SHALL be left entirely to the editor's default handling. Dragging an image element from another page or browser tab (which arrives as HTML rather than as a file) SHALL keep inserting through the editor's normal HTML handling, and pasted HTML SHALL keep passing through existing paste processing such as Word paste cleanup. + +#### Scenario: Non-image file dropped + +- **WHEN** the user drops a file that is not an image, such as a PDF +- **THEN** image drop handling does not claim the event + +#### Scenario: Image dragged from another browser tab + +- **WHEN** the user drags an image element from another tab into the editor +- **THEN** it is inserted by the editor's normal HTML handling, as before this change + +#### Scenario: Pasted Word content + +- **WHEN** the user pastes HTML content copied from Microsoft Word +- **THEN** the existing Word paste cleanup still applies + +### Requirement: Editor shows drop-target and rejection feedback + +While a drag carrying files is over the editor, the editor SHALL show a drop-target affordance, and that affordance SHALL be removed when the drag leaves the editor or the drop completes. When a dropped or pasted file is rejected, the editor SHALL show the reason inline, using the widget's existing translated image error messages, and that message SHALL disappear on its own after a short delay. + +#### Scenario: Drop-target affordance during a drag + +- **WHEN** the user drags a file over the editor body +- **THEN** the editor shows a drop-target affordance + +#### Scenario: Affordance cleared after drop + +- **WHEN** the user drops the file, or drags it back out of the editor +- **THEN** the drop-target affordance is removed + +#### Scenario: Rejection message is translated and transient + +- **WHEN** a file is rejected +- **THEN** the reason is shown using the widget's translated image error text +- **AND** the message clears by itself after a short delay diff --git a/packages/pluggableWidgets/rich-text-web/src/RichText.xml b/packages/pluggableWidgets/rich-text-web/src/RichText.xml index 2f8758f423..d12053164a 100644 --- a/packages/pluggableWidgets/rich-text-web/src/RichText.xml +++ b/packages/pluggableWidgets/rich-text-web/src/RichText.xml @@ -201,7 +201,7 @@ Enable default upload - + Allows uploading images from the device. This covers the image dialog's upload tab, dragging and dropping image files onto the editor, and pasting images from the clipboard. Uploaded images are embedded in the content as base64. Status bar content diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx index d8d10f1ad7..9af748034d 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx @@ -7,7 +7,16 @@ import { TaskList } from "@tiptap/extension-task-list"; import { TextStyle } from "@tiptap/extension-text-style"; import { EditorContent, useEditor } from "@tiptap/react"; import { StarterKit } from "@tiptap/starter-kit"; -import { CSSProperties, forwardRef, ReactElement, useEffect, useImperativeHandle, useMemo } from "react"; +import { + CSSProperties, + forwardRef, + ReactElement, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from "react"; import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; import { EditorContextProvider, useCurrentEditor } from "./EditorContext"; import { HighlightedCodeEditor } from "./HighlightedCodeEditor"; @@ -18,6 +27,7 @@ import { FontFamilyClass } from "../extensions/FontFamilyClass"; import { FontSize } from "../extensions/FontSize"; import { Fullscreen } from "../extensions/Fullscreen"; import { GenericEmbed } from "../extensions/GenericEmbed"; +import { ImagePasteDrop, IMAGE_DROP_ERROR_EVENT } from "../extensions/ImagePasteDrop"; import { ImageResize } from "../extensions/ImageResize"; import { Indent } from "../extensions/Indent"; import { KeyboardNavigation } from "../extensions/KeyboardNavigation"; @@ -32,9 +42,13 @@ import { TextHighlightClass } from "../extensions/TextHighlightClass"; import { WordPaste } from "../extensions/WordPaste"; import { YouTubeResize } from "../extensions/YouTubeResize"; import { TranslationProvider, useT } from "../utils/i18n"; +import { ImageFileError } from "../utils/imageFiles"; import { ConfirmDialog } from "./toolbars/components/ConfirmDialog"; import { ToolbarGroupsConfig } from "./toolbars/ToolbarConfig"; +/** How long a rejected drop/paste message stays on screen. */ +const DROP_ERROR_TIMEOUT_MS = 5000; + export interface EditorProps extends Pick< RichTextContainerProps, | "styleDataFormat" @@ -95,6 +109,32 @@ function EditorInner({ }: EditorInnerProps): ReactElement { const { editor, codeViewState, codeViewDispatch } = useCurrentEditor(); const t = useT(); + // Rejected image drops/pastes are reported by the ImagePasteDrop plugin as a + // DOM event (it runs outside React, where `useT` is unavailable) and are + // translated and rendered here. + const [dropError, setDropError] = useState(null); + + useEffect(() => { + if (!editor) { + return; + } + const dom = editor.view.dom; + const handleDropError = (event: Event): void => { + setDropError((event as CustomEvent).detail); + }; + + dom.addEventListener(IMAGE_DROP_ERROR_EVENT, handleDropError); + return () => dom.removeEventListener(IMAGE_DROP_ERROR_EVENT, handleDropError); + }, [editor]); + + useEffect(() => { + if (!dropError) { + return; + } + const timer = window.setTimeout(() => setDropError(null), DROP_ERROR_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [dropError]); + const handleSaveCode = (): void => { if (!editor) return; @@ -151,6 +191,11 @@ function EditorInner({ {!readOnly && } )} + {dropError && ( +
+ {dropError.arg ? t(dropError.key, dropError.arg) : t(dropError.key)} +
+ )}
{codeViewState.showConfirm && ( ((props, ref) => { [props] ); + // Read by the ImagePasteDrop plugin at event time. Extension options are frozen + // when the editor is created (`useEditor(..., [])`), and @tiptap/react + // deliberately keeps the editor's current `editable` on re-render, so neither + // `configure()` values nor `editor.isEditable` follow these props. A ref + // reassigned on every render does. + const configRef = useRef({ enableDefaultUpload, editable: !readOnly }); + configRef.current = { enableDefaultUpload, editable: !readOnly }; + const extensions = useMemo( () => [ StarterKit.configure({ @@ -276,7 +329,13 @@ const Editor = forwardRef((props, ref) => { class: "tiptap-embed" } }), - WordPaste + WordPaste, + ImagePasteDrop.configure({ + isEnabled: () => configRef.current.enableDefaultUpload, + isEditable: () => configRef.current.editable, + wrapperSelector: ".tiptap-wrapper", + dragOverClass: "rich-text-drag-over" + }) ], [styleDataFormat] ); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/ImageResize.tsx b/packages/pluggableWidgets/rich-text-web/src/components/ImageResize.tsx index 6bead47aa8..090dd6c0b9 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/ImageResize.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/ImageResize.tsx @@ -1,27 +1,31 @@ import { NodeViewWrapper, NodeViewProps } from "@tiptap/react"; import { useState, useRef, useEffect, ReactElement } from "react"; +import { toCssLength } from "../utils/imageSize"; + +/** Stored dimensions as CSS lengths. Rich Text 4 stored bare numbers, which CSS ignores. */ +function cssSize(node: NodeViewProps["node"]): { width: string; height: string } { + return { + width: toCssLength(node.attrs.width) ?? "auto", + height: toCssLength(node.attrs.height) ?? "auto" + }; +} export function ImageResize(props: NodeViewProps): ReactElement { const { node, updateAttributes } = props; const [isResizing, setIsResizing] = useState(false); - const [size, setSize] = useState({ - width: node.attrs.width || "auto", - height: node.attrs.height || "auto" - }); + const [size, setSize] = useState(() => cssSize(node)); const imgRef = useRef(null); const startPos = useRef({ x: 0, y: 0, width: 0, height: 0 }); const currentSize = useRef({ width: size.width, height: size.height }); useEffect(() => { if (node.attrs.width) { - const nextSize = { - width: node.attrs.width, - height: node.attrs.height || "auto" - }; - // eslint-disable-next-line react-hooks/set-state-in-effect + const nextSize = cssSize(node); setSize(nextSize); currentSize.current = nextSize; } + // `node` is a new object on every transaction; the dimensions are what matters. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [node.attrs.width, node.attrs.height]); const handleMouseDown = (e: globalThis.React.MouseEvent, corner: string): void => { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/__tests__/ImageResize.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/__tests__/ImageResize.spec.tsx new file mode 100644 index 0000000000..86f9937387 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/__tests__/ImageResize.spec.tsx @@ -0,0 +1,53 @@ +import { render } from "@testing-library/react"; +import { NodeViewProps } from "@tiptap/react"; +import { ImageResize } from "../ImageResize"; + +/** Renders the node view in isolation with fabricated props, as YouTubeResize's test does. */ +function renderNodeView(attrs: Record = {}): HTMLElement { + const props = { + node: { attrs: { src: "a.png", alt: null, width: null, height: null, ...attrs } }, + updateAttributes: jest.fn() + } as unknown as NodeViewProps; + + const { container } = render(); + return container; +} + +function imageStyle(container: HTMLElement): CSSStyleDeclaration { + return (container.querySelector("img") as HTMLImageElement).style; +} + +describe("ImageResize node view sizing", () => { + it("renders the unitless dimensions of Rich Text 4 content as pixels", () => { + const style = imageStyle(renderNodeView({ width: "300", height: "200" })); + + expect(style.width).toBe("300px"); + expect(style.height).toBe("200px"); + }); + + it("renders the pixel strings of Rich Text 5 content unchanged", () => { + const style = imageStyle(renderNodeView({ width: "300px", height: "200px" })); + + expect(style.width).toBe("300px"); + expect(style.height).toBe("200px"); + }); + + it("keeps a percentage width", () => { + expect(imageStyle(renderNodeView({ width: "50%" })).width).toBe("50%"); + }); + + it("falls back to auto when no size is stored", () => { + const style = imageStyle(renderNodeView()); + + expect(style.width).toBe("auto"); + expect(style.height).toBe("auto"); + }); + + it("sizes the container along with the image", () => { + const container = renderNodeView({ width: "300", height: "200" }); + const box = container.querySelector(".image-container") as HTMLElement; + + expect(box.style.width).toBe("300px"); + expect(box.style.height).toBe("200px"); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx index 5f5da0f595..904ef79d3e 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx @@ -2,19 +2,14 @@ import classNames from "classnames"; import { ReactElement, useState, useEffect, KeyboardEvent } from "react"; import { useDropzone } from "react-dropzone"; import { DialogShell } from "./DialogShell"; -import { useT, TranslateFn } from "../../../utils/i18n"; +import { useT } from "../../../utils/i18n"; +import { MAX_FILE_SIZE, formatFileSize, readFileAsDataUrl, validateImageFile } from "../../../utils/imageFiles"; import { useCurrentEditor } from "../../EditorContext"; -import { ImageDialogProps, EntityImage, ImageSourceMode, MAX_FILE_SIZE } from "../helpers/toolbarTypes"; +import { ImageDialogProps, EntityImage, ImageSourceMode } from "../helpers/toolbarTypes"; import "./Dialog.scss"; const TITLE_ID = "rich-text-image-dialog-title"; -const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; - const toPixelValue = (value: string): string | undefined => { const parsed = Number(value); if (!value.trim() || !Number.isFinite(parsed) || parsed <= 0) { @@ -23,16 +18,6 @@ const toPixelValue = (value: string): string | undefined => { return `${parsed}px`; }; -const validateFile = (file: File, t: TranslateFn): string | null => { - if (file.size > MAX_FILE_SIZE) { - return t("image.errorTooLarge", formatFileSize(file.size)); - } - if (!file.type.startsWith("image/")) { - return t("image.errorNotImage"); - } - return null; -}; - export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): ReactElement { const { editor, imageConfig, dialogStyle } = useCurrentEditor(); const { imageSourceContent, enableDefaultUpload, hasImageSource } = imageConfig; @@ -87,23 +72,22 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re } const file = acceptedFiles[0]; - const error = validateFile(file, t); + const error = validateImageFile(file); if (error) { - setDragError(error); + setDragError(error.arg ? t(error.key, error.arg) : t(error.key)); return; } - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result as string; - setSrc(base64); - setUploadedFile(file); - }; - reader.onerror = () => { - setDragError(t("image.errorReadFailed")); - }; - reader.readAsDataURL(file); + readFileAsDataUrl(file).then( + base64 => { + setSrc(base64); + setUploadedFile(file); + }, + () => { + setDragError(t("image.errorReadFailed")); + } + ); }; const handleClearFile = (): void => { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts index b4fb802d2f..1701aa3c77 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/toolbarTypes.ts @@ -104,11 +104,6 @@ export type ImageSourceMode = "url" | "upload" | "entity"; // Constants // ============================================================================ -/** - * Maximum file size for image uploads (5MB) - */ -export const MAX_FILE_SIZE = 5 * 1024 * 1024; - /** * Maximum table dimensions for TableGridSelector */ diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts new file mode 100644 index 0000000000..ef28b3f8a3 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/ImagePasteDrop.ts @@ -0,0 +1,230 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import { ImageFileError, pickImageFiles, readFileAsDataUrl, validateImageFile } from "../utils/imageFiles"; + +/** + * Inserts image files dropped onto or pasted into the editor as base64 images, + * matching the image dialog's Upload tab (same validation, same `data:` URI). + * + * Handlers are registered through `handleDOMEvents` rather than `handleDrop`/ + * `handlePaste` on purpose. ProseMirror dispatches the latter through + * `handlers[event.type]`, which is gated on `view.editable`, and its + * `dragover`/`dragenter` preventDefault sits behind the same gate — so on a + * read-only editor the drop would reach the browser, which navigates the whole + * page to the dropped file and discards unsaved form data. `handleDOMEvents` + * runs ahead of that gate, so this extension can neutralise the event in every + * state, including when uploading is disabled. + * + * The decision and insertion logic are exported as plain functions so they can + * be tested without jsdom's stub `DataTransfer`; the plugin below is only + * registration and DOM plumbing. + */ + +/** Fired on the editor DOM node when a dropped or pasted file is rejected. */ +export const IMAGE_DROP_ERROR_EVENT = "richtextImageDropError"; + +export interface ImagePasteDropOptions { + /** Whether the widget's "Enable default upload" property is on. */ + isEnabled: () => boolean; + /** Whether the editor currently accepts edits. */ + isEditable: () => boolean; + /** Ancestor of the editor DOM that carries the drag-over class. */ + wrapperSelector: string; + dragOverClass: string; +} + +export type ImageEventDecision = + /** No image file present: leave the event to ProseMirror. */ + | "ignore" + /** Image file present but insertion not allowed: neutralise, insert nothing. */ + | "swallow" + | "insert"; + +export interface ImageInsertContext { + insertImage: (src: string, pos: number) => void; + docSize: () => number; + reportError: (error: ImageFileError) => void; +} + +export function decideImageEvent( + files: File[], + gate: Pick +): ImageEventDecision { + if (files.length === 0) { + return "ignore"; + } + return gate.isEnabled() && gate.isEditable() ? "insert" : "swallow"; +} + +/** + * Document position under the drop, falling back to the selection. `posAtCoords` + * needs layout APIs (`elementFromPoint`); the event has already been prevented by + * the time it is called, so a throw there would silently lose the image. + */ +export function dropPosition(view: EditorView, coords: { clientX: number; clientY: number }): number { + try { + return view.posAtCoords({ left: coords.clientX, top: coords.clientY })?.pos ?? view.state.selection.from; + } catch { + return view.state.selection.from; + } +} + +/** True when a drag advertises files. `dataTransfer.files` is empty during a drag. */ +export function dragCarriesFiles(dataTransfer: DataTransfer | null): boolean { + return Array.from(dataTransfer?.types ?? []).includes("Files"); +} + +/** + * Reads and inserts each file in order. Invalid or unreadable files are + * reported and skipped; the remaining files are still inserted. + * + * Reading is asynchronous while the DOM handler must answer synchronously, so + * the target position is captured by the caller before the read starts. The + * document can change in between, so every insert clamps to the current + * document size. + */ +export async function insertImageFiles(files: File[], pos: number, ctx: ImageInsertContext): Promise { + let at = pos; + + for (const file of files) { + const error = validateImageFile(file); + if (error) { + ctx.reportError(error); + continue; + } + + let src: string; + try { + src = await readFileAsDataUrl(file); + } catch { + ctx.reportError({ key: "image.errorReadFailed" }); + continue; + } + + const target = Math.min(at, ctx.docSize()); + ctx.insertImage(src, target); + // An image node has size 1, so the next file lands after this one and + // multiple dropped files keep their drop order. + at = target + 1; + } +} + +export const ImagePasteDrop = Extension.create({ + name: "imagePasteDrop", + + addOptions() { + return { + isEnabled: () => true, + isEditable: () => true, + wrapperSelector: ".tiptap-wrapper", + dragOverClass: "rich-text-drag-over" + }; + }, + + addProseMirrorPlugins() { + const options = this.options; + const editor = this.editor; + // dragenter/dragleave fire for every descendant the pointer crosses, so + // a plain toggle flickers over text. Count enters instead. + let dragDepth = 0; + + const wrapperOf = (dom: HTMLElement): HTMLElement => + (dom.closest(options.wrapperSelector) as HTMLElement | null) ?? dom; + + const setDragOver = (dom: HTMLElement, active: boolean): void => { + wrapperOf(dom).classList.toggle(options.dragOverClass, active); + }; + + const context = (): ImageInsertContext => ({ + insertImage: (src, pos) => { + editor.commands.insertContentAt(pos, { type: "image", attrs: { src } }); + }, + docSize: () => editor.state.doc.content.size, + reportError: error => { + editor.view.dom.dispatchEvent(new CustomEvent(IMAGE_DROP_ERROR_EVENT, { detail: error })); + } + }); + + return [ + new Plugin({ + key: new PluginKey("imagePasteDrop"), + props: { + handleDOMEvents: { + dragenter: (view, event) => { + if (!dragCarriesFiles(event.dataTransfer)) { + return false; + } + // Prevented even when insertion is not allowed: without it the + // browser owns the drop on a read-only view. + event.preventDefault(); + dragDepth += 1; + if (options.isEnabled() && options.isEditable()) { + setDragOver(view.dom as HTMLElement, true); + } + return true; + }, + dragover: (_view, event) => { + if (!dragCarriesFiles(event.dataTransfer)) { + return false; + } + event.preventDefault(); + return true; + }, + dragleave: (view, event) => { + if (!dragCarriesFiles(event.dataTransfer)) { + return false; + } + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + setDragOver(view.dom as HTMLElement, false); + } + return false; + }, + drop: (view, event) => { + const files = pickImageFiles(event.dataTransfer?.files); + const decision = decideImageEvent(files, options); + if (decision === "ignore") { + return false; + } + + event.preventDefault(); + dragDepth = 0; + setDragOver(view.dom as HTMLElement, false); + + if (decision === "swallow") { + return true; + } + + insertImageFiles(files, dropPosition(view, event), context()); + return true; + }, + paste: (view, event) => { + // Rich content pasted from Word or Google Docs can carry an image + // file alongside its HTML. That HTML is the paste the user means, + // and it still has to reach `WordPaste`, so only a file-only + // clipboard (a screenshot, a copied image file) is intercepted. + if (Array.from(event.clipboardData?.types ?? []).includes("text/html")) { + return false; + } + + const files = pickImageFiles(event.clipboardData?.files); + const decision = decideImageEvent(files, options); + if (decision === "ignore") { + return false; + } + + event.preventDefault(); + if (decision === "swallow") { + return true; + } + + insertImageFiles(files, view.state.selection.from, context()); + return true; + } + } + } + }) + ]; + } +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts index fa858cb139..a02e1a90bd 100644 --- a/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/ImageResize.ts @@ -1,6 +1,7 @@ import { Image } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { ImageResize as ImageResizeComponent } from "../components/ImageResize"; +import { toHtmlDimension } from "../utils/imageSize"; export type ImageResizeOptions = { inline?: boolean; @@ -33,11 +34,12 @@ export const ImageResize = Image.extend({ const style = element.style.width; return style || null; }, + // Dimensions stay in width/height attributes rather than inline style: + // a style attribute needs `style-src 'unsafe-inline'`, which the + // `styleDataFormat: "class"` mode exists to avoid. renderHTML: attributes => { - if (!attributes.width) { - return {}; - } - return { width: attributes.width }; + const width = toHtmlDimension(attributes.width); + return width === undefined ? {} : { width }; } }, height: { @@ -51,10 +53,8 @@ export const ImageResize = Image.extend({ return style || null; }, renderHTML: attributes => { - if (!attributes.height) { - return {}; - } - return { height: attributes.height }; + const height = toHtmlDimension(attributes.height); + return height === undefined ? {} : { height }; } }, dataEntity: { diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts new file mode 100644 index 0000000000..9cda6f2003 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImagePasteDrop.spec.ts @@ -0,0 +1,314 @@ +import { Editor } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ImageFileError } from "../../utils/imageFiles"; +import { IMAGE_DROP_ERROR_EVENT, ImagePasteDrop, insertImageFiles } from "../ImagePasteDrop"; + +const DRAG_OVER_CLASS = "rich-text-drag-over"; + +type DOMHandlers = Record boolean>; + +interface Harness { + editor: Editor; + wrapper: HTMLElement; + handlers: DOMHandlers; + errors: ImageFileError[]; +} + +function imageFile(name: string, content: string, type = "image/png", size?: number): File { + const file = new File([content], name, { type }); + if (size !== undefined) { + Object.defineProperty(file, "size", { value: size }); + } + return file; +} + +function dropEvent(files: File[], coords: { clientX: number; clientY: number } = { clientX: 0, clientY: 0 }): any { + return { + ...coords, + preventDefault: jest.fn(), + dataTransfer: { files, types: files.length > 0 ? ["Files"] : [] } + }; +} + +function pasteEvent(files: File[], types = files.length > 0 ? ["Files"] : []): any { + return { + preventDefault: jest.fn(), + clipboardData: { files, types } + }; +} + +function dragEvent(types: string[] = ["Files"]): any { + return { preventDefault: jest.fn(), dataTransfer: { types } }; +} + +function makeHarness({ enabled = true, editable = true } = {}): Harness { + const wrapper = document.createElement("div"); + wrapper.className = "tiptap-wrapper"; + const element = document.createElement("div"); + wrapper.appendChild(element); + document.body.appendChild(wrapper); + + const editor = new Editor({ + element, + content: "

Hello world

", + extensions: [ + StarterKit, + Image.configure({ inline: true, allowBase64: true }), + ImagePasteDrop.configure({ + isEnabled: () => enabled, + isEditable: () => editable, + wrapperSelector: ".tiptap-wrapper", + dragOverClass: DRAG_OVER_CLASS + }) + ] + }); + + // Tiptap core registers its own drop/paste DOM handlers, so match this plugin by key. + const plugin = editor.state.plugins.find(p => (p as unknown as { key: string }).key.startsWith("imagePasteDrop")); + const errors: ImageFileError[] = []; + editor.view.dom.addEventListener(IMAGE_DROP_ERROR_EVENT, event => { + errors.push((event as CustomEvent).detail); + }); + + return { editor, wrapper, handlers: plugin!.spec.props!.handleDOMEvents as DOMHandlers, errors }; +} + +/** Lets the FileReader callbacks and the sequential inserts run. */ +async function flush(): Promise { + for (let i = 0; i < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +function imagePositions(editor: Editor): Array<{ pos: number; src: string }> { + const found: Array<{ pos: number; src: string }> = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "image") { + found.push({ pos, src: node.attrs.src }); + } + return true; + }); + return found; +} + +describe("ImagePasteDrop drop handling", () => { + it("inserts a dropped image at the drop position", async () => { + const { editor, handlers } = makeHarness(); + jest.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 6, inside: 0 }); + const event = dropEvent([imageFile("a.png", "a")], { clientX: 40, clientY: 12 }); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + const images = imagePositions(editor); + expect(images).toHaveLength(1); + expect(images[0].pos).toBe(6); + expect(images[0].src.startsWith("data:image/png;base64,")).toBe(true); + }); + + it("inserts several dropped images in drop order", async () => { + const { editor, handlers } = makeHarness(); + jest.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 6, inside: 0 }); + const files = [imageFile("a.png", "a"), imageFile("b.png", "bb"), imageFile("c.png", "ccc")]; + + handlers.drop(editor.view, dropEvent(files)); + await flush(); + + const srcs = imagePositions(editor).map(image => image.src); + expect(srcs).toHaveLength(3); + expect(srcs).toEqual([...srcs].sort((a, b) => a.length - b.length)); + expect(new Set(srcs).size).toBe(3); + }); + + it("rejects a file above the size limit and reports its size", async () => { + const { editor, handlers, errors } = makeHarness(); + const event = dropEvent([imageFile("huge.png", "a", "image/png", 13_000_000)]); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + expect(errors).toEqual([{ key: "image.errorTooLarge", arg: "12.4 MB" }]); + }); + + it("inserts the valid files of a mixed drop and reports the rejected one", async () => { + const { editor, handlers, errors } = makeHarness(); + jest.spyOn(editor.view, "posAtCoords").mockReturnValue({ pos: 6, inside: 0 }); + + handlers.drop(editor.view, dropEvent([imageFile("ok.png", "a"), imageFile("huge.png", "a", "image/png", 9e6)])); + await flush(); + + expect(imagePositions(editor)).toHaveLength(1); + expect(errors).toEqual([{ key: "image.errorTooLarge", arg: "8.6 MB" }]); + }); + + it("leaves a drop without image files to ProseMirror", () => { + const { editor, handlers } = makeHarness(); + const event = dropEvent([imageFile("doc.pdf", "a", "application/pdf")]); + + expect(handlers.drop(editor.view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("swallows the drop when default upload is disabled", async () => { + const { editor, handlers, errors } = makeHarness({ enabled: false }); + const event = dropEvent([imageFile("a.png", "a")]); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + expect(errors).toEqual([]); + }); + + it("swallows the drop when the editor is read-only", async () => { + const { editor, handlers, errors } = makeHarness({ editable: false }); + const event = dropEvent([imageFile("a.png", "a")]); + + expect(handlers.drop(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + expect(errors).toEqual([]); + }); +}); + +describe("ImagePasteDrop paste handling", () => { + it("inserts a pasted image at the selection", async () => { + const { editor, handlers } = makeHarness(); + editor.commands.setTextSelection(4); + const event = pasteEvent([imageFile("a.png", "a")]); + + expect(handlers.paste(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + await flush(); + + expect(imagePositions(editor)[0].pos).toBe(4); + }); + + it("leaves a paste that also carries HTML to the existing paste handling", () => { + const { editor, handlers } = makeHarness(); + const event = pasteEvent([imageFile("a.png", "a")], ["text/html", "Files"]); + + expect(handlers.paste(editor.view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("swallows the paste when default upload is disabled", async () => { + const { editor, handlers } = makeHarness({ enabled: false }); + const event = pasteEvent([imageFile("a.png", "a")]); + + expect(handlers.paste(editor.view, event)).toBe(true); + await flush(); + + expect(imagePositions(editor)).toHaveLength(0); + }); +}); + +describe("ImagePasteDrop drag affordance", () => { + it("prevents dragover of a file drag so the drop reaches the editor", () => { + const { editor, handlers } = makeHarness(); + const event = dragEvent(); + + expect(handlers.dragover(editor.view, event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("prevents dragover even when insertion is not allowed", () => { + const { editor, handlers } = makeHarness({ enabled: false }); + const event = dragEvent(); + + handlers.dragover(editor.view, event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("ignores drags that carry no file", () => { + const { editor, handlers } = makeHarness(); + const event = dragEvent(["text/html"]); + + expect(handlers.dragover(editor.view, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("highlights the wrapper while a file drag is over the editor", () => { + const { editor, handlers, wrapper } = makeHarness(); + + handlers.dragenter(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(true); + + handlers.dragleave(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(false); + }); + + it("keeps the highlight while nested elements are entered", () => { + const { editor, handlers, wrapper } = makeHarness(); + + handlers.dragenter(editor.view, dragEvent()); + handlers.dragenter(editor.view, dragEvent()); + handlers.dragleave(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(true); + }); + + it("clears the highlight on drop even after several dragenters", () => { + const { editor, handlers, wrapper } = makeHarness(); + + handlers.dragenter(editor.view, dragEvent()); + handlers.dragenter(editor.view, dragEvent()); + handlers.drop(editor.view, dropEvent([imageFile("a.png", "a")])); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(false); + }); + + it("does not highlight when default upload is disabled", () => { + const { editor, handlers, wrapper } = makeHarness({ enabled: false }); + + handlers.dragenter(editor.view, dragEvent()); + + expect(wrapper.classList.contains(DRAG_OVER_CLASS)).toBe(false); + }); +}); + +describe("insertImageFiles", () => { + it("clamps the insert position to the current document size", async () => { + const insertImage = jest.fn(); + + await insertImageFiles([imageFile("a.png", "a")], 500, { + insertImage, + docSize: () => 12, + reportError: jest.fn() + }); + + expect(insertImage).toHaveBeenCalledWith(expect.stringContaining("data:image/png;base64,"), 12); + }); + + it("reports a read failure and continues with the next file", async () => { + const readAsDataURL = jest.spyOn(FileReader.prototype, "readAsDataURL").mockImplementationOnce(function ( + this: FileReader + ) { + this.onerror?.(new ProgressEvent("error") as ProgressEvent); + }); + const insertImage = jest.fn(); + const reportError = jest.fn(); + + await insertImageFiles([imageFile("bad.png", "a"), imageFile("good.png", "b")], 3, { + insertImage, + docSize: () => 100, + reportError + }); + + expect(reportError).toHaveBeenCalledWith({ key: "image.errorReadFailed" }); + expect(insertImage).toHaveBeenCalledTimes(1); + expect(insertImage).toHaveBeenCalledWith(expect.any(String), 3); + + readAsDataURL.mockRestore(); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts new file mode 100644 index 0000000000..a3a42cc6b2 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ImageResize.spec.ts @@ -0,0 +1,85 @@ +import { Editor } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ImageResize } from "../ImageResize"; + +function editorWith(html: string): Editor { + return new Editor({ + element: document.createElement("div"), + content: html, + extensions: [StarterKit, ImageResize] + }); +} + +function imageAttrs(editor: Editor): Record { + let attrs: Record = {}; + editor.state.doc.descendants(node => { + if (node.type.name === "image") { + attrs = node.attrs; + } + return true; + }); + return attrs; +} + +describe("ImageResize parsing", () => { + it("keeps the unitless dimensions written by Rich Text 4", () => { + const editor = editorWith('

'); + + expect(imageAttrs(editor)).toMatchObject({ width: "300", height: "200" }); + }); + + it("keeps the pixel strings written by Rich Text 5", () => { + const editor = editorWith('

'); + + expect(imageAttrs(editor)).toMatchObject({ width: "300px", height: "200px" }); + }); + + it("reads dimensions from inline style when no attribute is present", () => { + const editor = editorWith('

'); + + expect(imageAttrs(editor)).toMatchObject({ width: "300px", height: "200px" }); + }); +}); + +describe("ImageResize serialization", () => { + it("leaves Rich Text 4 content unchanged when nothing is edited", () => { + const editor = editorWith('

'); + + expect(editor.getHTML()).toContain('width="300" height="200"'); + }); + + it("drops the px suffix so the attribute value is a valid HTML dimension", () => { + const editor = editorWith('

'); + + expect(editor.getHTML()).toContain('width="300" height="200"'); + }); + + it("serializes inline-style dimensions as attributes, not as style", () => { + const editor = editorWith('

'); + const html = editor.getHTML(); + + expect(html).toContain('width="300" height="200"'); + expect(html).not.toContain("style="); + }); + + it("keeps a percentage width", () => { + const editor = editorWith('

'); + + expect(editor.getHTML()).toContain('width="50%"'); + }); + + it("omits a dimension a width attribute cannot express", () => { + const editor = editorWith('

'); + + // `width="20em"` would be legacy-parsed as 20 pixels, silently shrinking the image. + expect(editor.getHTML()).not.toContain("width="); + }); + + it("writes no dimensions for an image without a size", () => { + const editor = editorWith('

'); + const html = editor.getHTML(); + + expect(html).not.toContain("width="); + expect(html).not.toContain("height="); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss index 1c8a369b12..bd1a21149f 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss @@ -6,6 +6,10 @@ $rte-border-color-default: #ced0d3; $rte-gray-ligher: #f8f8f8; $rte-brand-primary: #264ae5; +$rte-drag-over-bg: #e6eaff; +$rte-brand-warning: #eca51c; +$rte-warning-bg: #fbedd2; +$rte-warning-text: #8f620b; .widget-rich-text { // CSS Variables for consistent theming @@ -49,6 +53,26 @@ $rte-brand-primary: #264ae5; display: flex; flex-direction: column; width: 100%; + + // Set while an image file is dragged over the editor (see extensions/ImagePasteDrop). + &.rich-text-drag-over .tiptap-editor { + outline: 1.5px dashed var(--brand-primary, $rte-brand-primary); + outline-offset: -2px; + background-color: var(--color-primary-lighter, $rte-drag-over-bg); + } + } + + // Transient message for an image drop/paste that was rejected. + .rich-text-drop-error { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + font-size: 12px; + line-height: 17px; + color: var(--file-dropzone-color, $rte-warning-text); + background-color: var(--color-warning-lighter, $rte-warning-bg); + border-top: 1px solid var(--brand-warning, $rte-brand-warning); } .tiptap-editor { diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts new file mode 100644 index 0000000000..905cc89ab4 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageFiles.spec.ts @@ -0,0 +1,80 @@ +import { MAX_FILE_SIZE, formatFileSize, pickImageFiles, readFileAsDataUrl, validateImageFile } from "../imageFiles"; + +function fileOf(name: string, type: string, size: number): File { + const file = new File(["x"], name, { type }); + // `File` derives its size from the content, so override it to size the file + // without allocating megabytes in the test. + Object.defineProperty(file, "size", { value: size }); + return file; +} + +describe("validateImageFile", () => { + it("rejects files above the maximum size, reporting the actual size", () => { + const error = validateImageFile(fileOf("huge.png", "image/png", 13_000_000)); + + expect(error).toEqual({ key: "image.errorTooLarge", arg: "12.4 MB" }); + }); + + it("rejects files that are not images", () => { + const error = validateImageFile(fileOf("doc.pdf", "application/pdf", 1024)); + + expect(error).toEqual({ key: "image.errorNotImage" }); + }); + + it("accepts an image within the size limit", () => { + expect(validateImageFile(fileOf("photo.jpg", "image/jpeg", MAX_FILE_SIZE - 1))).toBeNull(); + }); + + it("accepts an image exactly at the size limit", () => { + expect(validateImageFile(fileOf("photo.jpg", "image/jpeg", MAX_FILE_SIZE))).toBeNull(); + }); +}); + +describe("formatFileSize", () => { + it.each([ + [512, "512 B"], + [2048, "2.0 KB"], + [5 * 1024 * 1024, "5.0 MB"] + ])("formats %p as %p", (bytes, expected) => { + expect(formatFileSize(bytes)).toBe(expected); + }); +}); + +describe("pickImageFiles", () => { + it("keeps only image files, in their original order", () => { + const first = fileOf("a.png", "image/png", 10); + const text = fileOf("notes.txt", "text/plain", 10); + const second = fileOf("b.gif", "image/gif", 10); + + expect(pickImageFiles([first, text, second])).toEqual([first, second]); + }); + + it("returns an empty list when no file is an image", () => { + expect(pickImageFiles([fileOf("doc.pdf", "application/pdf", 10)])).toEqual([]); + }); + + it("returns an empty list for missing file lists", () => { + expect(pickImageFiles(null)).toEqual([]); + expect(pickImageFiles(undefined)).toEqual([]); + }); +}); + +describe("readFileAsDataUrl", () => { + it("resolves with a data URI", async () => { + const dataUrl = await readFileAsDataUrl(new File(["hello"], "a.png", { type: "image/png" })); + + expect(dataUrl.startsWith("data:image/png;base64,")).toBe(true); + }); + + it("rejects when the file cannot be read", async () => { + const readAsDataURL = jest.spyOn(FileReader.prototype, "readAsDataURL").mockImplementation(function ( + this: FileReader + ) { + this.onerror?.(new ProgressEvent("error") as ProgressEvent); + }); + + await expect(readFileAsDataUrl(new File([""], "a.png", { type: "image/png" }))).rejects.toThrow(); + + readAsDataURL.mockRestore(); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts new file mode 100644 index 0000000000..e98ac930a8 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/imageSize.spec.ts @@ -0,0 +1,40 @@ +import { toCssLength, toHtmlDimension } from "../imageSize"; + +describe("toCssLength", () => { + it.each([ + ["300", "300px"], + ["300.5", "300.5px"], + [300, "300px"], + ["300px", "300px"], + [" 300 ", "300px"], + ["50%", "50%"], + ["20em", "20em"], + ["auto", "auto"] + ])("converts %p to %p", (input, expected) => { + expect(toCssLength(input)).toBe(expected); + }); + + it.each([null, undefined, "", " "])("returns undefined for %p", input => { + expect(toCssLength(input)).toBeUndefined(); + }); +}); + +describe("toHtmlDimension", () => { + it.each([ + ["300", "300"], + ["300px", "300"], + ["300PX", "300"], + ["300.5px", "300.5"], + [300, "300"], + ["50%", "50%"] + ])("converts %p to %p", (input, expected) => { + expect(toHtmlDimension(input)).toBe(expected); + }); + + it.each([null, undefined, "", "20em", "auto", "inherit", "calc(100% - 10px)"])( + "returns undefined for %p", + input => { + expect(toHtmlDimension(input)).toBeUndefined(); + } + ); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts b/packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts new file mode 100644 index 0000000000..30675f13fc --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/imageFiles.ts @@ -0,0 +1,63 @@ +import type { TranslationKey } from "./i18n"; + +/** + * Shared image-file handling for every path that turns a `File` into a base64 + * image: the image dialog's Upload tab and the editor's drop/paste handling + * (`extensions/ImagePasteDrop`). Both must accept and reject exactly the same + * files, so the rules live here and nowhere else. + * + * Errors are returned as a translation key plus its `###` substitution rather + * than as finished text: the drop/paste path runs inside a ProseMirror plugin, + * outside React, where `useT()` is not available. + */ + +/** Maximum file size for image uploads (5MB). */ +export const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export interface ImageFileError { + key: TranslationKey; + /** Substituted into the message's `###` placeholder, when the key has one. */ + arg?: string; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Returns the rejection reason, or `null` when the file may be inserted. */ +export function validateImageFile(file: File): ImageFileError | null { + if (file.size > MAX_FILE_SIZE) { + return { key: "image.errorTooLarge", arg: formatFileSize(file.size) }; + } + if (!file.type.startsWith("image/")) { + return { key: "image.errorNotImage" }; + } + return null; +} + +export function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("read failed")); + reader.readAsDataURL(file); + }); +} + +/** + * The `image/*` files carried by a drop or paste, in their original order. + * An empty result means the event carries no image file and must be left to + * ProseMirror (HTML slices, an `` dragged from another tab, Word paste). + */ +export function pickImageFiles(files: FileList | File[] | null | undefined): File[] { + if (!files) { + return []; + } + return Array.from(files).filter(file => file.type.startsWith("image/")); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts b/packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts new file mode 100644 index 0000000000..f32a25790a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/imageSize.ts @@ -0,0 +1,56 @@ +/** + * Conversions between an image node's stored `width`/`height` and the two forms + * it has to take: a CSS length for the node view, and a `width`/`height` HTML + * attribute value for serialized content. + * + * The stored value is not one shape. Rich Text 4 wrote bare numbers as HTML + * attributes (`width="300"`), version 5 writes pixel strings (`"300px"`), and + * pasted HTML can carry any CSS length or a percentage. Parsing deliberately + * keeps whatever it found, so both conversions below have to cope with all of + * them. + */ + +const UNITLESS = /^\d+(\.\d+)?$/; +const PIXELS = /^(\d+(\.\d+)?)px$/i; +const PERCENTAGE = /^\d+(\.\d+)?%$/; + +function normalize(value: string | number | null | undefined): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + const str = String(value).trim(); + return str === "" ? undefined : str; +} + +/** + * Stored dimension as a CSS length. A bare number is pixels — that is what + * version 4 stored, and `width: 300` on its own is invalid CSS, so without this + * the browser drops the declaration and the image falls back to its natural size. + */ +export function toCssLength(value: string | number | null | undefined): string | undefined { + const str = normalize(value); + if (str === undefined) { + return undefined; + } + return UNITLESS.test(str) ? `${str}px` : str; +} + +/** + * Stored dimension as a `width`/`height` attribute value. The attribute is + * defined as a non-negative integer, so the `px` suffix is dropped; percentages + * are kept because browsers accept them here and dropping one would resize the + * image. Anything else (`20em`, `auto`) returns undefined so the attribute is + * omitted: legacy dimension parsing would read `20em` as 20 pixels and silently + * shrink the image. + */ +export function toHtmlDimension(value: string | number | null | undefined): string | undefined { + const str = normalize(value); + if (str === undefined) { + return undefined; + } + if (UNITLESS.test(str) || PERCENTAGE.test(str)) { + return str; + } + const pixels = PIXELS.exec(str); + return pixels ? pixels[1] : undefined; +} From 982914d28f3b205a18a8052c7bbaded5f9bdcbfa Mon Sep 17 00:00:00 2001 From: gjulivan Date: Thu, 3 Sep 2026 13:20:01 +0200 Subject: [PATCH 5/6] fix(richtext): allow adding styles into list bullets --- .../rich-text-web/CHANGELOG.md | 4 + .../rich-text-web/e2e/RichText.spec.js | 45 +++ .../.openspec.yaml | 2 + .../design.md | 216 ++++++++++++ .../proposal.md | 47 +++ .../specs/list-marker-format/spec.md | 191 ++++++++++ .../tasks.md | 61 ++++ .../openspec/specs/list-marker-format/spec.md | 197 +++++++++++ .../src/__tests__/RichText.spec.tsx | 38 +- .../__snapshots__/RichText.spec.tsx.snap | 2 +- .../rich-text-web/src/components/Editor.tsx | 21 +- .../src/extensions/BulletListStyled.ts | 42 +++ .../src/extensions/ListItemMarkerFormat.ts | 123 +++++++ .../src/extensions/OrderedListStyled.ts | 18 + .../__tests__/ListItemMarkerFormat.spec.ts | 227 ++++++++++++ .../rich-text-web/src/ui/RichText.scss | 34 +- .../src/ui/RichTextFormatStyle.scss | 37 +- .../src/utils/__tests__/markerFormat.spec.ts | 315 +++++++++++++++++ .../rich-text-web/src/utils/markerFormat.ts | 332 ++++++++++++++++++ 19 files changed, 1946 insertions(+), 6 deletions(-) create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/.openspec.yaml create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/design.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/proposal.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/specs/list-marker-format/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/tasks.md create mode 100644 packages/pluggableWidgets/rich-text-web/openspec/specs/list-marker-format/spec.md create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 7423d8bc49..320e6ae0ab 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -24,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where header toolbar not showing when selected on custom mode. +- We fixed an issue where the bullet or number of a list item kept its original size, color and font when the list text was formatted. Markers now follow the formatting of the item's first character, including size, bold, italic, color and font, and each item is evaluated on its own. + +- We fixed an issue where opening a page could mark the Rich Text value as changed and run the "On change" action even though nothing was edited. + ## [5.0.0] - 2026-08-14 ### Added diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js index 899cdfedb5..b39290805d 100644 --- a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js +++ b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js @@ -258,6 +258,51 @@ test.describe("RichText", () => { await expect(editor.locator("table li ul")).toHaveCount(1); }); + for (const { label, button, tag } of [ + { label: "bullet", button: "Bullet List", tag: "ul" }, + { label: "numbered", button: "Numbered List", tag: "ol" } + ]) { + test(`enlarging a ${label} list item's first run enlarges its marker`, async ({ page }) => { + await page.goto("/p/advanced"); + await waitForMendixApp(page); + + const widget = page.locator(".mx-name-richText1"); + const editor = widget.locator(".tiptap"); + await editor.scrollIntoViewIfNeeded(); + await expect(editor).toBeVisible(); + + await editor.click(); + await editor.selectText(); + await page.keyboard.press("Backspace"); + + await widget.locator(`.tiptap-toolbar button[title="${button}"]`).click(); + await page.keyboard.type("item"); + await expect(editor.locator(`${tag} li`)).toHaveCount(1); + + // Select the item's text, which puts the first character in the selection. + await page.keyboard.press("Home"); + await page.keyboard.press("Shift+End"); + + await widget.locator('.tiptap-toolbar button[title="Font Size"]').click(); + await widget.locator('.tiptap-toolbar [data-value="84px"]').click(); + await expect(editor.locator(`${tag} li span`)).toHaveCount(1); + + // `::marker` is a pseudo-element, so `toHaveCSS` cannot reach it. `expect.poll` + // keeps the retry behaviour that a bare `evaluate` would lose. + await expect + .poll(() => editor.locator(`${tag} li`).evaluate(li => getComputedStyle(li, "::marker").fontSize)) + .toBe("84px"); + + // The marker grows leftward out of the list's padding, so the gutter has to leave + // room for it — otherwise the number or bullet is clipped at the editor's edge. + const gutter = await editor + .locator(tag) + .first() + .evaluate(list => parseFloat(getComputedStyle(list).paddingLeft)); + expect(gutter).toBeGreaterThan(84); + }); + } + test("inserting a YouTube URL renders a framable embed URL", async ({ page }) => { await page.goto("/p/advanced"); await waitForMendixApp(page); diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/.openspec.yaml new file mode 100644 index 0000000000..9696e00f11 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/design.md new file mode 100644 index 0000000000..00a3800f60 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/design.md @@ -0,0 +1,216 @@ +## Context + +`::marker` inherits from its `
  • `. The `FontSize`, `TextColorClass`, and `FontFamilyClass` extensions all write to the inline `textStyle` mark, and `Bold`/`Italic` are their own inline marks — so every format the user can apply to list text lands on a `` two levels below the `
  • `: + +``` +
      font-size: 14px (inherited from theme) + └─
    1. font-size: 14px ← ::marker reads THIS + └─

      font-size: 14px + └─ Big text + + renders: 1. Big text + ▲ ▲ + 14px 32px +``` + +CSS has no child-to-ancestor selector and no way to read a descendant's value, so the `

    2. ` must be told the format by JavaScript. + +Two facts constrain the implementation: + +**`listItem` content is `paragraph block*`** (`@tiptap/extension-list`, `ListItem`). The first child of a list item is always a paragraph, so "the first inline run" is unambiguous: `node.firstChild.firstChild`. + +**The widget renders all content through ProseMirror, including read-only.** `Editor.tsx:350` sets `editable: !readOnly` rather than swapping in a plain HTML renderer, so anything the editor view does applies to read-only display too. + +**Constraint that shapes everything: the document must not be mutated.** `Editor.tsx:381-391` reconciles by string comparison: + +```js +const newContent = editor.getHTML(); +if (newContent !== defaultValue) { + editor.commands.setContent(defaultValue || ""); +} +``` + +An `appendTransaction` that injected marker attributes into the document would make `getHTML()` differ from the stored `defaultValue` for every pre-existing document. Combined with `onUpdate` (`Editor.tsx:351`) pushing HTML back out on every transaction, opening any existing list content would write to the attribute — dirtying forms, triggering unsaved-changes prompts, and firing `onDataChange` microflows. It also drags in `appendTransaction`'s usual costs: undo grouping, paste handling, and split/merge synchronization. + +## Goals / Non-Goals + +**Goals:** + +- Markers follow the first inline run's font size, weight, style, color, and family. +- Partial formatting affects the marker only when it covers the first character. +- Correct in the live editor as the user types, in read-only mode, and in the saved HTML. +- Works in both `styleDataFormat` modes (`inline` and `class`). +- Zero document mutation; no migration; no change to stored content until the user edits. +- Enlarged markers get enough gutter that they are neither clipped nor overlapping the item text. + +**Non-Goals:** + +- **Task lists.** `taskItem` renders `
    3. element +initial view render fresh as the user types +``` + +**Why `renderHTML` alone is not enough.** `ListItem.renderHTML` receives the full node — the signature is `renderHTML({ node, HTMLAttributes })`, and `TaskItem` at `@tiptap/extension-list/dist/index.js:1300` already destructures `node`, so reading `node.firstChild.firstChild.marks` is supported and idiomatic. But ProseMirror only re-invokes `toDOM` when a node's _markup_ changes. `sameMarkup` compares type, attrs, and marks — not content. Restyling the first run produces a listItem with identical markup, so the view reuses the existing `
    4. ` element and patches only its children. A content-derived attribute would go stale. + +**Why decorations alone are not enough.** Decorations live in the view, not the document. `getHTML()` re-serializes the document through `DOMSerializer`, so decorations never appear in saved output. + +Together they cover exactly what the other cannot, and because both call the same pure function they cannot disagree. + +**Decoration merge semantics are safe** — verified in `prosemirror-view@1.42.2`: + +```js +// patchAttributes, dist/index.js:1740-1750 +if (cur.style) dom.style.cssText += cur.style; // appends +// class handled via classList.add/remove, touching only decorated classes +``` + +So the decoration adds `--rt-marker-*` on top of whatever `toDOM` emitted rather than clobbering it, and removes only the properties it previously set. `data-*` attributes go through `setAttribute`/`removeAttribute`, letting the decoration act as the authoritative value in the view — which is what we want. + +**Round-tripping is self-healing.** Because no node attribute is declared for marker format, `parseHTML` ignores `--rt-marker-*` and `data-marker-*` on input. Pasted or reloaded content drops any stale marker data and recomputes it on render. + +**Alternative considered — `:has()` enumeration.** `FONT_SIZE_LIST` (`fontHelpers.ts:21-36`) is a closed set of 14 values, so 14 static rules could cover font size with no JavaScript. Rejected: it cannot handle arbitrary sizes from pasted content; `:first-child` breaks when other marks wrap the span (mark nesting order varies, e.g. ``); inline mode would need `[style*="font-size: 32px"]` substring matching; and it does not extend to color or font-family at all. Reading marks from the document model sidesteps every one of these. + +### Decision 2: Read only the first inline child of the first block + +```js +const firstBlock = node.firstChild; // always a paragraph (content: "paragraph block*") +const firstInline = firstBlock?.firstChild; // text node, or image/hardBreak +const marks = firstInline?.marks; +``` + +From `marks`, take `textStyle`'s `fontSize`, `color`, and `fontFamily`, plus the presence of the `bold` and `italic` marks. + +This is what makes the "first character" rule fall out for free, because partial selection splits the text into separate marked runs: + +| user selects | resulting content | marker | +| -------------- | ------------------------- | --------- | +| all of "Hello" | `Hello` | 32px | +| only "H" | `Hello` | 32px | +| only "llo" | `Hello` | unchanged | + +Cases that yield no marker format (leave the marker at base): empty list item, item starting with an image or hard break, first run with no relevant marks. + +`fontFamily` in class mode must reuse the `fontValue` → `fontFamily` fallback that `ToolbarConfig.ts:341-353` applies, or markers will silently miss the font on legacy content. + +### Decision 3: CSS custom properties + `::marker`, not real font properties on the `
    5. ` + +Emit the format as data and let the stylesheet apply it to `::marker`: + +```scss +li::marker { + font-size: var(--rt-marker-font-size, inherit); + font-weight: var(--rt-marker-font-weight, inherit); + font-style: var(--rt-marker-font-style, inherit); + color: var(--rt-marker-color, inherit); + font-family: var(--rt-marker-font-family, inherit); +} +``` + +Setting a custom property on the `
    6. ` does not affect its content rendering — only `::marker` reads it. Following the widget's existing convention for the two modes: + +| mode | `
    7. ` markup | +| -------- | -------------------------------------------------------------------------------- | +| `inline` | `style="--rt-marker-font-size:32px;--rt-marker-color:red"` | +| `class` | `class="has-marker-format" data-marker-font-size="32" data-marker-color="red" …` | + +Class mode maps these through `attr()` in `RichTextFormatStyle.scss`, matching the existing `has-font-size` / `has-text-color` rules. + +**Alternative considered — real font properties on the `
    8. `.** `
    9. ` scales the marker _and_ the gutter for free, since `padding-left: 1.5em` would resolve against the larger size. Rejected: the `
    10. ` then cascades into its own content, so it needs a `li > p` reset, and CSS cannot express "inherit from grandparent." In class mode you can fake the base (`--rt-base: 1em` declared at the ProseMirror root, which computes to absolute px there — it must not be declared on the list itself, or a nested list inside an enlarged `
    11. ` would capture the enlarged value). In inline mode you would have to read the computed base in JavaScript and bake a theme-dependent pixel value into stored content, so theme changes would stop propagating and content moved between apps would render wrong. That is a correctness bug, not a cosmetic one. + +The trade-off accepted: markers rendered outside the widget without its stylesheet fall back to base size — identical to today's behavior. The rejected alternative degrades _worse_: without the stylesheet the reset is missing, so the first run's format would apply to the entire list item, which is actively wrong rather than merely unfixed. + +**Naming:** the widget's own custom properties are otherwise unprefixed (`--file-dropzone-color`, `--file-icon`), but `--marker-font-size` is generic enough to risk colliding with a theme variable, so `--rt-marker-*` is used. + +### Decision 4: Scale the gutter via a formulation that is a no-op by default + +`RichText.scss:181` and `:209` set `padding-left: 1.5em` on `ol`/`ul`, and `RichTextFormatStyle.scss:24` repeats it for the `ol.indent-N`/`ul.indent-N` rules. That em resolves against the _list's_ font size (~21px at a 14px base), so an 84px marker overflows it: + +``` +current: |◀ 21px ▶| + 10. Big text + ╰──────╯ needs ~120px +``` + +Leaving it alone is not the conservative choice — it is _newly_ broken, since today's undersized marker at least fits. All three sites become: + +```scss +padding-left: max(1.5em, calc(1.5 * var(--rt-marker-max-size, 0px))); +``` + +With the variable absent, `calc(1.5 * 0px)` is `0px` and `max()` yields `1.5em` — byte-identical to today. `orderedList` and `bulletList` emit `--rt-marker-max-size` as the largest first-run font size among their **direct** `li` children, so it is only present when a list actually contains an enlarged marker. + +Keeping the `1.5` multiplier in CSS rather than computing pixels in JavaScript means the ordered/unordered gutter ratio stays adjustable in the stylesheet. + +Ordered lists already have a home for this in `OrderedListStyled.ts`. Bullet lists currently use StarterKit's `BulletList` unmodified and need extending. + +### Decision 5: Extension layout + +| file | role | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `src/utils/markerFormat.ts` | pure `computeMarkerFormat(node)` + the two attribute serializers (inline vs class) | +| `src/extensions/ListItemMarkerFormat.ts` | `ListItem.extend({ renderHTML, addProseMirrorPlugins })` — hosts the decoration plugin, which walks all list types | +| `src/extensions/BulletListStyled.ts` | `BulletList.extend({ renderHTML })` for `--rt-marker-max-size` | +| `src/extensions/OrderedListStyled.ts` | add `--rt-marker-max-size` to the existing extension | + +`Editor.tsx:250` currently passes `StarterKit.configure({ orderedList: false })`; it gains `listItem: false` and `bulletList: false`, with the replacements registered alongside `OrderedListStyled`. `ListItem`'s `bulletListTypeName`/`orderedListTypeName` options must be preserved so list toggling and lifting keep working. + +The decoration plugin recomputes on doc-changing transactions only (not selection changes), scanning list items within the changed ranges rather than the whole document. + +## Risks / Trade-offs + +**`::marker` may not accept all five font properties in every target browser** → **Chrome verified, Safari/Firefox outstanding.** Measured in Chrome 152 via `/tmp/marker-support-check.html`: `font-size`, `font-weight`, `font-style`, `color`, and `font-family` all resolve on `li::marker` through CSS custom properties, confirmed both by `getComputedStyle(el, "::marker")` and by visual rendering, for `
        ` and `
          ` alike. An item with no marker variables keeps the base 14px, confirming the `inherit` fallback is a no-op. + +Firefox is not installed on this machine and Safari cannot be automated without `safaridriver --enable`. The probe page self-reports PASS/FAIL, so opening it in Safari is a one-look manual check. Residual risk is low: `RichTextFormatStyle.scss:37-77` already ships typed `attr()` (`attr(data-font-size px)`, `attr(data-text-color type())`), which is Chrome 133+ and absent in Safari and Firefox — so class-mode font size does not work in Safari _today_. `::marker` with font properties is more broadly supported than what the widget already depends on. + +If a property does fail, the blast radius is contained to task group 5 (stylesheet) — the extension work is unaffected. The fallback is `list-style: none` + `li::before { content: counter(…) }`, which would have to reimplement the six-level cycling and the `data-list-style` overrides, and should be treated as a separate change. + +**The `1.5` gutter multiplier was a heuristic, and it was not enough** → Measured in Chrome at the 98px maximum (task 8.2): a flat `1.5` gives a 147px gutter, but `998.` needs 191px and `1000.` needs 245px, so three- and four-digit markers were clipped. Two digits (136px) fit. The multiplier is now marker-length-aware: + +```scss +padding-left: max(1.5em, calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5))); +``` + +`0.6` per character comes from the ~0.55em digit advance width plus headroom; `0.5` covers the trailing `.` and the gap to the text. `computeMarkerLength(listNode)` supplies the count from the _longest_ marker — the last item's, at `start + childCount - 1` — because markers sit to the left of the text, so the gutter has to fit the widest one, not a typical one. It resolves the counter style from `listStyleType` or the HTML `type` attribute, since `lower-roman` needs the numeral's length rather than the number's digits (`xxxviii` is 7 characters where 38 is 2), and `lower-alpha` stays one character through `z`. + +`--rt-marker-chars` is emitted only above 1, so bullet lists (always 1 glyph) and short numbered lists keep byte-identical markup and fall back to the stylesheet's default of 1. Verified in Chrome against the compiled declaration for 1/3/4 characters, in both style modes, with an unformatted list still computing to exactly `1.5em`. + +Residual: the per-character factor is calibrated on digit widths, so a marker in an unusually wide font could still crowd. Both factors remain in CSS and are adjustable without touching the extensions. + +**Decoration and `renderHTML` could drift** → Both call `computeMarkerFormat`; unit-test the shared function directly, and assert that a `getHTML()` round-trip and the rendered view agree for the same document. + +**Stale marker data in pasted HTML** → Harmless by construction: no node attribute is declared, so `parseHTML` drops `--rt-marker-*` / `data-marker-*` and the value is recomputed. Worth an explicit test so a future refactor that adds an attribute does not silently reintroduce stickiness. + +**Replacing StarterKit's `listItem` and `bulletList`** → Widens the blast radius to list toggling, Tab nesting, and `Indent.ts:280-281`'s `liftListItem`/`sinkListItem`. `OrderedListStyled` is precedent that this is safe, but the existing list specs (`list-margin-indent`, `list-tab-indent`, `list-style-auto-cycle`, `manual-list-style`, `ordered-list-interaction`) are the regression surface and should be re-run. + +**Performance on large documents** → Every doc-changing transaction rebuilds the decoration set, and the implemented rebuild walks the whole document rather than only changed ranges (see task 3.5). Per node the work is one `node.firstChild?.firstChild?.marks` read, and prosemirror-view already diffs the resulting `DecorationSet` so an unchanged rebuild redraws nothing. A changed-range scan would have to map surviving decorations and handle nested lists straddling a range; it is available as a later optimization if profiling on a large document justifies it. + +**Class mode cannot set the font properties on `::marker` directly** → Discovered while implementing task 5.2. The `li::marker` rule in `RichText.scss` is both more specific and later in source order than anything in `RichTextFormatStyle.scss` (`@use` hoists that file above it), so a class-mode `::marker` rule would lose, and the base rule's `var(…, inherit)` fallback would reset the marker to the base. Resolved by having class mode assign the `--rt-marker-*` custom properties from `attr()` instead, keeping one consumption point for both modes. Verified in Chrome 152 that typed `attr()` resolves inside a custom property declaration (`/tmp/marker-attr-var-check.html`). Sass simplifies `calc(1.5 * var(…))` to `1.5 * var(…)` inside `max()`; that form was verified to compute identically. + +**The load-time attribute rewrite had to be stopped, which moves the "Characters (HTML)" count** → Task 7.1's hazard turned out to be pre-existing and not list-specific: `Editor.tsx`'s sync effect called `setContent(defaultValue)` with updates enabled, so on every mount the editor's own serialization was written back over any stored value that was not already byte-identical to `getHTML()` — dirtying the attribute and firing On change without a user edit. Derived marker formatting would have made that true of every previously saved formatted list, so the effect now passes `{ emitUpdate: false }`. + +Consequence: `characterCountHtml` counts `stringAttribute.value` (`EditorWrapper.tsx:99-100`), which is now the value as actually stored rather than the editor's re-serialization. The bundled fixture's count drops from 82 to 49. This is arguably the more truthful number, but the count will still shift on the user's first real edit, when the canonical serialization is written. Pointing that metric at the editor's own HTML instead would make it stable, but that is a separate decision about a different feature and is left alone here. + +## Open Questions + +1. **Should the marker follow block-level typography?** Currently out of scope (Non-Goals). If a list item's first block is a heading, Word sizes the marker to the heading but this design leaves it at base. Resolving it means reading a computed block size, which reintroduces the base-size problem from Decision 3. +2. **Does the gutter change land here or in a follow-up?** It is separable from the marker formatting itself. Shipping marker formatting alone leaves large markers overflowing, so the recommendation is to keep them together — but they could be staged if the gutter work grows. +3. ~~**Safari `::marker` support for `font-weight`, `font-style`, and `font-family`**, not just `font-size` and `color`.~~ Resolved for Chrome 152 — all five properties verified by computed style and by rendering. Safari and Firefox remain unverified (neither is drivable here); see Risks. Proceeding on the basis that the widget's existing typed `attr()` usage already sets a higher support bar than `::marker` does. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/proposal.md new file mode 100644 index 0000000000..99fd76291f --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/proposal.md @@ -0,0 +1,47 @@ +## Why + +When a user increases the font size of list item text, the bullet or number keeps rendering at the base size, because `::marker` inherits from the `
        • ` while the font size lives on an inline `textStyle` span two levels down. The mismatch is visible on every enlarged list and there is no CSS path from a child span up to its ancestor `
        • `. + +Word and Google Docs both resolve this by having the marker follow the formatting of the list item's **first text run**, which also gives a well-defined answer when the user formats only part of the item. This change adopts that rule. + +## What Changes + +- List markers (`
            ` numbers and `
              ` bullets) follow the format of the first inline run of their list item: **font size, font weight, font style, color, and font family**. +- Marker format is derived, never authored. There is no new toolbar control and no new user-facing property. +- Formatting only part of a list item affects the marker only when the formatting covers the **first character**. Formatting a later portion leaves the marker unchanged. +- The list's marker gutter grows when a list contains an enlarged marker, so large numbers and bullets are not clipped or overlapped by the item text. +- Marker format is carried in the saved HTML, so re-rendering stored content reproduces the markers. +- Task lists are explicitly out of scope: `taskItem` renders a checkbox with `list-style: none` and has no `::marker` to format. +- Not a breaking change. Content without marker formatting renders byte-identically to today. + +## Capabilities + +### New Capabilities + +- `list-marker-format`: Deriving list marker format (size, weight, style, color, family) from the list item's first inline run, exposing it through the rendered HTML in both inline and class style modes, and scaling the list's marker gutter to fit an enlarged marker. + +### Modified Capabilities + + + +## Impact + +**Affected code** (`packages/pluggableWidgets/rich-text-web`): + +- New: a shared pure helper that computes marker format from a `listItem` node, plus a `ListItem` extension override and a ProseMirror plugin supplying view decorations. +- `src/components/Editor.tsx` — register the new extension and configure it with `styleDataFormat`. +- `src/ui/RichText.scss` — add `::marker` rules; make the `ol`/`ul` `padding-left` gutter dynamic (currently a hardcoded `1.5em`). +- `src/ui/RichTextFormatStyle.scss` — class-mode `attr()` mappings; the `ol.indent-N`/`ul.indent-N` rules repeat the same hardcoded `1.5em` gutter and need the same treatment. +- Ordered lists already run through `src/extensions/OrderedListStyled.ts`, so the ordered-list side of the gutter work has an existing home; bullet lists currently use StarterKit's `BulletList` unmodified and will need extending. + +**Deliberately not affected:** + +- The ProseMirror **document** is not mutated. Marker format is computed during rendering only. This avoids writing to every existing document on load, which would dirty forms and fire `onChange` microflows through the `onUpdate` → value → `setContent` reconciliation path in `Editor.tsx`. +- No change to `FontSize`, `TextColorClass`, `FontFamilyClass`, or the toolbar. Marker format reads the marks those extensions already produce. + +**Known limitation to accept:** the saved HTML carries the marker format as data (custom properties or `data-*`), so rendering it outside the widget requires the widget stylesheet. Without it, markers fall back to base size — today's behavior. See `design.md` for why the fully self-contained alternative was rejected. + +**Compatibility:** existing content gains correct markers on render with no migration and no document rewrite. Browser support rests on `::marker` accepting font properties, which is more broadly supported than the typed `attr()` the widget's class mode already ships. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/specs/list-marker-format/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/specs/list-marker-format/spec.md new file mode 100644 index 0000000000..410d28c32a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/specs/list-marker-format/spec.md @@ -0,0 +1,191 @@ +## ADDED Requirements + +### Requirement: List markers follow the format of the list item's first inline run + +The bullet or number of a list item SHALL render with the font size, font weight, font style, color, and font family of the first inline run of that list item's first block. When the first inline run carries none of these formats, the marker SHALL render at the base size and format inherited from the list, exactly as it does today. + +Each list item SHALL be evaluated independently, so sibling items in the same list MAY render markers at different formats. + +#### Scenario: Enlarging the whole list item enlarges the marker + +- **WHEN** a list item's entire text is set to a larger font size +- **THEN** the item's bullet or number renders at that larger font size + +#### Scenario: Marker follows bold, italic, color, and font family + +- **WHEN** the first inline run of a list item is bold, italic, colored, or set to a different font family +- **THEN** the item's bullet or number renders bold, italic, in that color, and in that font family respectively + +#### Scenario: Unformatted list item is unchanged + +- **WHEN** a list item's first inline run carries no font size, weight, style, color, or family +- **THEN** the marker renders identically to the current behavior, with no marker format data emitted for that item + +#### Scenario: Sibling items format independently + +- **WHEN** one item of a list has an enlarged first run and another does not +- **THEN** only the enlarged item's marker is enlarged, and the sibling's marker is unchanged + +#### Scenario: Nested list items follow their own first run + +- **WHEN** a nested list item's first run is enlarged +- **THEN** that nested item's marker is enlarged, and the parent item's marker is unaffected + +### Requirement: Only formatting that covers the first character affects the marker + +When a user formats part of a list item's text, the marker SHALL change only if the formatted range includes the item's first character. Formatting that begins after the first character SHALL leave the marker unchanged. + +#### Scenario: Formatting only the first character + +- **WHEN** the user selects only the first character of a list item and increases its font size +- **THEN** the item's marker renders at the increased font size + +#### Scenario: Formatting a later portion of the text + +- **WHEN** the user selects a range that starts after the first character and increases its font size +- **THEN** the item's marker is unchanged + +#### Scenario: Removing formatting from the first character + +- **WHEN** the first run of a list item is enlarged and the user then clears its font size +- **THEN** the marker returns to the base size and the marker format data is removed from that item + +### Requirement: Marker format is derived, never stored in the document + +Marker format SHALL be computed from the list item's content during rendering. The widget SHALL NOT write marker format into the editor document, SHALL NOT declare it as a node attribute, and SHALL NOT modify stored content as a side effect of loading a document. + +Marker format data present in incoming HTML SHALL be ignored when parsing and recomputed when rendering. + +#### Scenario: Opening existing content does not modify it + +- **WHEN** a document containing lists is loaded into the widget and the user makes no edit +- **THEN** no update is emitted, the bound attribute is not marked as changed, and no change action is triggered + +#### Scenario: Legacy content renders correct markers without migration + +- **WHEN** content saved before this change, containing an enlarged first run in a list item, is loaded +- **THEN** the marker renders enlarged immediately, with no migration step and no rewrite of the stored value + +#### Scenario: Stale marker data in pasted HTML is discarded + +- **WHEN** HTML containing marker format data that disagrees with its own list item content is pasted +- **THEN** the pasted marker format data is discarded and the marker format is recomputed from the first inline run + +### Requirement: Marker format renders in the live editor, in read-only mode, and in saved HTML + +The marker format SHALL be correct in the editor as the user types, in read-only rendering, and in the HTML the widget emits. + +Editing a list item's first run SHALL update that item's marker without requiring a reload, re-render of the whole document, or loss of cursor position. + +#### Scenario: Marker updates while typing + +- **WHEN** the user changes the font size of a list item's first run in the editor +- **THEN** the marker updates immediately and the cursor position is preserved + +#### Scenario: Read-only rendering matches the editor + +- **WHEN** the same content is rendered in read-only mode +- **THEN** the markers render with the same format as in the editable editor + +#### Scenario: Saved HTML carries the marker format + +- **WHEN** content with formatted list markers is serialized by the widget +- **THEN** the emitted `
            • ` elements carry the marker format data +- **AND** loading that HTML back into the widget reproduces the same marker rendering + +#### Scenario: Copied content carries the marker format + +- **WHEN** a formatted list is copied out of the editor +- **THEN** the copied HTML carries the marker format data for each item + +### Requirement: Marker format renders in both inline and class style modes + +Marker format SHALL render correctly whether the widget is configured for inline styles or CSS classes, following the same convention the widget already uses for inline text formatting. + +#### Scenario: Inline mode emits inline marker format + +- **WHEN** the widget is in inline mode and a list item's first run is formatted +- **THEN** the `
            • ` carries the marker format in its inline `style` attribute +- **AND** the marker renders with that format + +#### Scenario: Class mode emits a class and data attributes + +- **WHEN** the widget is in class mode and a list item's first run is formatted +- **THEN** the `
            • ` carries a marker format class and corresponding `data-` attributes +- **AND** the marker renders with that format + +#### Scenario: Class mode resolves legacy font family values + +- **WHEN** class mode content specifies a font family in the legacy form, without the current font value attribute +- **THEN** the marker still renders in that font family + +### Requirement: The list gutter grows to fit an enlarged marker + +When a list contains an item whose marker is enlarged, the list SHALL increase its marker gutter so the marker is neither clipped nor overlapping the item text. The gutter SHALL be derived from the largest marker font size among the list's direct list items. + +When no item in a list has an enlarged marker, the gutter SHALL be unchanged from its current value. + +#### Scenario: Large ordered list numbers are not clipped + +- **WHEN** an ordered list contains items whose first runs are set to a large font size +- **THEN** the numbers render fully within the list's gutter without being clipped or overlapping the item text + +#### Scenario: Large bullets are not clipped + +- **WHEN** a bullet list contains items whose first runs are set to a large font size +- **THEN** the bullets render fully within the list's gutter without overlapping the item text + +#### Scenario: Gutter is unchanged for unformatted lists + +- **WHEN** a list has no item with an enlarged marker +- **THEN** the list's gutter is identical to the current behavior + +#### Scenario: Gutter is sized by the largest marker in the list + +- **WHEN** one item of a list has a much larger first run than its siblings +- **THEN** the list's gutter accommodates the largest marker +- **AND** the smaller siblings' markers remain aligned with it + +#### Scenario: Gutter coexists with list indent margin + +- **WHEN** a list has both a non-zero indent and an enlarged marker +- **THEN** the indent margin and the enlarged gutter both apply, and the markers remain visible and not collapsed against the text + +### Requirement: Existing list behavior is preserved + +This change SHALL NOT alter marker glyph selection, list nesting, list toggling, or list indentation. + +#### Scenario: Nesting level cycling is unchanged + +- **WHEN** lists are nested to any depth +- **THEN** the ordered and unordered style cycling by nesting depth behaves exactly as before + +#### Scenario: Manual list style overrides are unchanged + +- **WHEN** an ordered list has a manually selected numbering style +- **THEN** that style and its nested cycle offset behave exactly as before + +#### Scenario: Tab nesting and indent shortcuts are unchanged + +- **WHEN** the user nests list items with Tab or indents a list with the indent shortcut +- **THEN** the behavior is unchanged, including when the affected items have formatted markers + +#### Scenario: List toggling is unchanged + +- **WHEN** the user toggles a bullet or ordered list on or off from the toolbar +- **THEN** the behavior is unchanged + +### Requirement: Task lists are excluded + +Task list items SHALL NOT receive marker formatting. Their checkbox rendering SHALL be unchanged. + +#### Scenario: Formatting a task item's first run leaves the checkbox alone + +- **WHEN** the first run of a task list item is enlarged, colored, or bolded +- **THEN** the checkbox renders unchanged +- **AND** no marker format data is emitted for that item + +#### Scenario: Task lists nested with other list types are unaffected + +- **WHEN** a task list is nested inside a bullet or ordered list with formatted markers +- **THEN** the task items' checkboxes are unchanged while the enclosing list's markers are formatted diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/tasks.md new file mode 100644 index 0000000000..35b692bbfd --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-09-03-list-marker-first-run-format/tasks.md @@ -0,0 +1,61 @@ +## 1. Resolve the browser support prerequisite + +- [x] 1.1 Build a static HTML page setting `font-size`, `font-weight`, `font-style`, `color`, and `font-family` on `li::marker` via CSS custom properties, and confirm each property applies in current Chrome, Firefox, and Safari (Open Question 3) — probe at `/tmp/marker-support-check.html`; all five PASS in Chrome 152 by computed style and by rendering. Firefox not installed; Safari not automatable without `safaridriver --enable`. **Outstanding: open the probe page in Safari (it self-reports PASS/FAIL).** +- [x] 1.2 If any property fails in a target browser, stop and record the finding in `design.md` before continuing — the fallback (`list-style: none` + `li::before { content: counter(…) }`) would require reworking the level cycling in `RichText.scss:180-230` and the `data-list-style` overrides, which is a different change — no failure in Chrome; result and residual Safari/Firefox risk recorded in `design.md` + +## 2. Shared compute helper + +- [x] 2.1 Create `src/utils/markerFormat.ts` with a pure `computeMarkerFormat(node)` reading `node.firstChild?.firstChild?.marks` — `fontSize`, `color`, and `fontFamily` from the `textStyle` mark, plus presence of the `bold` and `italic` marks +- [x] 2.2 Return a normalized result (or `null`) so an item with no relevant marks emits nothing at all +- [x] 2.3 ~~Reuse the `fontValue` → `fontFamily` legacy fallback from `ToolbarConfig.ts:341-353` so class-mode legacy content resolves its font family~~ **Not needed — premise was wrong.** That fallback derives `fontValue` (a kebab-case identifier for dropdown matching) _from_ `fontFamily`, not the reverse. The `fontFamily` mark attribute always holds the CSS value in both style modes and in legacy content (`FontFamilyClass.ts:82-94` parses it from `data-font-family` or `element.style.fontFamily`), so the marker reads it directly. No fallback path exists to reuse. Design risk retired. +- [x] 2.4 Add `markerFormatToInlineStyle(format)` emitting `--rt-marker-*` custom properties, and `markerFormatToClassAttrs(format)` emitting the marker class plus `data-marker-*` attributes — class mode uses **one class per property** (`has-marker-font-size`, `has-marker-color`, …), matching the existing separate `has-font-size` / `has-text-color` / `has-font-family` classes. A single combined class would leave `attr()` referencing a missing attribute whenever the user set only some of the five properties. +- [x] 2.5 Add `computeMaxMarkerSize(listNode)` returning the largest first-run font size among the list's **direct** `li` children, for the gutter variable — px only; non-px units are not comparable without a layout context and every toolbar size is px +- [x] 2.6 Unit-test `computeMarkerFormat` directly: all five properties, first-character-only formatting, formatting starting after the first character, empty item, item starting with an image, item starting with a hard break — `src/utils/__tests__/markerFormat.spec.ts`, 20 tests. Caught a real bug: the colour attribute is **`textColor`** (registered by `TextColorClass`), not `color`. `@tiptap/extension-color` is a dependency but is never registered in `Editor.tsx`, so no `color` attribute exists on `textStyle`. + +## 3. ListItem rendering and view decoration + +- [x] 3.1 Create `src/extensions/ListItemMarkerFormat.ts` as `ListItem.extend(...)`, preserving the `bulletListTypeName` and `orderedListTypeName` options, and taking a `styleDataFormat` option — options preserved via `...this.parent?.()` in `addOptions` +- [x] 3.2 Override `renderHTML({ node, HTMLAttributes })` to merge the computed marker attributes for the configured style mode — this covers `getHTML()`, copy/paste, and the initial view render — merges into `HTMLAttributes` and **delegates to `this.parent?.()`** rather than rebuilding the spec, so upstream rendering logic is preserved +- [x] 3.3 Declare no new node attribute, so `parseHTML` ignores incoming `--rt-marker-*` / `data-marker-*` and recomputes on render +- [x] 3.4 Add `addProseMirrorPlugins()` with a decoration plugin emitting `Decoration.node` per list item, since `toDOM` is not re-invoked when only a node's content changes +- [x] 3.5 Recompute decorations on doc-changing transactions only (not selection changes), ~~scanning changed ranges rather than the whole document~~, and return the previous `DecorationSet` unchanged when nothing differs — **deviation:** the doc-changed guard is implemented, but the rebuild walks the whole document rather than only changed ranges. Per-node work is one `node.firstChild?.firstChild?.marks` read, and a changed-range scan would have to map existing decorations and handle nested lists straddling a range — complexity that is not justified at the document sizes this widget targets. The "unchanged when nothing differs" case is already handled downstream: prosemirror-view diffs the new `DecorationSet` against the old and redraws nothing when they match. +- [x] 3.6 Skip `taskItem` nodes in both `renderHTML` and the decoration plugin — falls out of the node type: `taskItem`/`taskList` are distinct types, so neither the `listItem` branch nor `GUTTER_LIST_TYPES` matches them, and `TaskItem` keeps its own unextended `renderHTML`. The stylesheet additionally excludes `li[data-type="taskItem"]::marker`, since custom properties inherit down into a task list nested under a formatted item. +- [x] 3.7 Unit-test that a `getHTML()` round-trip and the rendered view agree for the same document, so the two delivery paths cannot drift — `src/extensions/__tests__/ListItemMarkerFormat.spec.ts`, "view and getHTML agreement". Asserted in class mode: jsdom's `style.cssText` drops custom properties, so inline mode's decoration output is not observable there, whereas class mode goes through `classList`/`setAttribute`. Includes the staleness case (restyle the first run, `toDOM` is not re-invoked) and the removal case. + +## 4. Gutter variable on the list nodes + +- [x] 4.1 Add `--rt-marker-max-size` emission to `src/extensions/OrderedListStyled.ts` via `computeMaxMarkerSize`, alongside its existing `listStyleType` rendering — delegates to the parent, which handles `start` and `type` specially; a hand-built `["ol", …]` spec would have dropped both +- [x] 4.2 Create `src/extensions/BulletListStyled.ts` as `BulletList.extend(...)` emitting the same variable — imports `BulletList` from `@tiptap/extension-list` (a direct dependency); `@tiptap/extension-bullet-list` is not one +- [x] 4.3 Emit the variable only when a list actually contains an enlarged marker, so unformatted lists render byte-identical markup — `maxMarkerSizeToAttrs` returns `{}` for a `null` size; confirmed by the 6 existing snapshots passing unchanged +- [x] 4.4 Extend the decoration plugin to keep `--rt-marker-max-size` fresh on the `ol`/`ul` elements as well, for the same `toDOM` staleness reason + +## 5. Stylesheet + +- [x] 5.1 Add `li::marker` rules in `src/ui/RichText.scss` reading `var(--rt-marker-font-size, inherit)` and the four other properties, scoped so `taskItem` list items are excluded — `li:not([data-type="taskItem"])::marker` inside the `.ProseMirror` block +- [x] 5.2 Add class-mode `attr()` mappings for the marker `data-` attributes in `src/ui/RichTextFormatStyle.scss`, matching the existing `has-font-size` / `has-text-color` rules — **design refinement:** the class-mode rules assign the `--rt-marker-*` custom properties (`--rt-marker-font-size: attr(data-marker-font-size px)`) rather than the font properties on `::marker`. Setting the font properties here would always lose to the 5.1 rule, which is both more specific and later in source order (`@use` hoists this file above it), and that rule's `var(…, inherit)` fallback would reset the marker. Feeding the variables keeps a single consumption point for both modes. Verified in Chrome 152 that typed `attr()` resolves inside a custom property declaration (`/tmp/marker-attr-var-check.html`, all 14 assertions pass). +- [x] 5.3 Change the `ol`/`ul` gutter at `RichText.scss:181` and `:209` to `padding-left: max(1.5em, calc(1.5 * var(--rt-marker-max-size, 0px)))` +- [x] 5.4 Apply the same gutter formulation to the `ol.indent-N` / `ul.indent-N` rules at `RichTextFormatStyle.scss:24`, which repeat the hardcoded `1.5em` +- [x] 5.5 Verify that with the variable absent the computed `padding-left` is unchanged from before this change — probe reports `plain padding-left = 24px`, exactly `1.5em` at the 16px base, and `plain ::marker font-size` equal to the `li`'s own. `ul[data-type="taskList"]`'s `padding-left: 0` still wins, being a later and more specific rule. + +## 6. Editor wiring + +- [x] 6.1 Add `listItem: false` and `bulletList: false` to the `StarterKit.configure(...)` call at `Editor.tsx:250`, which currently only disables `orderedList` +- [x] 6.2 Register `ListItemMarkerFormat` and `BulletListStyled`, both configured with `styleDataFormat`, alongside the existing `OrderedListStyled` +- [x] 6.3 Confirm `Indent.ts:280-281`'s `liftListItem` / `sinkListItem` still resolve against the replaced `listItem` — both take the type name `"listItem"`, which the replacement keeps (`name: "listItem"`), and the commands themselves come from `ListItem`'s own `addKeyboardShortcuts`/`addCommands`, inherited through `.extend()`. The `list-tab-indent` suite passes. + +## 7. Regression coverage + +- [x] 7.1 Assert that loading list content and making no edit emits no update and does not dirty the bound attribute — the `onUpdate` → value → `setContent` path at `Editor.tsx:351,381-391` is the specific hazard this design exists to avoid — **required a code fix.** The hazard was real and, it turns out, pre-existing and broader than lists: the sync effect's `setContent(defaultValue)` emitted an update, which serialized the editor's own normalization back over the stored value on every mount. Any value not already byte-identical to `getHTML()` was rewritten, firing On change on page load. Derived marker formatting would have made that true of every previously saved formatted list. Fixed by passing `{ emitUpdate: false }` at `Editor.tsx:401` — that direction is external value → editor, so echoing back is never wanted; a genuine edit still emits via the `onUpdate` handler. Covered by `RichText.spec.tsx`, "does not write to the attribute on load", asserting `stringAttribute.setValue` is never called, with fake timers advanced past the 200 ms debounce in `EditorWrapper.tsx:60-75` (without that the assertions would pass vacuously). Both tests were confirmed to fail with the fix reverted. +- [x] 7.2 Test that HTML carrying marker format data inconsistent with its own content is discarded and recomputed on paste — "stale and legacy content" describes both the discard case (marker data, no formatting) and the recompute case (99px data, 32px content) +- [x] 7.3 Test that legacy content with an enlarged first run renders an enlarged marker with no rewrite of the stored value +- [x] 7.4 Run the existing list suites — `list-margin-indent`, `list-tab-indent`, `list-style-auto-cycle`, `manual-list-style`, `ordered-list-interaction` — since replacing StarterKit's `listItem` and `bulletList` widens the blast radius to nesting, toggling, and indentation — all pass unchanged +- [x] 7.5 Update snapshots in `src/__tests__/__snapshots__/RichText.spec.tsx.snap` and confirm every diff is either an added marker attribute or the gutter change, with no unrelated churn — **no marker or gutter diff appeared at all**, confirming 4.3: the fixtures contain no formatted lists, so the markup is byte-identical. The one diff was `Characters (HTML): 82` → `49`, a consequence of 7.1: `characterCountHtml` counts `stringAttribute.value` (`EditorWrapper.tsx:99-100`), which is now the value as stored rather than the editor's re-serialization. See Risks in `design.md`. +- [x] 7.6 Add an E2E case per `docs/requirements/e2e-test-guidelines.md` covering enlarge-first-run in both an ordered and a bullet list, checking the marker scales and is not clipped — `e2e/RichText.spec.js`, one parameterized test per list type. `toHaveCSS` cannot address a pseudo-element, so the `::marker` read uses `expect.poll` to keep the auto-retry the guidelines ask for. Not-clipped is asserted as the list's `padding-left` exceeding the 84 px marker. +- [x] 7.7 Run `pnpm run test` and `pnpm turbo build` from the package root — 394 tests / 26 suites pass, 6 snapshots pass, build succeeds. The build surfaced a typing issue: the `?? ["li", …]` fallbacks referenced `this.options.HTMLAttributes`, which the local option interfaces do not declare; extending the upstream option types instead fails because their required fields are lost through the optional `this.parent?.()` spread. The fallbacks are unreachable (all three upstream nodes define `renderHTML`), so they were simplified to drop that reference. + +## 8. Manual verification and release notes + +- [x] 8.1 Verify in a Mendix project in both `styleDataFormat` modes: markers scale, sibling items format independently, nested lists follow their own first run, and task list checkboxes are untouched — confirmed working by the user in their Mendix project; not independently reproducible here, since no Mendix project is available in this environment +- [x] 8.2 Check a three-digit ordered list at the maximum font size against the `1.5` gutter multiplier; if it crowds, refine the multiplier in CSS or make it digit-count-aware using the list's `childCount` (recorded as a known heuristic in `design.md`) — **it crowded, so the gutter is now length-aware.** Measured in Chrome at 98px: the flat `1.5` multiplier gives a 147px gutter, but `998.` needs 191px and `1000.` needs 245px, so three- and four-digit markers were clipped (two digits, at 136px, fit). Replaced with `max(1.5em, calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5)))`, fed by a new `computeMarkerLength(listNode)` that counts the characters of the _longest_ marker — the last item's, from `start + childCount - 1`. It also handles the two non-decimal counter styles the widget supports, since `lower-roman` needs the numeral's length rather than the number's digits (`xxxviii` is 7 characters where 38 is 2). `--rt-marker-chars` is emitted only above 1, so bullet lists and short numbered lists keep byte-identical markup and fall back to the stylesheet default. Re-verified in Chrome against the _compiled_ declaration (Sass again drops the `calc()` wrapper inside `max()`): 107.8 / 225.4 / 284.2px for 1 / 3 / 4 characters, identical to the `calc()` form; a `start="998"` list at 98px gets a 284px gutter against a 245px widest marker, not clipped; class mode via `attr(data-marker-chars type())` matches inline mode; an unformatted list still computes to exactly `1.5em`. +- [x] 8.3 Confirm a document that is opened and closed without editing leaves the stored value byte-identical — mechanism unit-covered by `RichText.spec.tsx`, "does not write to the attribute on load" (both cases confirmed to fail with the `{ emitUpdate: false }` fix reverted); end-to-end behaviour confirmed working by the user +- [x] 8.4 Add a user-facing entry to `CHANGELOG.md` describing the behavior only — that list bullets and numbers now follow the formatting of the list item's first character — with no implementation detail — two entries under `[Unreleased] / Fixed`: the marker behaviour, and the load-time On change fix from 7.1, which is separately user-visible. diff --git a/packages/pluggableWidgets/rich-text-web/openspec/specs/list-marker-format/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/specs/list-marker-format/spec.md new file mode 100644 index 0000000000..05c3f55365 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/specs/list-marker-format/spec.md @@ -0,0 +1,197 @@ +# list-marker-format Specification + +## Purpose + +TBD - created by archiving change list-marker-first-run-format. Update Purpose after archive. + +## Requirements + +### Requirement: List markers follow the format of the list item's first inline run + +The bullet or number of a list item SHALL render with the font size, font weight, font style, color, and font family of the first inline run of that list item's first block. When the first inline run carries none of these formats, the marker SHALL render at the base size and format inherited from the list, exactly as it does today. + +Each list item SHALL be evaluated independently, so sibling items in the same list MAY render markers at different formats. + +#### Scenario: Enlarging the whole list item enlarges the marker + +- **WHEN** a list item's entire text is set to a larger font size +- **THEN** the item's bullet or number renders at that larger font size + +#### Scenario: Marker follows bold, italic, color, and font family + +- **WHEN** the first inline run of a list item is bold, italic, colored, or set to a different font family +- **THEN** the item's bullet or number renders bold, italic, in that color, and in that font family respectively + +#### Scenario: Unformatted list item is unchanged + +- **WHEN** a list item's first inline run carries no font size, weight, style, color, or family +- **THEN** the marker renders identically to the current behavior, with no marker format data emitted for that item + +#### Scenario: Sibling items format independently + +- **WHEN** one item of a list has an enlarged first run and another does not +- **THEN** only the enlarged item's marker is enlarged, and the sibling's marker is unchanged + +#### Scenario: Nested list items follow their own first run + +- **WHEN** a nested list item's first run is enlarged +- **THEN** that nested item's marker is enlarged, and the parent item's marker is unaffected + +### Requirement: Only formatting that covers the first character affects the marker + +When a user formats part of a list item's text, the marker SHALL change only if the formatted range includes the item's first character. Formatting that begins after the first character SHALL leave the marker unchanged. + +#### Scenario: Formatting only the first character + +- **WHEN** the user selects only the first character of a list item and increases its font size +- **THEN** the item's marker renders at the increased font size + +#### Scenario: Formatting a later portion of the text + +- **WHEN** the user selects a range that starts after the first character and increases its font size +- **THEN** the item's marker is unchanged + +#### Scenario: Removing formatting from the first character + +- **WHEN** the first run of a list item is enlarged and the user then clears its font size +- **THEN** the marker returns to the base size and the marker format data is removed from that item + +### Requirement: Marker format is derived, never stored in the document + +Marker format SHALL be computed from the list item's content during rendering. The widget SHALL NOT write marker format into the editor document, SHALL NOT declare it as a node attribute, and SHALL NOT modify stored content as a side effect of loading a document. + +Marker format data present in incoming HTML SHALL be ignored when parsing and recomputed when rendering. + +#### Scenario: Opening existing content does not modify it + +- **WHEN** a document containing lists is loaded into the widget and the user makes no edit +- **THEN** no update is emitted, the bound attribute is not marked as changed, and no change action is triggered + +#### Scenario: Legacy content renders correct markers without migration + +- **WHEN** content saved before this change, containing an enlarged first run in a list item, is loaded +- **THEN** the marker renders enlarged immediately, with no migration step and no rewrite of the stored value + +#### Scenario: Stale marker data in pasted HTML is discarded + +- **WHEN** HTML containing marker format data that disagrees with its own list item content is pasted +- **THEN** the pasted marker format data is discarded and the marker format is recomputed from the first inline run + +### Requirement: Marker format renders in the live editor, in read-only mode, and in saved HTML + +The marker format SHALL be correct in the editor as the user types, in read-only rendering, and in the HTML the widget emits. + +Editing a list item's first run SHALL update that item's marker without requiring a reload, re-render of the whole document, or loss of cursor position. + +#### Scenario: Marker updates while typing + +- **WHEN** the user changes the font size of a list item's first run in the editor +- **THEN** the marker updates immediately and the cursor position is preserved + +#### Scenario: Read-only rendering matches the editor + +- **WHEN** the same content is rendered in read-only mode +- **THEN** the markers render with the same format as in the editable editor + +#### Scenario: Saved HTML carries the marker format + +- **WHEN** content with formatted list markers is serialized by the widget +- **THEN** the emitted `
            • ` elements carry the marker format data +- **AND** loading that HTML back into the widget reproduces the same marker rendering + +#### Scenario: Copied content carries the marker format + +- **WHEN** a formatted list is copied out of the editor +- **THEN** the copied HTML carries the marker format data for each item + +### Requirement: Marker format renders in both inline and class style modes + +Marker format SHALL render correctly whether the widget is configured for inline styles or CSS classes, following the same convention the widget already uses for inline text formatting. + +#### Scenario: Inline mode emits inline marker format + +- **WHEN** the widget is in inline mode and a list item's first run is formatted +- **THEN** the `
            • ` carries the marker format in its inline `style` attribute +- **AND** the marker renders with that format + +#### Scenario: Class mode emits a class and data attributes + +- **WHEN** the widget is in class mode and a list item's first run is formatted +- **THEN** the `
            • ` carries a marker format class and corresponding `data-` attributes +- **AND** the marker renders with that format + +#### Scenario: Class mode resolves legacy font family values + +- **WHEN** class mode content specifies a font family in the legacy form, without the current font value attribute +- **THEN** the marker still renders in that font family + +### Requirement: The list gutter grows to fit an enlarged marker + +When a list contains an item whose marker is enlarged, the list SHALL increase its marker gutter so the marker is neither clipped nor overlapping the item text. The gutter SHALL be derived from the largest marker font size among the list's direct list items. + +When no item in a list has an enlarged marker, the gutter SHALL be unchanged from its current value. + +#### Scenario: Large ordered list numbers are not clipped + +- **WHEN** an ordered list contains items whose first runs are set to a large font size +- **THEN** the numbers render fully within the list's gutter without being clipped or overlapping the item text + +#### Scenario: Large bullets are not clipped + +- **WHEN** a bullet list contains items whose first runs are set to a large font size +- **THEN** the bullets render fully within the list's gutter without overlapping the item text + +#### Scenario: Gutter is unchanged for unformatted lists + +- **WHEN** a list has no item with an enlarged marker +- **THEN** the list's gutter is identical to the current behavior + +#### Scenario: Gutter is sized by the largest marker in the list + +- **WHEN** one item of a list has a much larger first run than its siblings +- **THEN** the list's gutter accommodates the largest marker +- **AND** the smaller siblings' markers remain aligned with it + +#### Scenario: Gutter coexists with list indent margin + +- **WHEN** a list has both a non-zero indent and an enlarged marker +- **THEN** the indent margin and the enlarged gutter both apply, and the markers remain visible and not collapsed against the text + +### Requirement: Existing list behavior is preserved + +This change SHALL NOT alter marker glyph selection, list nesting, list toggling, or list indentation. + +#### Scenario: Nesting level cycling is unchanged + +- **WHEN** lists are nested to any depth +- **THEN** the ordered and unordered style cycling by nesting depth behaves exactly as before + +#### Scenario: Manual list style overrides are unchanged + +- **WHEN** an ordered list has a manually selected numbering style +- **THEN** that style and its nested cycle offset behave exactly as before + +#### Scenario: Tab nesting and indent shortcuts are unchanged + +- **WHEN** the user nests list items with Tab or indents a list with the indent shortcut +- **THEN** the behavior is unchanged, including when the affected items have formatted markers + +#### Scenario: List toggling is unchanged + +- **WHEN** the user toggles a bullet or ordered list on or off from the toolbar +- **THEN** the behavior is unchanged + +### Requirement: Task lists are excluded + +Task list items SHALL NOT receive marker formatting. Their checkbox rendering SHALL be unchanged. + +#### Scenario: Formatting a task item's first run leaves the checkbox alone + +- **WHEN** the first run of a task list item is enlarged, colored, or bolded +- **THEN** the checkbox renders unchanged +- **AND** no marker format data is emitted for that item + +#### Scenario: Task lists nested with other list types are unaffected + +- **WHEN** a task list is nested inside a bullet or ordered list with formatted markers +- **THEN** the task items' checkboxes are unchanged while the enclosing list's markers are formatted diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx index 34c7da7c34..258d84eeb1 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps"; @@ -96,6 +96,42 @@ describe("Rich Text", () => { expect(component.container).toMatchSnapshot(); }); + describe("does not write to the attribute on load", () => { + // The editor's serialization of a stored value is rarely byte-identical to it, and + // derived list marker formatting widens that gap. Writing the difference back on mount + // would dirty the attribute and fire the On change action without any user edit. + // + // The write is debounced by 200ms in EditorWrapper, so the timer has to be flushed — + // otherwise these assertions would hold no matter what the editor emitted. + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function renderAndFlush(stringAttribute: RichTextContainerProps["stringAttribute"]): void { + render(); + act(() => { + jest.advanceTimersByTime(500); + }); + } + + it("leaves an unformatted value untouched", () => { + const stringAttribute = new EditableValueBuilder().withValue(richTextDefaultValue).build(); + + renderAndFlush(stringAttribute); + + expect(stringAttribute.setValue).not.toHaveBeenCalled(); + }); + + it("leaves a list whose first run is formatted untouched", () => { + const stored = `
              1. Hello

              `; + const stringAttribute = new EditableValueBuilder().withValue(stored).build(); + + renderAndFlush(stringAttribute); + + expect(stringAttribute.setValue).not.toHaveBeenCalled(); + expect(stringAttribute.value).toBe(stored); + }); + }); + describe("Empty content handling", () => { it("handles empty string value", () => { const emptyAttribute = new EditableValueBuilder().withValue("").build(); diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap index 9ae10c56d5..59c9cb351f 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap @@ -1004,7 +1004,7 @@ exports[`Rich Text renders with HTML character count status bar 1`] = ` - Characters (HTML): 82 + Characters (HTML): 49
  • diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx index 9af748034d..6071712d42 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx @@ -23,6 +23,7 @@ import { HighlightedCodeEditor } from "./HighlightedCodeEditor"; import { LinkBubbleMenu } from "./LinkBubbleMenu"; import { Toolbar } from "./toolbars"; import { RichTextContainerProps } from "../../typings/RichTextProps"; +import { BulletListStyled } from "../extensions/BulletListStyled"; import { FontFamilyClass } from "../extensions/FontFamilyClass"; import { FontSize } from "../extensions/FontSize"; import { Fullscreen } from "../extensions/Fullscreen"; @@ -31,6 +32,7 @@ import { ImagePasteDrop, IMAGE_DROP_ERROR_EVENT } from "../extensions/ImagePaste import { ImageResize } from "../extensions/ImageResize"; import { Indent } from "../extensions/Indent"; import { KeyboardNavigation } from "../extensions/KeyboardNavigation"; +import { ListItemMarkerFormat } from "../extensions/ListItemMarkerFormat"; import { OrderedListStyled } from "../extensions/OrderedListStyled"; import { TableBackgroundColor } from "../extensions/TableBackgroundColor"; import { TableCellBackgroundColor } from "../extensions/TableCellBackgroundColor"; @@ -248,7 +250,11 @@ const Editor = forwardRef((props, ref) => { const extensions = useMemo( () => [ StarterKit.configure({ + // All three list nodes are replaced below so list markers can follow the + // format of each item's first inline run. orderedList: false, + bulletList: false, + listItem: false, link: { openOnClick: false, HTMLAttributes: { class: "tiptap-link" } @@ -257,6 +263,12 @@ const Editor = forwardRef((props, ref) => { OrderedListStyled.configure({ styleDataFormat }), + BulletListStyled.configure({ + styleDataFormat + }), + ListItemMarkerFormat.configure({ + styleDataFormat + }), TextStyle, Superscript, Subscript, @@ -386,7 +398,14 @@ const Editor = forwardRef((props, ref) => { // only do update if editor not focused, otherwise it will override the user input. const newContent = editor.getHTML(); if (newContent !== defaultValue) { - editor.commands.setContent(defaultValue || ""); + // `emitUpdate: false`: this direction is external value -> editor, so echoing an + // update back out would only write the editor's own serialization over a value + // the user never touched. That rewrite is not harmless — it dirties the bound + // attribute and fires the On change action on mere page load, for any stored + // value that is not already byte-identical to `getHTML()`. Derived list marker + // formatting makes that true of every previously saved formatted list. + // A genuine edit still emits normally, through the `onUpdate` handler above. + editor.commands.setContent(defaultValue || "", { emitUpdate: false }); } }, [editor, defaultValue]); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts new file mode 100644 index 0000000000..d8c87583a9 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/BulletListStyled.ts @@ -0,0 +1,42 @@ +import { mergeAttributes } from "@tiptap/core"; +// Imported from the umbrella package, which is a direct dependency; +// `@tiptap/extension-bullet-list` is only transitively available. +import { BulletList } from "@tiptap/extension-list"; +import { computeMaxMarkerSize, maxMarkerSizeToAttrs } from "../utils/markerFormat"; + +export interface BulletListStyledOptions { + styleDataFormat: "inline" | "class"; +} + +/** + * Bullet list that publishes the largest marker size among its direct items. + * + * An enlarged marker grows leftward out of the list's `padding-left`, so the stylesheet + * widens the gutter from this value. Emitted only when an item actually has an enlarged + * marker, so unformatted lists render unchanged. + * + * Kept fresh in the live view by the decoration plugin in `ListItemMarkerFormat`, for the + * same `toDOM` staleness reason documented there. + */ +export const BulletListStyled = BulletList.extend({ + name: "bulletList", + + addOptions() { + return { + ...this.parent?.(), + styleDataFormat: "inline" + }; + }, + + renderHTML(props) { + const { node, HTMLAttributes } = props; + const merged = mergeAttributes( + HTMLAttributes, + maxMarkerSizeToAttrs(computeMaxMarkerSize(node), this.options.styleDataFormat) + ); + + // `BulletList` always defines `renderHTML`; the fallback only guards against an + // upstream change removing it. + return this.parent?.({ ...props, HTMLAttributes: merged }) ?? ["ul", merged, 0]; + } +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts new file mode 100644 index 0000000000..e9e827243a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/ListItemMarkerFormat.ts @@ -0,0 +1,123 @@ +import { mergeAttributes } from "@tiptap/core"; +import { ListItem } from "@tiptap/extension-list-item"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + computeMarkerFormat, + computeMarkerLength, + computeMaxMarkerSize, + markerFormatToAttrs, + maxMarkerSizeToAttrs +} from "../utils/markerFormat"; + +export interface ListItemMarkerFormatOptions { + styleDataFormat: "inline" | "class"; +} + +const markerFormatPluginKey = new PluginKey("listItemMarkerFormat"); + +/** Lists whose markers participate; `taskList` renders checkboxes and has no `::marker`. */ +const GUTTER_LIST_TYPES = new Set(["orderedList", "bulletList"]); + +/** + * Makes a list item's bullet or number follow the format of its first inline run. + * + * Marker format is *derived*, never stored. Nothing is written to the document, so opening + * existing content does not modify it — which matters because `Editor.tsx` reconciles by + * comparing `editor.getHTML()` against the bound value, and `onUpdate` pushes every + * transaction back out. A document mutation here would dirty every existing list document + * on load and fire change actions. + * + * Deriving it requires two delivery paths, because neither alone is sufficient: + * + * renderHTML — feeds getHTML(), copy/paste and the initial view render, but ProseMirror + * only re-invokes toDOM when a node's *markup* changes. `sameMarkup` + * compares type/attrs/marks, not content, so restyling the first run reuses + * the existing
  • element and the attribute would go stale. + * decoration — keeps the live
  • fresh, but decorations live in the view and never + * reach getHTML(). + * + * Both call `computeMarkerFormat`, so they cannot disagree. + * + * No node attribute is declared, so `parseHTML` ignores incoming `--rt-marker-*` and + * `data-marker-*`: pasted or reloaded content drops stale marker data and recomputes it. + */ +export const ListItemMarkerFormat = ListItem.extend({ + name: "listItem", + + addOptions() { + return { + ...this.parent?.(), + styleDataFormat: "inline" + }; + }, + + renderHTML(props) { + const { node, HTMLAttributes } = props; + // Merge into HTMLAttributes and delegate, rather than rebuilding the spec, so any + // upstream ListItem rendering logic keeps working. `mergeAttributes` concatenates + // `style` and `class` rather than overwriting them. + const merged = mergeAttributes( + HTMLAttributes, + markerFormatToAttrs(computeMarkerFormat(node), this.options.styleDataFormat) + ); + + // `ListItem` always defines `renderHTML`; the fallback only guards against an + // upstream change removing it. + return this.parent?.({ ...props, HTMLAttributes: merged }) ?? ["li", merged, 0]; + }, + + addProseMirrorPlugins() { + const styleDataFormat = this.options.styleDataFormat; + + return [ + ...(this.parent?.() ?? []), + new Plugin({ + key: markerFormatPluginKey, + state: { + init: (_, state) => buildDecorations(state.doc, styleDataFormat), + apply(transaction, previous, _oldState, newState) { + // Selection-only transactions cannot change any marker. + if (!transaction.docChanged) { + return previous; + } + return buildDecorations(newState.doc, styleDataFormat); + } + }, + props: { + decorations: state => markerFormatPluginKey.getState(state) + } + }) + ]; + } +}); + +/** + * Node decorations carrying marker format for every list item and gutter size for every + * list, at any nesting depth. + * + * Merging with what `toDOM` already emitted is safe: prosemirror-view appends decoration + * `style` (`dom.style.cssText += cur.style`) and adds decoration classes via + * `classList.add`, so neither clobbers the node's own attributes. + */ +function buildDecorations(doc: ProseMirrorNode, styleDataFormat: "inline" | "class"): DecorationSet { + const decorations: Decoration[] = []; + + doc.descendants((node, pos) => { + // `taskItem`/`taskList` are distinct node types and never match, so task lists are + // excluded without an explicit guard. + const attrs = + node.type.name === "listItem" + ? markerFormatToAttrs(computeMarkerFormat(node), styleDataFormat) + : GUTTER_LIST_TYPES.has(node.type.name) + ? maxMarkerSizeToAttrs(computeMaxMarkerSize(node), styleDataFormat, computeMarkerLength(node)) + : null; + + if (attrs && Object.keys(attrs).length > 0) { + decorations.push(Decoration.node(pos, pos + node.nodeSize, attrs)); + } + }); + + return DecorationSet.create(doc, decorations); +} diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts index 625b791769..c6d89c539e 100644 --- a/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/OrderedListStyled.ts @@ -1,4 +1,6 @@ +import { mergeAttributes } from "@tiptap/core"; import { OrderedList } from "@tiptap/extension-ordered-list"; +import { computeMarkerLength, computeMaxMarkerSize, maxMarkerSizeToAttrs } from "../utils/markerFormat"; export interface OrderedListStyledOptions { styleDataFormat: "inline" | "class"; @@ -62,6 +64,22 @@ export const OrderedListStyled = OrderedList.extend({ }; }, + // Publishes the largest marker size among direct items, plus how many characters the + // longest counter takes, so the stylesheet can widen the marker gutter to fit. Delegates + // to the parent, which handles the `start` and `type` attributes specially. Kept fresh in + // the live view by the decoration plugin in ListItemMarkerFormat. + renderHTML(props) { + const { node, HTMLAttributes } = props; + const merged = mergeAttributes( + HTMLAttributes, + maxMarkerSizeToAttrs(computeMaxMarkerSize(node), this.options.styleDataFormat, computeMarkerLength(node)) + ); + + // `OrderedList` always defines `renderHTML`; the fallback only guards against an + // upstream change removing it. + return this.parent?.({ ...props, HTMLAttributes: merged }) ?? ["ol", merged, 0]; + }, + addCommands() { return { ...this.parent?.(), diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts new file mode 100644 index 0000000000..b89f0cd235 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/ListItemMarkerFormat.spec.ts @@ -0,0 +1,227 @@ +import { Editor } from "@tiptap/core"; +import { TaskItem } from "@tiptap/extension-task-item"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TextStyle } from "@tiptap/extension-text-style"; +import { StarterKit } from "@tiptap/starter-kit"; +import { BulletListStyled } from "../BulletListStyled"; +import { FontSize } from "../FontSize"; +import { ListItemMarkerFormat } from "../ListItemMarkerFormat"; +import { OrderedListStyled } from "../OrderedListStyled"; + +type StyleDataFormat = "inline" | "class"; + +/** Mirrors the list-related extension set that `Editor.tsx` builds. */ +function makeEditor(styleDataFormat: StyleDataFormat = "inline"): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [ + StarterKit.configure({ orderedList: false, bulletList: false, listItem: false }), + OrderedListStyled.configure({ styleDataFormat }), + BulletListStyled.configure({ styleDataFormat }), + ListItemMarkerFormat.configure({ styleDataFormat }), + TextStyle, + TaskList, + TaskItem.configure({ nested: true }), + FontSize.configure({ types: ["textStyle"], styleDataFormat }) + ] + }); +} + +/** + * `setContent` appends an empty trailing paragraph when the document ends in a list. That is + * unrelated to marker format, so it is dropped before comparing. + */ +function setAndGet(editor: Editor, html: string): string { + editor.commands.setContent(html); + return editor.getHTML().replace(/

    <\/p>$/, ""); +} + +/** The first `

  • ` in the live editor view, i.e. after decorations are applied. */ +function liveListItem(editor: Editor): HTMLElement { + const li = editor.view.dom.querySelector("li"); + if (!li) { + throw new Error("No
  • in the editor view"); + } + return li as HTMLElement; +} + +/** + * Applies a font size through the toolbar command path. + * + * Class-mode markup cannot be used as a test input: `textStyle`'s only parse rule requires a + * `style` attribute, so a `` is discarded on + * parse. Driving the command reaches the same document state either way. + */ +function setFontSizeOnAll(editor: Editor, size: string): void { + editor.commands.selectAll(); + editor.commands.setFontSize(size); +} + +describe("ListItemMarkerFormat", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + + describe("inline mode", () => { + beforeEach(() => { + editor = makeEditor("inline"); + }); + + it("publishes the first run's format as custom properties on the li", () => { + const html = setAndGet(editor, `
    1. Hello

    `); + + expect(html).toContain(`
  • { + const html = setAndGet(editor, `
    • big

    `); + + expect(html).toContain(`
      { + const source = `
      1. one

      2. two

      `; + + expect(setAndGet(editor, source)).toBe(source); + }); + + it("emits nothing for a task list", () => { + const html = setAndGet( + editor, + `
      • ` + + `

        task

      ` + ); + + expect(html).toContain("font-size: 32px"); + expect(html).not.toContain("--rt-marker"); + }); + + it("keeps the ordered list's start and type attributes", () => { + const html = setAndGet( + editor, + `
      1. c

      ` + ); + + expect(html).toContain(`start="3"`); + expect(html).toContain(`type="a"`); + expect(html).toContain("--rt-marker-max-size: 32px"); + }); + + it("marks only the item whose first run is formatted", () => { + editor.commands.setContent(`
      1. one

      2. two

      `); + // Select just the first character of the first item and enlarge it. + // ol opens at 0, li at 1, p at 2, so the text "one" starts at 3. + editor.commands.setTextSelection({ from: 3, to: 4 }); + editor.commands.setFontSize("32px"); + const html = editor.getHTML(); + + expect(html).toContain(`
    • { + beforeEach(() => { + editor = makeEditor("class"); + }); + + it("publishes per-property classes and data attributes", () => { + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + const html = editor.getHTML(); + + expect(html).toContain(`data-marker-font-size="32"`); + expect(html).toContain("has-marker-font-size"); + expect(html).toContain(`data-marker-max-size="32"`); + expect(html).toContain("has-marker-gutter"); + }); + + it("leaves an unformatted list byte-identical", () => { + const source = `
      • one

      `; + + expect(setAndGet(editor, source)).toBe(source); + }); + }); + + // The two delivery paths — `renderHTML` for `getHTML()`, decorations for the live view — + // must not drift, so both are asserted against the same document. Class mode is used + // because its attributes survive jsdom, whose `style.cssText` drops custom properties. + describe("view and getHTML agreement", () => { + beforeEach(() => { + editor = makeEditor("class"); + }); + + it("marks the live li the same way getHTML does", () => { + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + const li = liveListItem(editor); + + expect(li.classList.contains("has-marker-font-size")).toBe(true); + expect(li.getAttribute("data-marker-font-size")).toBe("32"); + expect(editor.getHTML()).toContain(`data-marker-font-size="32"`); + }); + + it("refreshes the live li when only the first run's format changes", () => { + // `toDOM` is not re-invoked here: `sameMarkup` compares type, attrs and marks but + // not content, so without the decoration plugin the
    • would keep 32. + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + expect(liveListItem(editor).getAttribute("data-marker-font-size")).toBe("32"); + + setFontSizeOnAll(editor, "48px"); + + expect(liveListItem(editor).getAttribute("data-marker-font-size")).toBe("48"); + expect(editor.getHTML()).toContain(`data-marker-font-size="48"`); + }); + + it("clears the live li when the format is removed", () => { + editor.commands.setContent(`
      1. Hello

      `); + setFontSizeOnAll(editor, "32px"); + + editor.commands.selectAll(); + editor.commands.unsetFontSize(); + + expect(liveListItem(editor).hasAttribute("data-marker-font-size")).toBe(false); + expect(editor.getHTML()).not.toContain("data-marker-font-size"); + }); + }); + + describe("stale and legacy content", () => { + beforeEach(() => { + editor = makeEditor("inline"); + }); + + it("discards marker data that disagrees with the content", () => { + // What a paste from a document whose first run was since unformatted looks like. + const html = setAndGet( + editor, + `
        ` + + `
      1. plain

      2. ` + + `
      ` + ); + + expect(html).not.toContain("99px"); + expect(html).not.toContain("--rt-marker"); + }); + + it("recomputes stale marker data from the content that is actually there", () => { + const html = setAndGet( + editor, + `
      1. ` + + `

        Hello

      ` + ); + + expect(html).toContain("--rt-marker-font-size: 32px"); + expect(html).not.toContain("99px"); + }); + + it("renders a marker for legacy content that carries no marker data", () => { + const html = setAndGet(editor, `
      • legacy

      `); + + expect(html).toContain("--rt-marker-font-size: 42px"); + // The stored inline font size is untouched; the marker data is purely additive. + expect(html).toContain("font-size: 42px"); + }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss index bd1a21149f..060dcca460 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichText.scss @@ -177,9 +177,36 @@ $rte-warning-text: #8f620b; font-weight: 700; } + // Marker follows the format of the list item's first inline run. The values are + // published as custom properties on the
    • by ListItemMarkerFormat; a custom + // property does not affect the item's own content, only what ::marker reads. + // Absent property = `inherit`, i.e. unchanged from the base. + // Task items carry a checkbox instead of a marker, and custom properties inherit + // down the DOM, so a task list nested under a formatted item is excluded here. + li:not([data-type="taskItem"])::marker { + font-size: var(--rt-marker-font-size, inherit); + font-weight: var(--rt-marker-font-weight, inherit); + font-style: var(--rt-marker-font-style, inherit); + color: var(--rt-marker-color, inherit); + font-family: var(--rt-marker-font-family, inherit); + } + // Ordered list auto-cycling (6 levels = 2 full cycles) ol { - padding-left: 1.5em; + // An enlarged marker grows leftward out of this padding, so the gutter has + // to grow with it. `--rt-marker-max-size` is the largest marker size among + // the list's direct items; when absent, calc() is 0px and this is exactly + // the previous `1.5em`. + // + // `--rt-marker-chars` is how many characters the longest counter takes, so a + // hundred-item list reserves room for `100.` rather than for `1.`. The + // factors come from measuring digit advance widths in Chrome: ~0.55em per + // digit, plus the `.` and the gap to the text. Verified to clear the widest + // marker at every length from 1 to 4 characters at the 98px maximum size. + padding-left: max( + 1.5em, + calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5)) + ); margin: 0.5em 0; list-style-type: decimal; @@ -206,7 +233,10 @@ $rte-warning-text: #8f620b; // Unordered list auto-cycling (6 levels = 2 full cycles) ul { - padding-left: 1.5em; + padding-left: max( + 1.5em, + calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5)) + ); margin: 0.5em 0; list-style-type: disc; diff --git a/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss b/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss index b131a69db6..ed3788ff1a 100644 --- a/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss +++ b/packages/pluggableWidgets/rich-text-web/src/ui/RichTextFormatStyle.scss @@ -23,7 +23,7 @@ // inline-mode margin step (2em per level). ol.indent-#{$i}, ul.indent-#{$i} { - padding-left: 1.5em; + padding-left: max(1.5em, calc(var(--rt-marker-max-size, 0px) * (0.6 * var(--rt-marker-chars, 1) + 0.5))); margin-left: #{$i * 2}em; } } @@ -53,6 +53,41 @@ font-size: attr(data-font-size px); } + // List marker format, class mode. These feed the same `--rt-marker-*` custom properties + // that inline mode writes directly, so `li::marker` in RichText.scss stays the single + // consumption point for both modes. Setting the font properties on `::marker` here + // instead would lose to that rule, which is both more specific and later in source + // order (`@use` hoists this file above it). + .has-marker-font-size { + --rt-marker-font-size: attr(data-marker-font-size px); + } + + .has-marker-color { + --rt-marker-color: attr(data-marker-color type()); + } + + .has-marker-font-family { + --rt-marker-font-family: attr(data-marker-font-family raw-string); + } + + .has-marker-bold { + --rt-marker-font-weight: bold; + } + + .has-marker-italic { + --rt-marker-font-style: italic; + } + + // Widens the marker gutter on the list itself; consumed by the `ol`/`ul` padding rules. + .has-marker-gutter { + --rt-marker-max-size: attr(data-marker-max-size px); + } + + // Only present when the longest counter takes more than one character. + .has-marker-chars { + --rt-marker-chars: attr(data-marker-chars type()); + } + .text-align-left { text-align: left; } diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts new file mode 100644 index 0000000000..2b7a8678d8 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/markerFormat.spec.ts @@ -0,0 +1,315 @@ +import { Editor } from "@tiptap/core"; +import { TextStyle } from "@tiptap/extension-text-style"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { StarterKit } from "@tiptap/starter-kit"; +import { FontFamilyClass } from "../../extensions/FontFamilyClass"; +import { FontSize } from "../../extensions/FontSize"; +import { ImageResize } from "../../extensions/ImageResize"; +import { TextColorClass } from "../../extensions/TextColorClass"; +import { + computeMarkerFormat, + computeMarkerLength, + computeMaxMarkerSize, + markerFormatToClassAttrs, + markerFormatToInlineStyle, + maxMarkerSizeToAttrs +} from "../markerFormat"; + +function makeEditor(): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [ + StarterKit, + TextStyle, + ImageResize.configure({ inline: true }), + FontSize.configure({ types: ["textStyle"], styleDataFormat: "inline" }), + FontFamilyClass.configure({ types: ["textStyle"], styleDataFormat: "inline" }), + TextColorClass.configure({ types: ["textStyle"], styleDataFormat: "inline" }) + ] + }); +} + +/** First node of the given type in the document. */ +function firstNodeOfType(editor: Editor, typeName: string): ProseMirrorNode { + let found: ProseMirrorNode | null = null; + editor.state.doc.descendants(node => { + if (found) { + return false; + } + if (node.type.name === typeName) { + found = node; + return false; + } + return true; + }); + if (!found) { + throw new Error(`No ${typeName} node found`); + } + return found; +} + +function markerFormatOf(editor: Editor, html: string): ReturnType { + editor.commands.setContent(html); + return computeMarkerFormat(firstNodeOfType(editor, "listItem")); +} + +describe("computeMarkerFormat", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + beforeEach(() => { + editor = makeEditor(); + }); + + it("reads all five properties from the first inline run", () => { + const format = markerFormatOf( + editor, + `
      • Hi

      ` + ); + + expect(format).toEqual({ + fontSize: "32px", + color: "rgb(255, 0, 0)", + fontFamily: "Arial", + bold: true, + italic: true + }); + }); + + it("reads a font size applied to the whole item", () => { + const format = markerFormatOf(editor, `
      1. Hello

      `); + + expect(format).toEqual({ fontSize: "32px" }); + }); + + it("reads a font size applied to only the first character", () => { + const format = markerFormatOf(editor, `
      1. Hello

      `); + + expect(format).toEqual({ fontSize: "32px" }); + }); + + it("ignores formatting that starts after the first character", () => { + const format = markerFormatOf(editor, `
      1. Hello

      `); + + expect(format).toBeNull(); + }); + + it("returns null for an unformatted item", () => { + expect(markerFormatOf(editor, `
      1. plain

      `)).toBeNull(); + }); + + it("returns null for an empty item", () => { + expect(markerFormatOf(editor, `
      `)).toBeNull(); + }); + + it("returns null when the item starts with an inline image", () => { + const format = markerFormatOf( + editor, + `
      1. after

      ` + ); + + expect(format).toBeNull(); + }); + + it("returns null when the item starts with a hard break", () => { + const format = markerFormatOf( + editor, + `

      1. after

      ` + ); + + expect(format).toBeNull(); + }); + + it("reads bold alone without a textStyle mark", () => { + expect(markerFormatOf(editor, `
      • bold

      `)).toEqual({ bold: true }); + }); + + it("evaluates each item independently", () => { + editor.commands.setContent( + `
      1. big

      2. small

      ` + ); + const list = firstNodeOfType(editor, "orderedList"); + + expect(computeMarkerFormat(list.child(0))).toEqual({ fontSize: "32px" }); + expect(computeMarkerFormat(list.child(1))).toBeNull(); + }); + + it("reads a nested item's own first run, not its parent's", () => { + editor.commands.setContent( + `
      • outer

        • inner

      ` + ); + const outer = firstNodeOfType(editor, "listItem"); + + expect(computeMarkerFormat(outer)).toBeNull(); + expect(computeMarkerFormat(firstNodeOfType(editor, "bulletList").child(0).child(1).child(0))).toEqual({ + fontSize: "42px" + }); + }); + + it("drops an unsafe font size rather than emitting it", () => { + const format = markerFormatOf(editor, `
      1. ok

      `); + expect(format?.fontSize).toBe("32px"); + + // A value that is not a valid CSS size must not reach the marker. + editor.commands.setContent(`
      1. ok

      `); + const item = firstNodeOfType(editor, "listItem"); + const textStyleType = editor.schema.marks.textStyle; + const doctored = item.type.create( + item.attrs, + item.firstChild!.type.create(item.firstChild!.attrs, [ + editor.schema.text("ok", [textStyleType.create({ fontSize: "url(javascript:alert(1))" })]) + ]) + ); + + expect(computeMarkerFormat(doctored)).toBeNull(); + }); +}); + +describe("computeMaxMarkerSize", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + beforeEach(() => { + editor = makeEditor(); + }); + + it("returns the largest first-run size among direct items", () => { + editor.commands.setContent( + `
        ` + + `
      1. a

      2. ` + + `
      3. b

      4. ` + + `
      5. c

      6. ` + + `
      ` + ); + + expect(computeMaxMarkerSize(firstNodeOfType(editor, "orderedList"))).toBe("84px"); + }); + + it("returns null when no item has an enlarged marker", () => { + editor.commands.setContent(`
      1. a

      2. b

      `); + + expect(computeMaxMarkerSize(firstNodeOfType(editor, "orderedList"))).toBeNull(); + }); + + it("ignores a nested list's items", () => { + editor.commands.setContent( + `
      • outer

        • inner

      ` + ); + + expect(computeMaxMarkerSize(firstNodeOfType(editor, "bulletList"))).toBeNull(); + }); +}); + +describe("computeMarkerLength", () => { + let editor: Editor; + afterEach(() => editor?.destroy()); + beforeEach(() => { + editor = makeEditor(); + }); + + function lengthOf(html: string, typeName = "orderedList"): number { + editor.commands.setContent(html); + return computeMarkerLength(firstNodeOfType(editor, typeName)); + } + + function items(count: number): string { + return `
    • x

    • `.repeat(count); + } + + it("counts the digits of the last item's number", () => { + expect(lengthOf(`
        ${items(9)}
      `)).toBe(1); + expect(lengthOf(`
        ${items(10)}
      `)).toBe(2); + expect(lengthOf(`
        ${items(100)}
      `)).toBe(3); + }); + + it("accounts for a start offset, which shifts every number up", () => { + expect(lengthOf(`
        ${items(3)}
      `)).toBe(4); + }); + + it("returns 1 for a bullet list, whose marker is a single glyph", () => { + expect(lengthOf(`
        ${items(100)}
      `, "bulletList")).toBe(1); + }); + + it("counts alphabetic markers, which stay one character through z", () => { + expect(lengthOf(`
        ${items(26)}
      `)).toBe(1); + expect(lengthOf(`
        ${items(27)}
      `)).toBe(2); + }); + + it("counts roman markers by numeral length, not by digits", () => { + // "viii" is four characters where the decimal 8 is one. + expect(lengthOf(`
        ${items(8)}
      `)).toBe(4); + expect(lengthOf(`
        ${items(38)}
      `)).toBe(7); // xxxviii + }); + + it("ignores nested items, which belong to their own list", () => { + expect(lengthOf(`
      1. a

          ${items(20)}
      `)).toBe(1); + }); +}); + +describe("marker format serializers", () => { + it("emits custom properties for inline mode", () => { + const style = markerFormatToInlineStyle({ + fontSize: "32px", + color: "red", + fontFamily: "Arial", + bold: true, + italic: true + }); + + expect(style).toBe( + "--rt-marker-font-size: 32px; --rt-marker-color: red; --rt-marker-font-family: Arial; " + + "--rt-marker-font-weight: bold; --rt-marker-font-style: italic" + ); + }); + + it("emits only the properties that are set", () => { + expect(markerFormatToInlineStyle({ fontSize: "20px" })).toBe("--rt-marker-font-size: 20px"); + }); + + it("emits one class per property for class mode", () => { + const attrs = markerFormatToClassAttrs({ + fontSize: "32px", + color: "red", + fontFamily: "Arial", + bold: true, + italic: true + }); + + expect(attrs).toEqual({ + "data-marker-font-size": "32", + "data-marker-color": "red", + "data-marker-font-family": "Arial", + class: "has-marker-font-size has-marker-color has-marker-font-family has-marker-bold has-marker-italic" + }); + }); + + it("omits classes for properties that are not set", () => { + expect(markerFormatToClassAttrs({ italic: true })).toEqual({ class: "has-marker-italic" }); + }); +}); + +describe("maxMarkerSizeToAttrs", () => { + it("emits nothing when no item has an enlarged marker", () => { + expect(maxMarkerSizeToAttrs(null, "inline", 3)).toEqual({}); + expect(maxMarkerSizeToAttrs(null, "class", 3)).toEqual({}); + }); + + it("omits the character count at 1, keeping the markup as it was before this feature", () => { + expect(maxMarkerSizeToAttrs("84px", "inline")).toEqual({ style: "--rt-marker-max-size: 84px" }); + expect(maxMarkerSizeToAttrs("84px", "class")).toEqual({ + "data-marker-max-size": "84", + class: "has-marker-gutter" + }); + }); + + it("emits the character count above 1, so the gutter fits the longest marker", () => { + expect(maxMarkerSizeToAttrs("84px", "inline", 3)).toEqual({ + style: "--rt-marker-max-size: 84px; --rt-marker-chars: 3" + }); + expect(maxMarkerSizeToAttrs("84px", "class", 3)).toEqual({ + "data-marker-max-size": "84", + "data-marker-chars": "3", + class: "has-marker-gutter has-marker-chars" + }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts b/packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts new file mode 100644 index 0000000000..aae90ca037 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/markerFormat.ts @@ -0,0 +1,332 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { isSafeCssColor, isSafeCssFontFamily, isSafeCssSize, normalizeCssSize } from "./helpers"; + +/** + * Formatting a list marker inherits from the first inline run of its list item. + * + * `::marker` inherits from its `
    • `, but every format the user can apply lands on an + * inline mark two levels down (`
    • >

      > `), and CSS has no child-to-ancestor + * selector. So the format has to be lifted onto the `

    • ` by JavaScript. + * + * Word and Google Docs both take the format of the item's *first text run*, which also + * gives a well-defined answer when the user formats only part of the item. Since partial + * selection splits the text into separate marked runs, "read the first inline child" + * expresses that rule directly: + * + * select all "Hello" -> Hello marker 32px + * select only "H" -> Hello marker 32px + * select only "llo" -> Hello marker unchanged + */ +export interface MarkerFormat { + fontSize?: string; + color?: string; + fontFamily?: string; + bold?: true; + italic?: true; +} + +/** Only px sizes participate in gutter math; see `parsePxSize`. */ +const PX_SIZE = /^(\d+(?:\.\d+)?)px$/; + +/** Class-mode `attr()` consumers take the bare number, matching `has-font-size`. */ +const LEADING_NUMBER = /^(\d+(?:\.\d+)?)/; + +/** + * Read the marker format from a `listItem` node. + * + * `listItem`'s content expression is `paragraph block*`, so the first child is always a + * paragraph and "the first inline run" is unambiguous. Returns `null` when the first run + * carries none of the relevant marks, so an unformatted item emits no attributes at all + * and renders byte-identically to before this feature existed. + */ +export function computeMarkerFormat(node: ProseMirrorNode): MarkerFormat | null { + // Non-text leading nodes (images, hard breaks) have an empty mark set, so they fall + // through to `null` without special-casing. + const marks = node.firstChild?.firstChild?.marks; + if (!marks?.length) { + return null; + } + + const format: MarkerFormat = {}; + + // `textStyle` is the single mark behind FontSize, TextColorClass and FontFamilyClass. + const textStyle = marks.find(mark => mark.type.name === "textStyle"); + if (textStyle) { + // The colour attribute is `textColor`, registered by TextColorClass — not the + // `color` attribute of @tiptap/extension-color, which this widget does not load. + const { fontSize, textColor, fontFamily } = textStyle.attrs; + + const normalizedSize = typeof fontSize === "string" ? normalizeCssSize(fontSize) : null; + if (normalizedSize && isSafeCssSize(normalizedSize)) { + format.fontSize = normalizedSize; + } + if (typeof textColor === "string" && isSafeCssColor(textColor)) { + format.color = textColor; + } + // `fontFamily` always holds the CSS value in both style modes; `fontValue` is only + // the kebab-case identifier the toolbar dropdown matches on, so it is not used here. + if (typeof fontFamily === "string" && isSafeCssFontFamily(fontFamily)) { + format.fontFamily = fontFamily; + } + } + + // Bold and italic are their own marks, not `textStyle` attributes. + if (marks.some(mark => mark.type.name === "bold")) { + format.bold = true; + } + if (marks.some(mark => mark.type.name === "italic")) { + format.italic = true; + } + + return Object.keys(format).length > 0 ? format : null; +} + +/** + * Largest first-run font size among a list's *direct* items, as a px string. + * + * Drives the marker gutter: an enlarged marker grows leftward out of the list's + * `padding-left`, so the padding has to grow with it. Returns `null` when no item has a + * px font size, leaving the gutter at its existing value. + * + * Non-px units are skipped rather than converted — they are not comparable without a + * layout context, and every size the toolbar offers is px. + */ +export function computeMaxMarkerSize(listNode: ProseMirrorNode): string | null { + let max = 0; + + listNode.forEach(child => { + const size = parsePxSize(computeMarkerFormat(child)?.fontSize); + if (size > max) { + max = size; + } + }); + + return max > 0 ? `${max}px` : null; +} + +function parsePxSize(value: string | undefined): number { + const match = value ? PX_SIZE.exec(value) : null; + return match ? parseFloat(match[1]) : 0; +} + +/** Roman numerals, descending, for `romanLength`. */ +const ROMAN_NUMERALS: Array<[number, string]> = [ + [1000, "m"], + [900, "cm"], + [500, "d"], + [400, "cd"], + [100, "c"], + [90, "xc"], + [50, "l"], + [40, "xl"], + [10, "x"], + [9, "ix"], + [5, "v"], + [4, "iv"], + [1, "i"] +]; + +/** + * Character count of the longest marker the list will render, excluding the trailing `.`. + * + * The gutter must fit the *longest* marker, not a typical one: markers are laid out to the + * left of the item text, so `998.` needs roughly twice the room `9.` does. Measured in + * Chrome at 98px, a 1.5x-of-font-size gutter fits one and two digits but clips three, which + * is why this is counted rather than assumed. + * + * Returns 1 for bullet lists, whose marker is a single glyph. + */ +export function computeMarkerLength(listNode: ProseMirrorNode): number { + if (listNode.type.name !== "orderedList") { + return 1; + } + + // The last item carries the highest counter, so it is the widest. + const start = typeof listNode.attrs.start === "number" ? listNode.attrs.start : 1; + const highest = Math.max(start + listNode.childCount - 1, 1); + + switch (resolveCounterStyle(listNode.attrs)) { + case "lower-alpha": + return alphaLength(highest); + case "lower-roman": + return romanLength(highest); + default: + return String(highest).length; + } +} + +/** + * The counter style, from the widget's own `listStyleType` attribute or, failing that, the + * HTML `type` attribute that `OrderedList` parses. + */ +function resolveCounterStyle(attrs: Record): string { + if (typeof attrs.listStyleType === "string") { + return attrs.listStyleType; + } + + switch (attrs.type) { + case "a": + case "A": + return "lower-alpha"; + case "i": + case "I": + return "lower-roman"; + default: + return "decimal"; + } +} + +/** `a`..`z`, then `aa`..`zz`: base-26 with no zero digit, so 26 is still one character. */ +function alphaLength(highest: number): number { + let length = 0; + let remaining = highest; + + while (remaining > 0) { + remaining = Math.ceil(remaining / 26) - 1; + length++; + } + + return length; +} + +function romanLength(highest: number): number { + let length = 0; + let remaining = highest; + + for (const [value, numeral] of ROMAN_NUMERALS) { + while (remaining >= value) { + remaining -= value; + length += numeral.length; + } + } + + return length; +} + +/** + * Inline-mode attributes: custom properties consumed by the `li::marker` rule. + * + * Custom properties are used rather than real font properties because real ones would + * cascade into the item's own content, which would need a `li > p` reset — and CSS cannot + * express "inherit from grandparent". Faking the base size would mean baking a + * theme-dependent pixel value into stored content. See design.md, Decision 3. + */ +export function markerFormatToInlineStyle(format: MarkerFormat): string { + const declarations: string[] = []; + + if (format.fontSize) { + declarations.push(`--rt-marker-font-size: ${format.fontSize}`); + } + if (format.color) { + declarations.push(`--rt-marker-color: ${format.color}`); + } + if (format.fontFamily) { + declarations.push(`--rt-marker-font-family: ${format.fontFamily}`); + } + if (format.bold) { + declarations.push("--rt-marker-font-weight: bold"); + } + if (format.italic) { + declarations.push("--rt-marker-font-style: italic"); + } + + return declarations.join("; "); +} + +/** + * Class-mode attributes: one class per property, mirroring `has-font-size` / + * `has-text-color` / `has-font-family`. + * + * Per-property classes rather than one combined class so each `attr()` rule only applies + * where its attribute actually exists — a combined class would leave `attr()` referencing + * a missing attribute whenever the user set only some of the five properties. + */ +export function markerFormatToClassAttrs(format: MarkerFormat): Record { + const classes: string[] = []; + const attrs: Record = {}; + + if (format.fontSize) { + // Bare number, matching what `attr(data-marker-font-size px)` expects. + const match = LEADING_NUMBER.exec(format.fontSize); + if (match) { + classes.push("has-marker-font-size"); + attrs["data-marker-font-size"] = match[1]; + } + } + if (format.color) { + classes.push("has-marker-color"); + attrs["data-marker-color"] = format.color; + } + if (format.fontFamily) { + classes.push("has-marker-font-family"); + attrs["data-marker-font-family"] = format.fontFamily; + } + if (format.bold) { + classes.push("has-marker-bold"); + } + if (format.italic) { + classes.push("has-marker-italic"); + } + + if (classes.length > 0) { + attrs.class = classes.join(" "); + } + + return attrs; +} + +/** Marker attributes for a `listItem`, in whichever shape the widget is configured for. */ +export function markerFormatToAttrs( + format: MarkerFormat | null, + styleDataFormat: "inline" | "class" +): Record { + if (!format) { + return {}; + } + + if (styleDataFormat === "class") { + return markerFormatToClassAttrs(format); + } + + const style = markerFormatToInlineStyle(format); + return style ? { style } : {}; +} + +/** + * Gutter attributes for an `orderedList`/`bulletList`, in the configured shape. + * + * `markerLength` is only emitted above 1, so a bullet list or a short numbered list keeps + * the markup it had before this feature and falls back to the stylesheet's default of 1. + */ +export function maxMarkerSizeToAttrs( + maxSize: string | null, + styleDataFormat: "inline" | "class", + markerLength = 1 +): Record { + if (!maxSize) { + return {}; + } + + if (styleDataFormat === "class") { + const match = LEADING_NUMBER.exec(maxSize); + if (!match) { + return {}; + } + + const classes = ["has-marker-gutter"]; + const attrs: Record = { "data-marker-max-size": match[1] }; + if (markerLength > 1) { + classes.push("has-marker-chars"); + attrs["data-marker-chars"] = String(markerLength); + } + + return { ...attrs, class: classes.join(" ") }; + } + + const declarations = [`--rt-marker-max-size: ${maxSize}`]; + if (markerLength > 1) { + declarations.push(`--rt-marker-chars: ${markerLength}`); + } + + return { style: declarations.join("; ") }; +} From ccb2af4dee6f831b32278a9aae376602569b4700 Mon Sep 17 00:00:00 2001 From: gjulivan Date: Thu, 3 Sep 2026 14:04:56 +0200 Subject: [PATCH 6/6] fix: e2e baseline due to css variables changes --- .../rich-text-web/e2e/RichText.spec.js | 3 +- ...ttomToolbarAdvancedMode-chromium-linux.png | Bin 44048 -> 44025 bytes .../inlineBasicMode-chromium-linux.png | Bin 36972 -> 36948 bytes .../toolbarBasicMode-chromium-linux.png | Bin 37104 -> 37080 bytes .../toolbars/components/Dialog.scss | 45 +++++------------- .../toolbars/components/HelpDialog.tsx | 2 +- .../toolbars/components/ImageDialog.tsx | 4 +- .../toolbars/components/LinkDialog.tsx | 4 +- .../toolbars/components/VideoDialog.tsx | 4 +- 9 files changed, 20 insertions(+), 42 deletions(-) diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js index b39290805d..e39c15fda3 100644 --- a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js +++ b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js @@ -209,12 +209,11 @@ test.describe("RichText", () => { // Blur the editor to trigger the save/normalize path. await page.keyboard.press("Tab"); - await page.waitForTimeout(500); // The editor should now be empty. Tiptap keeps a placeholder paragraph // in the DOM, but the widget normalizes that empty paragraph to an // empty string on save (see normalizeEmpty in EditorWrapper). - expect((await editor.textContent())?.trim() || "").toBe(""); + await expect(editor).toHaveText(""); // No text nodes remain — only an empty placeholder paragraph/break. const strippedText = (await editor.innerHTML()).replace(/<[^>]*>/g, "").trim(); expect(strippedText).toBe(""); diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/bottomToolbarAdvancedMode-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/bottomToolbarAdvancedMode-chromium-linux.png index 46239886499852ecea868539d87df42b13036163..fbf884dfb5bf6834ecd30f6e77a8521ec75627d0 100644 GIT binary patch delta 17702 zcmcJ%1yG#9^C(D^V8Mb12p-%a=q9)Z4esvl_CbO>EUv*JxGnDP?v@aIabNTx$?r+k zJ9&5i`>M8z@$R0Uw&@{q_9<-UX_<%yP);W)_Xf{aWX&yXGR)5%4esHyZk6>DhqImu z42^m${v2wp6qmp)U_XdTA|-pQfus49knJ0#c0v>d^|QcutjR~#jmC(SmM80_eDYBA z?nk4felkbS{`LR58Th|x|Npxg`2PU=e+6v7|69WSS1kUpHt_#y*ZzwZe^?v%-)EWs zy2T&X*7)D#*Z(YT!G?#gF_c`G7hVQf52c8UI& zuC9m$K~62=Rs10w92%Us&?iN(i_R@z!|sxiEB5|7{gP9vBRFD7N=KF?`T65O8o>lM zgV}R6ad$r#KdbRd-T8TX`Db}&pSVb(v?^IOaNHW|=4R(q&BmU;#EX{}$o@0x$EU0Y z5_Yd?;$eQ1(%a^1$BV$cKoc%&ekDgJbW(a)DCN!8K2d@fJRc`Acc<7N#{bUZD=z3i z&W4WOS8Zf^;r&`%9iAAqk35>gf4U-Rvl1$%W)`U=?iBRgc3h{UQv4CR$1&eRGpR|7 z?Az@U4a>p^IY|I9=tHG6Hg(^>7Q z=JeKgpTNcXV_+HmascH^Lg&)J=1u|GuC`TEiA=JHNM=-hlgOIHqqu67CFo)rZ0%|O zqRDCR-$g5$9A{xqdx?){%U$@xpc5|&$}Z12Eyh>-xVr!38A0sCU?@C4f132u#~L_U zZ)0!_6KN%;fp5u-*pkX)-Qml3$qz#E%Ahw;gYg+}_p{1RM)gm?UdEpPejpNp9~R?P z1_XcS`0npdE>(}^5%xIvfi_fZ_0PbJCQ;y)FPMN0GB}~$p)_%yN5YWL`qiHvt;sA! zmEu*+=DSgzh4jyF!$#%e)2XeDu5R+#_(t2-zQL&3ksq;p;KkZsOIzX7EY<9s+1J-9 zO37(9?`zimJ_HtvuRvS`l>%&dmkyl&_PydL>d4j#+n@ChvGviir`$=BasX5?a8ti9 z#9|4%4RNV>vYYzt4_d;amsj*vGoWpiJ*0$=)c>$}%f8+I$&P}bB|x=o^SF2%D*6?r zq{}a_YPKsyOzpcVhF#|Rl@_PmLHP?MA}b|Q#6NV2dH{f{LvW zNtL))?7{**z2ku8%v{U5C5PynKfDen_zT5Pyy5(9euHPcyRCH~B67+!d}6$)+tB%Z z1sO$NekRH-%b#gm(d6P+q{r=+J&JawX9|*2f<91>F7Ywlz9yyk(*YOJ`n}XVdik8o zj=_Fc6UmQAadJ!KVng;3fni;Fa7wI7Ug%>VV50_%TWa&pqGFQjfKXq{V5l~K=yhJ=y4-d!f z_P^Wo6Xg^5iQ`AWUq7L9GRJ+$VMUGx>MHnmz;cXks&wX=rhde(x+Te$wQqR+jg&3x zNxMyM`FpRSo{f6S-=Q0|0lD#F|9A-wd$_Y1e7#C&s>FuGx*;w_gHR~7sILz`&2EQ>0Ds7{3(h9O z_dH^|wlTXkK*Gy&B*Ck!$jS#hrvKIMTQiA7P|1 zzz`=8G*=xunR6tkXBR6ZZx9*>QYIq_uw1+~fg8mNklw$;F-)-2{_9?Sx2-Ag$Affj zjpb5e))sQ#8Ux0!3S(Kq3LXTkEZbiJOOL_pekm4BjOpG%ezRq!{^T#bve9mA^;z`a zUU$w)zAj=KXT#$(G$lO>U9u?sbh_KPC+5@{u#euv^E_T-V7SzRtHrkMxz*Zm{C;$n z;}-hcKPbF3c+>IzsH3pv2h?b|?A=2h7|#|3SzEK-<}Zpag5Xb%K7h`1e{m_z`WdMU z!W{nsb)bRmNUx_|d9l$VT2>c27RJ73G%gjnsK=uc$&bT$YQs)miOd6q!BF;bq{z0{ly6$Z}Eg@A&uh~z3&W!_=U;L^w1}S5r`vl_yIXAu0 zi}b9T5OSi~Rd!%l$jZgwv$)kFOKHSVp641-(wRnOefap=5Ii)MQ@Ce;wpIwBlm4@W z&U5)>CTi&Ji~XEVobDulB2bmb4wpxLeI1!XNdkL?}g*JOXTDc>=&$+oK2wb)UbS zPjXsLQxdTHK1YYXAe)DVWIB6w9ew(-G$Ni*2glW1i!Wgj%k&R}w7$xGIYMbW7PaAv zI)3L6=}vsOAibE*uZ0w7ry{Npl3hT)CO8n8rW>QGUwHG2)F(GEb4~DlQl>BIj}!YO zG?5>dr%|JB9Z`cQ+M2-g0^IVy(;jkQG}vsx=N#BlTDK&N>3+`+`a?Om)7+O~`?ZU5 z*WUmeSMmqhE5{$(nIUvlk3W8OJiWh*HZlF~6Quof@qg?lkmH|$qwruIdj=uz&$xez zKVO(lY<-R;j~-ogPMiM9RGL_q?&-M*y1L}2Vd0;pP^Dq3NQTCr!V?Q7eMfrvXN=46 zdw)6796NBjB@VLa&yj(@%B%4;_~=2T{!iTdPpvhM8FXem3RIN|?56?`x{<5;Wtvs@ zn}4z(A!_zN`n9f6{-iE@ZIC-V_Rm@$5y)!BmR3r--RWnQRPNU$dV**x@N0jC!FZHv zWv1Ejj8CJk{ZH+vK^5fA15)wyKoC5{Whk9Bihw9lA)26O5UPJ-N?aFUG{y8+`+1p8 zQcCr6yZg9PANiH%X7r%ar~lY?*SJ|iAQm*0o9qwt(AC+YI4V}qrF69QiGpFk0tsWg zHpOLkl(n@%tK{PI_z5L=t~ZP`C#1rwzDs^X|0dRb35)%oaM%SJ?2p9q*2ZUl z1+Lu^vu}qt1-N^gYB~GYW9rMIbFsx0YF% zBP}NY$SDJ;HyzUQkPF3x?o&f8W?}iiyFfPkYInN?6w}<3*m!G2>gN+p`L|&-8Q%cx z(TH6Ix)g1-=CozpN4cPq!>1UZnTni_uW>RCNPxUVkW_p}&r_uv6w>iF0LwPj^8C;K z==jP-7=w?8b8W}aFIJb|AAyT(-U0m}mrgO&j%sa+XYgLPwi0~i15`5SrWux%fL0VS#fN6aYyQ!_`BoaKU}5kBbOo#`0< zkIi}a>c(}Bq>D`{mb33w)RYoLuG>xEA9;ot^Ya=$wPL8L7g8D)x*HHMA1*fcX~2}+ z|6t0rNFpWw)N&U%rUm|uz{g(#lQ^-1N6{QBzdNu$0hR^+2XkWc_m4{$aoyW|y?ITr0>I!aVctUox9(I38AttsJ6m>-rGEd{nRjbHU%=7dGWkKo7fU#;3YRo zZt;8C7L9p#G<7eRK6p>5=}wY*i&k6w1si7Cmhitu7!zqIrJ&(W5g}menmlCM-b1zS z#}!o0Jirm%`1>yR(JsN33y)Kmn)g#;c%w^T|GT~NMTTu#rSBV(m|v|O#nY>T@b=hq z-0DZ#yF!UBqMNGywSj@flBf|3<%stdu@sX z)yaRvyweC!naMNNKTZ@Vu5noD_IH$)YLZ2 zdVDw6@kvY%c^iRx4=5Qk(JMK)4$x zO|A>tuLiEC&EtJb#otFqhmTL@XJgk8=IvN}dQvWyY}g3hurH1N4v3SPI^|=y+xLwi z`71=LIUT}){WnZuuz=TEBphQ-^S*43sIf8GSQW!OU*lDF&@mhi1C`Ku(kLV9WQ4*= z8g7b(7YRVd;6++?mT76BrVn%B<0&2mjwZDcl}|1TWDG8`F3NUEgBCFKN4ADkL-d_{ z0+U%)M<&tSQLGGDbiyc1b2{m*eq%J-eqY`>JohLCd?#`Uq{S#bZWi=YtMjx4DX6riq1ICCJG4V}S*# zAaQ}wQyflbTT60Du?b$niIf&GV%o}rYTC>5gb2)*DAB_g6~{5PY^&+$`d;iD`zg_T zt0oy~fo6rK7Emp%L?KduF`b`2X>}Jf+T&QI%5b2r9s8yk^AV}mSHOvpTYWMHTIJXe z1}l++5{{!I~sO{31x%Qa;cNb2#fbH3XnGj)eE%1^peIr>$1{4K*;+rp*gIs@qvM(I z>JpFLf{FeNMfX{{I~T*}0ynEKyjeBx_l3rJ=Q6PDsibUCho_LG1068sa+lKyPpNv8 z24Np34BAt0`xbjmQ29vNE>iQpplz_*OMia$&q+Pwe9$cU^DF;r5AufdqTx><2M*5c z33v=m9{4&YL;$_ID!QqrIPsV$l4z zlt=gX+j)6`F>r9i4^29_J67;{60MmI38(uF2hGDXe}VDmy0HScIe>sxRAOoq6B8yj zHu6JASTBlC6BI?zhxSDmyA-m4;LYiTj7&QJEzI}^a{ZHl-E4AeENjIz?;yFi#Wxyo zMaU;inTz?l>h`)q9PWF{LGyJuT&sHf)MPrZ>sMUUK${l>Sd7ZOj9#Zm)Ufy%xK>7# z_D3_Oy3LUF6>>R05&*;bay&Z(_X>aM0kC}ZwWwupex(0YK1}-O8~K#*S6hFF)BWey zeyzc#WFFs03jT52&Ebz+k+%!tA-b{d=^#G5Bsomj$daV*HchfA4brlukbj!uU*rgO zw2aGH!8?8SC~|b3yPRl#=rsfEz|K9n=_Ll$d1hn9^kEf8aY($Jz5y>(5PP96S0fug zj;+m;7MiIJ5SHC90uJuQy9tuiX4P=bWDQ=~Rv*UhCnFJG5Q%J$yqbNBhi5=fS}Gk~ zX)!!~QXKs3#b@gH2zu9{0#@f?L*--_mn6h>K;j@}pqTT>i8ZIsT;Ck1&}-MPM6`0o z8Td|T16_BA56{$?pzzRRv+TI9LQdkrVajPp1e`?wsU% zq-3e2lATWMbqN98n5N@R=O^UUr)PRUx-i_0Bp_Q9_X2C{oCP^_`#K#B6-E069Ir{Dz`ZHjo*z%X>9JCK=@wauERIc$?Ama4v9jWUTI##bFW)j z8)TDWd03Wf*w}^KbkUdG#)#d@5vgy46uJMrP*JB@`WJ$z>%Ox zM^Md^o5J|l;yzQ*_KiJLJGpq<{OZM2a=x!E-4h%!vb?t+>Gc6($dfvM(0>m&kBhe<(*NW74Zvv6KCp7EWA-wbE z=WSP;-DQgoJAi!5**l@3{;hmk+R_HQ=3!ok$`drdDNiT@#638M_CEIbm^gSn(psYi zcgCKU^zz*e%Ez(c|2R(Uf>yb8?`wXV8A^(dpjn?3s`lV%41n305MNmI@wvg4Q^b^2nhh z1Cf+u-6a3|!d)rk_{9F1;IdFcXO|d(^M?8^>^wU;qw2lc`mi+h2GvXwZIhQ^m3(xj zw3d2^sV2JXqO9r)&1f|n(Fh~Kb!|pTbLG$!zhh_NS~B zfPn6n=Y=)yjylRzh8kGzoJ_EeRd-#r*9`Xzi*%_6D5WcQpV5ozQ4G_f6+$>O8l1YD zQ3q&g=qNzpsa98HBnQzMQ_s)S*O4>ibr{WdQyJzXUq&5s z;!5BNwHbx)?eu#t4-&Uz{NajcVxJIkf8!6~EE^m#rPpXNv3IL%XX=r;kFGO123kuo zq@Yua$w@@^Z<~bLem7EKcV7-n&+Rby_C+y^WKowM)F0dvzWg`!@WQ z_zzA57@cYHEp%Cm3RN}3v+5>XOVm_eN5#kVvU$tZV>%;?je?wRz~%&JjL5C(W;q4% zA{h9!Y>>sHy2swnIh?Wqi9Up>h_PA!E@}V53|~SQ>a64x;YwrKI3}PD+%6b|;mmAo zM;suYRf(G}pKe!&*Z4&7)^(BE+;j(>=G zzil_n^{prU<-#%;xZ#(YLIqHHAlVK`mJ=f99I0K18Y0u$&>ZQ0*1FHK2sXXRLMsu+X&2fhJ>D>8S8J>$Qt9m(2+=7$<4c5 zl*bjMl|@LQU$;090JK)lp;M#Gqp2TJHEFh8$Gd3|a4Hnuu)DysPFzJ|o0;Z!g?-4# z4V>HzIdF*7^9d>q!E8n9kLQkbiJZ403`PmF4p z`Z5cmK91W@<@JgtI!Z0rl+*%N+*Hrf9Jo8>HjdiFeWl^*Y+f#cX*e`ZNUmu=1Iyu>D5v4jN$KF}4T%~Hq z-x(eRucYSV0%9v1<2#@O4XB0XiBH`?%e|9z_zj#XZ}qg-7}WxBkqkKBnMB_nuMmSy z<__X`cmTEz%L(GrzO}jRYE}=KNhj-y>Bf8ggB!rEzM|7&?`UQ4kWc9-*F9Pn+I%+= zyq<33Hwxplzn0oM9&&GK<|$L(`yHnIws*1evTGA4NK7`)V(lrpy&q!V;x315-dHu? zT7y(uJYX#ibvA|-_fTwq)4r=EkIg^D^FZRuBLxMEOE%=?8vOcZM ztvLew-XXin*QT07@6lbadHVFpH_j?}AFiJIhvp9!w6E&xMrPnR=VQ;iE5x5RR9~M= zH(Ma9Ei5Xk$S(9_tfw)#-^e_iU1%c+QabX!*wlV8FN*UQi8A`VyltdU7W5|{L92wi zp^*Dc*>VpL@sm|lr|EuwUwFn3z$m)|cm*h0CWslYsz^9C`o`y@IjQY09>TJ2v#v^o ziD%zMQAiyNzs%@{v_3}#IoCUegbYzroii|RaB?cdSDOlVnyxzAVag>{?Pypfr`A+% zNteuogzb}a!BVXgepH1O3fR3sx()uF6%qt-{1};s5pl@uJe*??Z)40M5;7F7G6+-{ zW8Cm81iqHY;z8;8Y0^SKf_G+J&)hZ2UJstI2`w>;p!{aOmDJq?6LG%PNn*J1f+BFA z9%?r^^zA7W#*13daPV;hbX^|GV`j05*oFcRV))0(E~s2**|;z>E|`<8nER;b#k1Pf zA*GaAtXs~B%L(n^p)0fJ^XF5TwE zUxJ7;j4M&nb;P$55Z7lC*|IMcsM{M4-|;KlY9Hn0W8H6OIg;Ma$wH1efH}{r0oVD8 z!k`w-@{E${CDl>5X zxkXXs#4+aJ>`b8_OW?eIh7t>PQv53j$#}&jyBr14wfwhJ@;c@L(!!MA;lkX}H@%Jx z>YCta?MiVHWeR?$rb3B90F+x!x9hLDIHrQc@*#l-wi=)mK22I(Th8Cu1WVh$5=9X5 zjqSap3uV#1j=GI%yxwou^htpUxu;q_x^BDh{iz zVE&zsG+)i|W1HFedZ<$%-kLdo?=2zD1~10zhueU8^YF%nZy7t*fT$Y4U*Iww&SVVv zq1;9zU(HKUFT7S#uf3A%Gmdc1<2x znDYoqU8mYqx_tY#?w{83B3$(Tx@^Rsv9RG~*!`KsP)B^rZ}nJk`J^IkPr*}x%5LJ% zlg;{PQ0hhZ{?(44HF=(Ey=b9@{>`i-s7#RgCL_B^bJ7Ic9AG@W#tD4-p2zUOjsrJN zJug>wo~>!6dWWFh{bW6JWSFi9#&nQdDI{ImZwI`1u8r87L;PSoEpT%e8O|{r5MUF z@e=i@904esW+yaJ=Xu}#dY%Cx%1MpRjPY`bM$*SFMo||s5RNK5Zn`k&gdHZncWpp# z9!nk2Id$mSqfkDBSI|QOgPwT=uTH+| z(t<~k?1wm0&)&!*4_cc~7ik}x^y0Tp=E!rmf!Dyn^1Zh9lmmxojz+!zL<}W;I+3)( z;&qAm1rNN>dXK#ybXcqLH~)c}?u6ontDALR)N?+^qYMywyP;ffO6N>iRN$%6#<{|z z_1#w2i_8&rL`=jUXHAtCzw6AcXtmL?aciFsN+N%tyV=_x_CB}7?Zk+zg2-VHEAiz? z@8JPf*xT!2EP6NdJdIU3TWXG&=W|m#-0{GB-=#gm4gSq;h zeLe;jT%_?@w?T7y4Z`Y}kILt;I`et=N8PyDnTmWz$Xrd1xC+_(uwuZBcyD+2zFt0K zO`#Vk@)Fk!Lax_+Ln&Ue9y97x7~5RTqLdG09d#79NJ`%swjZhzWJFOrJ4-JPzg8ks z6QML+ABKFpx~b{)h_Y3`@u3q0$bx&#CMI&a3HJPdzHai*f$r38%f;>uHvURQQAjGv z$ZXCik;?Sf>|ryoRDGMGF4@g^I{LQgb(^VvdN!u-!)4fNQnp0sWC5#)+i$Jq2|_-A z;vKU+iC|NAwd&8t-?J253~yiNxA?fW}?rO7neLtwj3 zjp3m_YM9qAI4ze|w?}(TyM8MU<(x6nTrxej zq%mG&Q{hu$suYd|0ZR2|EB*k>o2##vfKu4yiiKbU{7||okv+q-cS{ef3~CC@3C4wT z{uES!oEPfXbxtcK)!Y|$1;l;!N{$-WiSf?pS*|2%$}7Z)Ob~hbWB`;ve%fF|RZr_P zk$_dpQkWl9jbHz~E_+9#;N83730bu_jfJxYJsGogisDMx+asLTxVeBS|y^V|gPT0^^Ah~BwG*6-D< z1zZ6H`me7=>QJP4C}Db%qAH*mzykK`ix&KX@Kq9N&pRIW2K5dJ8ffARcPqkeGaWt^ z!mlS$<`WcL?OUc!S<8-S&#LsuHAy_jfjJbgj`lwNvRG(o%uO|TY@-O$zd z-#UxqnGW$(Z^PCJ>P6Fdb(fb;OQ5WR1je*`Z;!8#c@fqSZGe zyhu?jK0?jm@`Sjm|GM{(_PRYM&Ec=Nd3ROv#2&uj%X?bDT<1CE*ETJ#4A+l0f#=Hkv#YwhLHTR8KPkpaZg-PyzOhjgrPE9xjeB6}viB&`(d;{wI> zw%x`!4yGE?Qc*R>{mjAPj{K}b(MTL*;U27!TPbiW_4Z_>wow{;Jv6OgNBY1-TF_Ec zB)MU(fOZ_%uqx;!xE9W-^JA0r6T~DtTi7b;F|TfhqKOWj_itrwf|5rEj0KAn7p??n z*sNx68tIB#ex7)u*4(dD-$Sl-f2Rh-72exlSV%MUZkucL{}g1s8h!0vGhW4be;90h z;iuH1s*8{I3+f@JkZ-tN*>u~$o6H5uyVe%Z1&$lu zU#yg@xb~mVB1$@1n736)21028#xI#}D|0a!BRKJH0aO}#_hK7n`*BC3XBjjV=Z*#) z`V+w3>dl_-(59Yqx8gh%MF2|wLF$BnJ?Fx(8HwT67RUr<3@O&oAa`@UH;!gO@M0Ht z&B7H4@_KLmWo9UaS%uHGw?U+jzMpZBi^|Iar+M>Z2&2>NuaQ< zcwQ`Z24VI^MmqnBl!W>2FCodeD6ru2^0EPC9MdR*c-gwBdEx4tP4F#S z6yg&^8pMvyz(urI;Ftb}32m;t2mzJyvKk$oy{O=XxRCUK`@4lYQ`16!d&C#I z-@7`vGuvB{bvD-DJ`oWOFTm&;_<)H!3v4?tJ&oV<>fu@f6W1b3Bn+R=%^@9Cm4l59 z7at$PWnp>MzqRP`@N&>DJZ`k9F`%JUywCx?acR5UC(TzBW0)HDF}TcVF?*qDWzJj`fp*3CkN5ap5mvq3bL1PZf~t(Ln^Nf~H@-dEnAB zRa8vkC#epYRjdj)VNHa473vA>(uE3T7egw@L453^6mzCMX)5U?YK9Nc;!dMcwh@q4 zFkwuur<5Q=?B8YwH&^X!`8x(|g&AXbnAqCs7)S!GQH%nX+SaSlt4jpVX;y@-3Dgwr zl09*_ktF01QSG#QU>TA6AcJQ3=@%tI-(_}@m~wk%zxOm)El+&$e()H}45k^~u6d0f$CG06?`nH;x$_8UT~Q!|~P5AQI@=aN#HDZ4#8QIS_&5i{Ctih<}JDyLZX1|NnP zDiZ=+4L<6&ruIP}XtjTsa?Y@_jTwJ2mFV@KB3ToPjv#tW3VmRw3~PrOwc6$7<7Z3z z@n;?@feOm-@+h8y^Nd!iYmFy6jP;%<%2ekScs-n)tJ;`Ns}k9_$Q~$F<(s7Xs$NQS zg&6G5CcFL6n2XcIZTbD#&|QUc{UbZs)q#nf+Fe2QJO7BwE+;v|L_8K=hiIz|+Y(lNR2+r!b&b?{3+yZh!ROp8Rk3r5YO2`tta zaa7<#KEeOiB+zqCC(3*Z|A4XXZWwQ}kn}0&{0yW#Jt_6+rj&>-Uti%7JeodrOMtxkw8HA zCT9=d^LX-82<-NGF$$V+cv}YKEQtCW*1|)&q4&H?hvtV@$oy*cmfz@4EOiNivCCvD}e|P@r?j9WnJo#J;%cfNCLrgwuY({Y$?@Ge4x?`Hq}Sl67;X z2;}wcKJ9eJsRq`pekbF7U}x-(bG`0vZ{;lduK2#v8FnLHA7^A`4!W-QL&mXKngP_x zZ@@+S>jqZo2%62V2Q$u7+Z&K7I@@*m7ERT4`F@+UhytL9d`~Ue!)0n)vv5&4H$NpC zV+ctytUOMmkwkaa-~0s%b`+I9FUoq*D-?Zm9(8F)%lBt=B8X5dGRKVf!9@LVd&S?Y zhj3gI2oUq=g8K#~PiuA-?Z%ygAi($$lkv0Q+&UaQ1F3;23B65AdPen+V%CAmEMW{S{+IJyyWEvoQXyb1X~GlBqWK z?b)H4ovyowu{>0FDM!BIA;Fes6;o?ZHQ?ARps(0%Py66bp$OEc-gE;7t;9Xtg_PyciU+9URlGSg}b+|vN%;l2Ff3G z>;E2T?m*43-<{JhguiVgH?>E}GBo^t2r#J!eCQkC%Uon;b1PBWM4NBbATN#&tvV%q z*p9|_0#vYCX)bY-giN|Q9eH~M1WOZUx0%u>kWo?`5(_!CGT)@vXj^lY+)T2WB2&8n z-8pRc6XU3xAqgqG1&U?Vl8IQx4M*u zuzi}O`cgm|JZB$Pa9%$wu7F9mXl!CnHfnUzI4EZCN=BR@H_z}SxoJ?Ss#RylKy7}U z+aj8M5wZK;G@diIrp+1F0^9G+U5vQfM&(z4Z)g^S;>*7HM4S}LsutVbrJwrDubwjd zor&A#$2hqRsf3NHGRO28RbpPC+*#Zhlie*CT*I!A^kuSFCYT=TTVFs z%Vx5?8pl4H=$a*SF%78>o#MghxEXJZVhXxt@SI91VR(DD2M$q~p|DCH;Iz`+-`LfH zS`4A4Hjb-R<-mIw`g!(#$v0`c9G3H&Pg=T_jL7k; zUm^h)ttwWPo`~MOHMRnVn@?&R;sk@9|K@>$-du=W$u7S*Qn4b{j3qUjtv1fgW8m); zmAKf+=L!-;C$sgPZfIUjuTjlka@zlB_AM-rHNF^Q{-A!6m8q_+U12-C(X?2V==|k4 zZ4k9EpIl@{Rs<`xY~gCoo4D2yK*iY5;sBJg(AqZ0J+iB2sQAG9B9N#hVS%5m&ozA5R1BZizwnX zG#B&VIL`=)>Rs4v_c_?MGL(vnaR*nM3QBn?JC#2;A>9XD8mG8~&LvUEEE#dsOG?X^ z8gZcmjtWiABDp}hga97iYvHTc5A5z=&bjC(*aiC0%w1!=P1ih>u)8xR16fJIx>xqP zTpFan9>H&MsO~3|#({{YkQUx64tXAZ*2)}s-drLe{gcKbbT`8TS!sU$ZdKvgc*~x4 z`t1G*6-=s>_%MwHa*e4puUT$nh>G{?xeqt*Mw_Ta9xHI~b_KfE-$7%vGIwmAq~_>X zATcp5=3m))(;(vE(OV<`JvIsL7SE{02MMIDZJ9&^6D-6*#V`SC*4CB(|EByi| zQ$gBAq(FfWK{N}h==0oA;(6TdJ-yl+!j)Ry2O9v1hrg_a)_8UGpP~yEpdXsTDyt;THeggy?ms&%pnHZ}L zsCj-dBl5iIY}19Eq*nPXv?);OeJc_cfcCEGFThsZ#w!qm2R~rwaSC0BjxI{K+|IV8 z7&px#3~`e0tIj5&#Rv(A+B}5js>%5|R>*f$Dq37;BF@J*3L{biCx^8lY#SQCW!Da# zJAL}(pVJq#3fU*I+~n}s#enUq*1fg`>^us_{NF)I?g@1tjb@|e)sX?SVf9~k+Ugyt z_Z)KMDOB4U@EqD8F(A=(8t1FReuDx!wuaf2!7H{L#gwrn#t^)#`1MaM} zI*CC}vofZtu)##b!!vt@T;xS%7bCBVwG|{3-=wRPCXNQhgC$td-V+c*sUg5~)^JLNA@S6Xv}Ubz@e0?aFRT%}-$qMir%?-Bb!3Za!KZEPu+fQAiuI1! zLa-~Abllh|AT|cVDio3LZApuR6U42+o!yjQd2qIHrkM`W`UM@0X6gG5N*98@=-i94 zSlp?Jk`6nmS^dVW>S|@i4+WAO%&c1p0;5zCvyUavmtZFqUoh|nl$PIPR6E?weaIep zJ@&@bvTlCTuA0f2zz{n%9?wktXuCS}D}F{|2HnLE2Y*I9kE(KG3zxRl(N}V-{C9ca zz}9|{2_N$TvM-N>f`9-La)-=i_%0mQ1BI_x9+Kf)KERY5mj>~$p8;A9GHo6OS;C{NesC_j*wbm?wAgDc74d4`rt+K-Hkw$af@WbKN~(Hx(e+vCqK3zqeh zv)|8pp2bTu684Z@LMwIqDS(6x`v#}(cu12OuY?JKhfTo300HTb3X;nb>gg_JMC9ro z7K?|&!!yRoGDcL$1Q2IN(27;7lCgV^ccYL(NdlpA~!-kkp_Oddkhw>oqenf)fQ{P_j>mLL@6Qh-(KGp= zobGo}P9gDj)rON6(uanJkMRuKz5dUWA2{4_ecC$A=B>n;~9~*HU1aT?Dh>Fwb zuO<-@@gA#mMj!m~(_x;2P(0R`+ug9D9D^UvQd~*O*1c`EMi`P}+^i_HwO+P!Zl|MQR<&#Sou9GSSK_~XQUc+Tef%T~9u0}#w$fyTJ4 zxI&E@{c{>|t=X@&7l9Qg2m;P<=vX3$J4>DR zwf6vR7t<6Kw6&0ABc6A0W<0lp1j~ySi6A;rly@sH62B7qu*5g&$(_zP z&ddTN9qOrV$$6d+uK-DruF5@23vTV#Pdy3kYHEwN$H1az@hvn>_giBVc?Pfkg`d2= z5YBRKqr{7aZof*uc?aerJn>SFN7i2ktWUwjUO&m= zFIE5<{;dGww8#r?5Haf&pU+JO@h_mLHB2(MFBlcTCvrWUoXU;o)jn;Gx-d=$uCDKy zTq8YCC#4HU4UWd^vO@S#QCHmWnui?-ePj=b@SeK-XE!gJ$4(JI8qI2ym!FI|;`d#( z4Kh!%56$qRK9^15L^4M_|2if^nvT83 z%hKO#o}a%BI^BSE6clVy-FR(>@J;j80hPNxI#&+27miXXIE;>-lNjH>|DXcy)H+n>_!I%vYN?iyX>3z4@#0MS`Azyk^5GKaplRx_!=}k(U4P(nieT~51jAE=jpyfZgmlVNFa5hD1aLa zLcni-jw$@kzt#KWcSCFjM7(m25)-To9OiKy&aFEiq>Bf{3gE_W>Wv{4$I>iM9WDP7 zHlJB5%!A5LNT{@#csW9g^3@)qHj>jqKUfnx|IoXY(Koj2y2(8XVn{3QTb1hNxWKbt zg(R%9FCel>Of@$l#XkKt#NJP0GO?ebKDB^6D^IEL z_7=L(5Kumb{zsMd7m_j|8M7DtKKb3 zumPQtH5>9{r!9;#6MshiXKG|_iEJkQ@yexIc@IhCaBvPj@3geER$fXgm`fZiWqTiR zuEf#+9vFX8*zYREvKeQ|UWAeRF0Y7aKXkvp@BG#M7%6`FX!bsXW6$VnTyhTlqhiE` MKMR!z=zRS@0DsMTCIA2c delta 17800 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 diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png index 92fbf89ccdbd064f1e35075f1ecb8406949ffc4d..3135045cfd7fd31cebe78f8e515261158a29cde7 100644 GIT binary patch delta 32212 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 delta 32315 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
      -
      diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx index 904ef79d3e..7d37907999 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ImageDialog.tsx @@ -392,10 +392,10 @@ export function ImageDialog({ onClose, referenceElement }: ImageDialogProps): Re {/* Action Buttons */}
      - -
      diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx index cfb9245afa..36e38c1449 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/LinkDialog.tsx @@ -166,10 +166,10 @@ export function LinkDialog({ onClose, referenceElement }: LinkDialogProps): Reac
  • - -
    diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx index 829407171c..1ef24d8263 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/VideoDialog.tsx @@ -283,10 +283,10 @@ export function VideoDialog({ onClose, referenceElement }: VideoDialogProps): Re {/* Actions */}
    - -