Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/pluggableWidgets/rich-text-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- We fixed a security vulnerability (CVE-2026-13149).

### Added

- We added new configuration to allow users to use class names instead of inline styling in generated HTML to support strict CSP.

- We added a help button to the toolbar that opens a dialog listing the editor's keyboard shortcuts. The button is shown when the full toolbar is enabled and can be turned off with the new "Keyboard shortcuts" setting.

- We added translations for the toolbar, dialogs, and help texts. The editor UI now follows the page language automatically, with bundled translations for English, Dutch, German, French, and Spanish (falling back to English).

- We added a clear button to every color picker (font color, font background, and the table and cell background and border color pickers) so a color can be reset to none.

### Fixed

- We fixed the image dialog to only show the image source tabs that are available: the Media Library tab now appears only when an image data source is configured, and the Upload tab is hidden when default upload is disabled.

- We fixed Tiptap duplicate extension warnings for `link`, `textStyle`, `underline`, and `textDirection` by properly configuring StarterKit, removing redundant extension registrations in TextColorClass, memoizing the extensions array to prevent re-registration on component re-renders, and disabling the core TextDirection extension that conflicts with our custom implementation.

- We fixed an issue where empty editor content was saving as `<p></p>` instead of an empty string, which incorrectly passed required field validation. Empty content now correctly saves as `""`, ensuring proper validation behavior. Note: This is a breaking change for forms that were relying on the incorrect behavior - required RichText fields will now correctly reject empty content.

- We fixed an issue where the editor pasting back the whole sentence instead of the single copied word

- We fixed an issue where table header cells did not honor background color and border (color, style, width) configuration. Header cells can now be styled the same way as regular cells.

### Changed

- We aligned the image dialog's "Upload" tab dropzone with the File Uploader widget's dropzone so both share the same look, states, and messages.

- We removed codemirror from code dialog viewer due to unsupported strict CSP policy. A simple internally built code editor using highlightjs is now replacing it.

## [4.12.0] - 2026-04-22

### Added
Expand Down
107 changes: 96 additions & 11 deletions packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { expect, test } from "@mendix/run-e2e/fixtures";
import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers";

test.describe("RichText", () => {
test.describe.configure({ mode: "serial" });
test("compares with a screenshot baseline and checks if inline basic mode are rendered as expected", async ({
page
}) => {
Expand Down Expand Up @@ -32,8 +33,8 @@
await expect(page.locator(".mx-name-richText1")).toBeVisible();
await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`bottomToolbarAdvancedMode.png`);

await page.click(".mx-name-richText1 .ql-toolbar button.ql-image");
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
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`
);
});
Expand All @@ -47,8 +48,8 @@
await expect(page.locator(".mx-name-richText4")).toBeVisible();
await expect(page.locator(".mx-name-richText4")).toHaveScreenshot(`toolbarAdvancedMode.png`);

await page.click(".mx-name-richText1 .ql-toolbar button.ql-view-code");
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
await page.click('.mx-name-richText4 .tiptap-toolbar button[title="View/Edit Code"]');
await expect(page.locator(".mx-name-richText4 .highlighted-code-editor").first()).toHaveScreenshot(
`viewCodeDialog.png`
);
});
Expand Down Expand Up @@ -115,23 +116,107 @@
await expect(page.locator(".mx-name-richText6")).toHaveScreenshot(`readOnlyModeReadPanel.png`);
});

test("compares with a screenshot baseline and checks if class mode editor is rendered as expected", async ({
page
}) => {
await page.goto("/p/classmode");
await waitForMendixApp(page);
await expect(page.locator(".mx-name-richText1")).toBeVisible();
await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`classModeEditor.png`, { threshold: 0.4 });
});

test("checks that class mode editor output uses CSS classes instead of inline styles", async ({ page }) => {
await page.goto("/p/classmode");
await waitForMendixApp(page);

const editor = page.locator(".mx-name-richText1 .tiptap");
await expect(editor).toBeVisible();

// Apply text color, highlight and indent to the first block so the
// class-based output can be asserted. Re-select before each command
// because clicking a toolbar control collapses the DOM selection.
const firstBlock = editor.locator("h1, h2, h3, p").first();

await firstBlock.click({ clickCount: 3 });
await page.click('.mx-name-richText1 button[title="Text Color"]');
await page.locator(".color-picker-dropdown div[title]").nth(10).click();

await firstBlock.click({ clickCount: 3 });
await page.click('.mx-name-richText1 button[title="Background Color"]');
await page.locator(".color-picker-dropdown div[title]").nth(10).click();

await firstBlock.click({ clickCount: 3 });
await page.click('.mx-name-richText1 button[title="Increase Indent"]');

const html = await editor.innerHTML();

// Class mode emits class + data-* attributes, not inline styles.
expect(html).toMatch(/class="[^"]*has-text-color/);
expect(html).toMatch(/data-text-color="/);
expect(html).toMatch(/class="[^"]*has-text-highlight/);
expect(html).toMatch(/data-text-highlight="/);
expect(html).toMatch(/class="[^"]*indent-\d/);
expect(html).toMatch(/data-indent="/);
expect(html).not.toMatch(/style="[^"]*color:/);
expect(html).not.toMatch(/style="[^"]*background-color:/);
expect(html).not.toMatch(/style="[^"]*padding-left:/);
});

test("compares with a screenshot baseline of the View/Edit Code dialog in class mode", async ({ page }) => {
await page.goto("/p/classmode");
await waitForMendixApp(page);
await page.click('.mx-name-richText1 .tiptap-toolbar button[title="View/Edit Code"]');
await expect(page.locator(".mx-name-richText1 .highlighted-code-editor").first()).toHaveScreenshot(
`classModeViewCodeDialog.png`
);
});

test("compares with a screenshot for rich text inside modal popup layout", async ({ page }) => {
await page.goto("/");
await waitForMendixApp(page);

await page.click(".mx-navbar-item [title='Demo']");

await page.click(".mx-name-customWidget1 .ql-toolbar button.ql-video");
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
await page.click('.mx-name-customWidget1 .tiptap-toolbar button[title="Insert YouTube Video"]');
await expect(page.locator(".toolbar-dialog.video-dialog").first()).toHaveScreenshot(
`richTextDialogInsidePopup.png`
);

await page.click(".widget-rich-text .widget-rich-text-modal-body #rich-text-video-src-input");
await page
.locator(".widget-rich-text .widget-rich-text-modal-body #rich-text-video-src-input")
.fill("https://www.mendix.com");
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
await page.locator(".toolbar-dialog.video-dialog #video-url").fill("https://www.mendix.com");
await expect(page.locator(".toolbar-dialog.video-dialog").first()).toHaveScreenshot(
`richTextDialogInsidePopupEdit.png`
);
});

test("clearing all content leaves the editor empty, not a stray <p></p>", async ({ page }) => {
await page.goto("/");
await waitForMendixApp(page);
await page.click("text=Generate Data");
await page.goto("/p/basic");
await waitForMendixApp(page);

// Find the first editable rich text editor
const editor = page.locator(".mx-name-richText1 .tiptap");
await editor.scrollIntoViewIfNeeded();
await expect(editor).toBeVisible();

// Click into the editor and clear all content. selectText() reliably
// selects the editor contents across platforms, unlike Control+A which
// depends on OS focus behaviour.
await editor.click();
await editor.selectText();
await page.keyboard.press("Backspace");

// 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("");
// No text nodes remain — only an empty placeholder paragraph/break.
const strippedText = (await editor.innerHTML()).replace(/<[^>]*>/g, "").trim();
expect(strippedText).toBe("");
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-05
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
## Context

The rich text editor uses TipTap, which already supports column widths via the `colwidth` attribute on table cells. TipTap's base `TableCell` extension includes this attribute, and the `TableBackgroundColor` extension reads it to generate `<colgroup>` elements for column sizing.

Currently:

- `colwidth` is an array of pixel values (e.g., `[150]` for single column, `[100, 150, 200]` for colspan)
- TipTap has a `resizable: true` option that should enable drag-to-resize, but the custom `TableBackgroundColorNodeView` doesn't implement resize handles
- Users can click column borders, which sets a width, but cannot drag to adjust
- No UI control exists to set exact pixel widths

The existing cell configuration dropdown already has sections for background color, border color/style/width using a `ConfigurationSection` pattern.

## Goals / Non-Goals

**Goals:**

- Add a number input control to the cell configuration dropdown for setting column width
- Support exact pixel values (25-1000px range)
- Provide clear button to reset to auto width
- Handle colspan cells by setting only the first column width
- Follow existing configuration dropdown patterns and styling
- Validate input and clamp to acceptable ranges

**Non-Goals:**

- Implementing drag-to-resize handles (would require significant NodeView work)
- Supporting percentage or other CSS units (only pixels for now)
- Setting widths for all columns in a colspan cell (only first column)
- Syncing widths across all rows (only first row widths apply per TipTap's design)
- Adding column width presets dropdown (keep it simple with number input only)

## Decisions

### Decision 1: Use number input type (not dropdown with presets)

**Rationale:** User requested "exact pixel values" in requirements. A number input provides precision without limiting users to preset sizes.

**Alternatives considered:**

- Dropdown with presets (Small/Medium/Large): Too restrictive, doesn't meet "exact values" requirement
- Hybrid (input + preset buttons): Over-engineered for initial implementation
- Text input: Number input provides better UX with built-in increment/decrement and validation

**Implementation:** Add new `"numberInput"` type to `ConfigurationSection` interface alongside existing `"colorPicker"` and `"dropdown"` types.

---

### Decision 2: Set only first column width for colspan cells

**Rationale:**

- User explicitly requested "set only first column" in requirements
- Simplifies implementation and UX (no need for multi-column width editor)
- Matches TipTap's array-based `colwidth` structure where `colwidth[0]` is the first column

**Alternatives considered:**

- Set all spanned columns to same width: Could surprise users if they had different widths set
- Show per-column width array editor: Complex UI for edge case (most cells don't have colspan)
- Disable control for colspan cells: Too restrictive

**Implementation:** `onChange` handler creates `colwidth` array with single value: `[width]`

---

### Decision 3: Use setCellAttribute command (not custom command)

**Rationale:**

- `setCellAttribute("colwidth", value)` should work because `colwidth` is in TipTap's base TableCell
- Other cell properties (backgroundColor, borderColor, etc.) use `setCellAttribute` successfully
- No need to define custom commands in extensions

**Alternatives considered:**

- Custom `setCellWidth` command in `TableCellBackgroundColor`: Unnecessary abstraction
- Modify `TableCellBackgroundColor.addAttributes()` to re-declare colwidth: Already inherits via `...this.parent?.()`

**Implementation:** Direct call to `editor.chain().focus().setCellAttribute("colwidth", [width]).run()`

---

### Decision 4: Render clear button conditionally (when value exists)

**Rationale:**

- Provides explicit way to reset to auto width (null)
- Visual indicator that a custom width is set
- Follows common UI pattern (seen in search inputs, etc.)

**Alternatives considered:**

- Always show clear button: Clutters UI when empty
- Use empty string to mean auto: Less explicit, could be confusing
- Add separate "Auto" button: Redundant with clear functionality

**Implementation:** Render `×` button only when `currentValue !== null`

---

### Decision 5: Store null for auto width (not empty array or zero)

**Rationale:**

- TipTap treats `colwidth: null` as auto-sizing behavior
- Consistent with how TipTap's base extensions work
- `colwidth: []` or `colwidth: [0]` could cause layout issues

**Implementation:** When user clears input, call `setCellAttribute("colwidth", null)`

## Risks / Trade-offs

### Risk: Parent attribute inheritance might not work

If `TableCellBackgroundColor` doesn't properly inherit `colwidth` from base `TableCell`, `setCellAttribute` calls will fail silently.

**Mitigation:** The extension already uses `...this.parent?.()` in `addAttributes()`, which should preserve `colwidth`. If issues arise, explicitly re-declare `colwidth` in the extension's attributes.

---

### Risk: First-row-only behavior might confuse users

TipTap only uses `colwidth` from the first row of the table to generate `<colgroup>`. Setting widths on other rows has no effect.

**Mitigation:** Could add tooltip or helper text: "Note: Only first row column widths apply." For initial implementation, leave as-is since it matches TipTap's inherent behavior.

---

### Risk: Custom NodeView might interfere with colwidth updates

The `TableBackgroundColorNodeView` manually generates `<colgroup>` in `updateColgroup()`. If it's not reactive to `colwidth` changes, widths might not update visually.

**Mitigation:** The NodeView's `update()` method already calls `updateColgroup()` on node changes, so it should react to `colwidth` updates. Test thoroughly during implementation.

---

### Trade-off: No drag-to-resize (only manual input)

Users lose the convenience of dragging column borders to resize.

**Mitigation:** The number input provides precision that drag handles lack. If drag-to-resize is needed later, it would require integrating ProseMirror's `columnResizing` plugin into the custom NodeView (~200 lines of code). For now, manual input meets the stated requirements.

---

### Trade-off: Pixel-only units (no percentages)

Users cannot set responsive column widths using percentages.

**Mitigation:** Percentage support would require custom rendering logic (TipTap's `colwidth` is pixel-only). Tables in rich text editors typically use fixed layouts. Can be added later if users request it.

## Migration Plan

No migration needed. This is a purely additive feature:

- Existing tables without `colwidth` continue to work (auto width)
- Existing tables with `colwidth` set (via TipTap's base functionality) display correctly
- No data model changes or breaking API changes

Deployment:

1. Merge code changes
2. Run build
3. Deploy widget to Mendix project
4. Feature is immediately available in cell configuration dropdown

Rollback:

- If issues arise, remove the new configuration section from `createCellConfigurationSections()`
- No data corruption risk (colwidth attribute is standard TipTap)

## Open Questions

1. **Should we show the actual rendered column width vs. the set value?**
- The set `colwidth` might differ from actual rendered width due to table layout constraints
- For initial implementation: Show the set value only (simpler)
- Can add "measured width" tooltip later if needed

2. **Should we prevent setting widths on non-first-row cells?**
- Pro: Avoids confusion about why widths don't apply
- Con: Restricts user control, might not match mental model
- Decision: Allow setting on any cell (simpler), rely on TipTap's first-row behavior

3. **Should we add keyboard shortcuts for common widths?**
- Out of scope for initial implementation
- Can be added later if users request it
Loading
Loading