Skip to content

Commit de7b471

Browse files
gjulivangjulivan
authored andcommitted
chore: update link dialog
1 parent 7eaaefc commit de7b471

13 files changed

Lines changed: 495 additions & 12 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-07-13
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
## Context
2+
3+
The rich text widget (`@mendix/rich-text-web`) runs tiptap v3. Links come from StarterKit's link mark, configured with `openOnClick: false` so clicking a link places the caret instead of navigating. Link insertion today goes through the toolbar's `DialogToolbarButton``LinkDialog`, which reads `getAttributes("link")` and already renders an "Edit Link" variant. There is no affordance to edit or remove an _existing_ link without reselecting it from the toolbar.
4+
5+
`@tiptap/react/menus` exposes `BubbleMenu` (backed by `@tiptap/extension-bubble-menu@3.27.1`, already resolved transitively). `BubbleMenu` manages its own floating position via a ProseMirror plugin and accepts `editor`, `shouldShow`, `pluginKey`, `updateDelay`, plus standard div attributes. `shouldShow` receives `{ editor, element, view, state, from, to }`.
6+
7+
Reference: tiptap menus docs — https://tiptap.dev/docs/examples/advanced/menus
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
13+
- Contextual Edit/Remove menu that appears on any link in an editable editor.
14+
- Reuse the existing `LinkDialog` for editing (prefilled, in-place update).
15+
- Correct behavior from a bare caret inside a link (no selection required).
16+
17+
**Non-Goals:**
18+
19+
- No change to toolbar link insertion.
20+
- No new bubble menus for other marks (bold, image, etc.) — link only.
21+
- No new npm dependency.
22+
23+
## Decisions
24+
25+
### Render `LinkBubbleMenu` inside `EditorInner`
26+
27+
The bubble menu lives as a sibling to `EditorContent`, within the existing `EditorContextProvider`, so it can read `editor` via `useCurrentEditor()`. Buttons reuse `ToolbarDefaultButton`.
28+
29+
```tsx
30+
<BubbleMenu editor={editor} pluginKey="linkBubbleMenu" shouldShow={showLinkMenu}>
31+
<ToolbarDefaultButton icon="<edit-icon>" onClick={openEdit} title="Edit link" />
32+
<ToolbarDefaultButton icon="<trash-icon>" onClick={removeLink} title="Remove link" />
33+
</BubbleMenu>;
34+
{
35+
isEditing && <LinkDialog referenceElement={linkEl} onClose={closeEdit} />;
36+
}
37+
```
38+
39+
### `shouldShow` = active link, editable, not editing
40+
41+
```ts
42+
shouldShow = ({ editor }) => editor.isEditable && editor.isActive("link") && !isEditing;
43+
```
44+
45+
Covers three spec requirements at once: appears on link, hidden off-link, hidden when read-only, and suppressed while the dialog is open (single floating layer).
46+
47+
_Alternative considered:_ require a non-empty selection over the link. Rejected — less discoverable; the caret-inside case is the common one.
48+
49+
### Full-range selection before Edit and Remove
50+
51+
Both actions first run `editor.chain().focus().extendMarkRange("link")`. This is the crux fix:
52+
53+
- **Edit:** `LinkDialog`'s submit branches on `selectedText`. With a bare caret, `selectedText === ""` → it takes the "insert new text" branch and **duplicates** the link. Selecting the whole link range first populates `selectedText` → correct "apply to existing selection" branch.
54+
- **Remove:** without `extendMarkRange`, `unsetLink()` only clears from the caret; the rest of the link survives. Extending first strips the entire link.
55+
56+
```ts
57+
removeLink = () => editor.chain().focus().extendMarkRange("link").unsetLink().run();
58+
openEdit = () => {
59+
editor.chain().focus().extendMarkRange("link").run();
60+
setLinkEl(resolveLinkEl());
61+
setIsEditing(true);
62+
};
63+
```
64+
65+
### Anchor the dialog to the link DOM element
66+
67+
`LinkDialog` takes a `referenceElement`. Anchoring to the actual `<a>`/`.tiptap-link` element (resolved from `editor.view.domAtPos(selection.from)`, walking up to the nearest anchor) keeps the dialog positioned even after the bubble menu hides on focus loss, and reads as "editing _this_ link."
68+
69+
_Alternative considered:_ anchor to the bubble menu container ref. Rejected — the bubble hides when the editor blurs (dialog input steals focus), orphaning the dialog.
70+
71+
## Risks / Trade-offs
72+
73+
- **Icon names unknown** → the icon font names for edit/trash must be confirmed against the existing icon set (config uses names like `Text-bold`). Resolve during implementation; pick the closest existing glyph.
74+
- **Focus loss hides the bubble mid-interaction** → mitigated by anchoring the dialog to the link element (not the bubble) and by `!isEditing` in `shouldShow`, so the dialog owns the interaction once open.
75+
- **KeyboardNavigation extension** intercepts wrapper/toolbar keys → verify Tab/Escape inside the bubble buttons and dialog don't conflict; the bubble buttons are plain `ToolbarDefaultButton`s so Escape/click-outside close is handled by `LinkDialog`'s `useDropdown`.
76+
- **`extendMarkRange` changes the user's selection** → acceptable and expected; the caret ends on the whole link, which is the intent for both edit and remove.
77+
78+
## Migration Plan
79+
80+
1. Add `LinkBubbleMenu.tsx` (menu + edit/remove + link-element resolution + dialog).
81+
2. Render it in `EditorInner` inside `tiptap-wrapper`.
82+
3. Add `.link-bubble-menu` container styling; reuse toolbar button styles.
83+
4. Unit-test the edit/remove commands and `shouldShow` gating; manual check in Studio Pro.
84+
85+
Rollback: remove the render in `EditorInner`; the new file becomes dead and can be deleted.
86+
87+
## Open Questions
88+
89+
- Exact icon glyph names for Edit and Remove — resolve against the shipped icon font during implementation.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Why
2+
3+
Today a user can insert a link via the toolbar, but there is no quick way to edit or remove an _existing_ link — they must reselect the text and reopen the toolbar dialog, and the toolbar's link dialog even duplicates the link when invoked on a bare cursor inside a link. A contextual bubble menu that appears on any link gives an in-place Edit/Remove affordance, matching the standard rich text editor UX.
4+
5+
## What Changes
6+
7+
- Add a `LinkBubbleMenu` component using tiptap's `BubbleMenu` from `@tiptap/react/menus` (already resolved transitively — no new dependency).
8+
- The menu appears whenever the caret is inside or a selection covers a link (`editor.isActive("link")`) and the editor is editable.
9+
- Menu contains two `ToolbarDefaultButton` controls:
10+
- **Edit** — selects the whole link (`extendMarkRange("link")`) then opens the existing `LinkDialog`, prefilled, anchored to the link's DOM element.
11+
- **Remove**`extendMarkRange("link").unsetLink()` to strip the link across its full range, turning it back into normal text.
12+
- While the dialog is open, the bubble menu is suppressed (`shouldShow` returns false during editing) so only one floating layer shows at a time.
13+
- Render `LinkBubbleMenu` inside `EditorInner`, as a sibling to `EditorContent`, within the existing `EditorContextProvider`.
14+
15+
## Capabilities
16+
17+
### New Capabilities
18+
19+
- `rich-text-link-bubble-menu`: Contextual bubble menu shown on links, offering in-place Edit and Remove actions, including the full-link selection behavior that keeps editing and removal correct regardless of caret position.
20+
21+
### Modified Capabilities
22+
23+
<!-- None — the existing toolbar link-insert behavior is unchanged. -->
24+
25+
## Impact
26+
27+
- Package: `@mendix/rich-text-web`
28+
- New file: `src/components/LinkBubbleMenu.tsx`
29+
- Modified: `src/components/Editor.tsx` (`EditorInner` renders the bubble menu)
30+
- Reuses: `LinkDialog` (existing edit path), `ToolbarDefaultButton`, `useCurrentEditor`.
31+
- Styling: new `.link-bubble-menu` container class; reuses existing toolbar button styles.
32+
- No XML, no runtime API change, no new dependency.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Contextual link bubble menu
4+
5+
The rich text editor SHALL display a floating bubble menu whenever the caret is inside a link or a selection covers a link, and the editor is editable. The menu SHALL contain an Edit action and a Remove action.
6+
7+
#### Scenario: Menu appears on a link
8+
9+
- **WHEN** the caret is placed inside link text in an editable editor
10+
- **THEN** a bubble menu with Edit and Remove buttons appears anchored to the link
11+
12+
#### Scenario: Menu hidden away from links
13+
14+
- **WHEN** the caret is in text that is not part of a link
15+
- **THEN** the bubble menu is not shown
16+
17+
#### Scenario: Menu suppressed when not editable
18+
19+
- **WHEN** the editor is read-only
20+
- **THEN** the bubble menu is not shown even if the caret is inside a link
21+
22+
### Requirement: Edit an existing link
23+
24+
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.
25+
26+
#### Scenario: Edit from a bare caret inside a link
27+
28+
- **WHEN** the caret is inside a link with no text selected and the user activates Edit
29+
- **THEN** the full link range is selected and the link dialog opens prefilled with the current URL, text, title, and target
30+
- **AND** submitting the dialog updates the existing link without duplicating its text
31+
32+
#### Scenario: Dialog anchored to the link
33+
34+
- **WHEN** the Edit action opens the link dialog
35+
- **THEN** the dialog is positioned relative to the link's DOM element and remains positioned there while editing
36+
37+
### Requirement: Remove a link
38+
39+
The Remove action SHALL strip the link mark across its entire range, converting the linked text back into normal text.
40+
41+
#### Scenario: Remove from a bare caret inside a link
42+
43+
- **WHEN** the caret is inside a link with no text selected and the user activates Remove
44+
- **THEN** the link mark is removed from the whole link range and the text remains as plain text
45+
46+
### Requirement: Single floating layer while editing
47+
48+
While the link dialog is open, the bubble menu SHALL be suppressed so that only one floating layer is visible at a time.
49+
50+
#### Scenario: Bubble hides during edit
51+
52+
- **WHEN** the link dialog is open via the Edit action
53+
- **THEN** the bubble menu is not shown
54+
55+
#### Scenario: Bubble returns after editing
56+
57+
- **WHEN** the link dialog is closed and the caret is still inside a link
58+
- **THEN** the bubble menu reappears
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## 1. Create the bubble menu component
2+
3+
- [x] 1.1 Add `src/components/LinkBubbleMenu.tsx` importing `BubbleMenu` from `@tiptap/react/menus`, reading `editor` via `useCurrentEditor()`
4+
- [x] 1.2 Add local `isEditing` state and a `linkEl` state for the resolved link DOM element
5+
- [x] 1.3 Implement `shouldShow = ({ editor }) => editor.isEditable && editor.isActive("link") && !isEditing`
6+
- [x] 1.4 Render `<BubbleMenu editor pluginKey="linkBubbleMenu" shouldShow>` wrapping two `ToolbarDefaultButton`s (Edit, Remove) in a `.link-bubble-menu` container
7+
8+
## 2. Wire the actions
9+
10+
- [x] 2.1 `removeLink`: `editor.chain().focus().extendMarkRange("link").unsetLink().run()`
11+
- [x] 2.2 `resolveLinkEl`: from `editor.view.domAtPos(editor.state.selection.from)`, walk up to nearest `<a>` / `.tiptap-link` element
12+
- [x] 2.3 `openEdit`: run `extendMarkRange("link")`, set `linkEl` from `resolveLinkEl`, set `isEditing = true`
13+
- [x] 2.4 Render `{isEditing && <LinkDialog referenceElement={linkEl} onClose={() => setIsEditing(false)} />}`
14+
- [x] 2.5 Choose icons from the shipped font (`src/ui/RichTextIcons.scss`) — no pencil glyph exists; use `Hyperlink` for Edit and `Erase` (or `Delete`) for Remove, or add a new glyph if a dedicated edit icon is wanted
15+
16+
## 3. Integrate into the editor
17+
18+
- [x] 3.1 Render `<LinkBubbleMenu />` inside `EditorInner` in `Editor.tsx`, as a sibling to `EditorContent` within `tiptap-wrapper`
19+
- [x] 3.2 Confirm it only mounts when not in code view (bubble is irrelevant in the HTML code editor)
20+
21+
## 4. Styling
22+
23+
- [x] 4.1 Add `.link-bubble-menu` container style (inline-flex, padding, background, shadow); reuse existing toolbar `.icon-button` styles for the buttons
24+
25+
## 5. Verify
26+
27+
- [x] 5.1 Unit test: `shouldShow` returns true on active link + editable, false off-link, false when read-only, false while editing
28+
- [x] 5.2 Unit test: Remove strips the whole link from a bare caret; Edit selects the full range so `LinkDialog` prefills and updates in place (no duplicate)
29+
- [x] 5.3 Run package unit tests (Jest + RTL) — all pass
30+
- [ ] 5.4 Manual check in Studio Pro (`pnpm start` with `MX_PROJECT_PATH`): click a link → bubble shows → Edit prefills + updates, Remove unlinks; bubble hidden while dialog open and in read-only mode
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Rich Text Link Bubble Menu Specification
2+
3+
## Purpose
4+
5+
Contextual floating bubble menu for the rich text editor that appears when the caret is inside a link (or a selection covers a link). It provides quick Edit and Remove actions for the link, coordinating with the link dialog so that only one floating layer is visible at a time.
6+
7+
## Requirements
8+
9+
### Requirement: Contextual link bubble menu
10+
11+
The rich text editor SHALL display a floating bubble menu whenever the caret is inside a link or a selection covers a link, and the editor is editable. The menu SHALL contain an Edit action and a Remove action.
12+
13+
#### Scenario: Menu appears on a link
14+
15+
- **WHEN** the caret is placed inside link text in an editable editor
16+
- **THEN** a bubble menu with Edit and Remove buttons appears anchored to the link
17+
18+
#### Scenario: Menu hidden away from links
19+
20+
- **WHEN** the caret is in text that is not part of a link
21+
- **THEN** the bubble menu is not shown
22+
23+
#### Scenario: Menu suppressed when not editable
24+
25+
- **WHEN** the editor is read-only
26+
- **THEN** the bubble menu is not shown even if the caret is inside a link
27+
28+
### Requirement: Edit an existing link
29+
30+
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.
31+
32+
#### Scenario: Edit from a bare caret inside a link
33+
34+
- **WHEN** the caret is inside a link with no text selected and the user activates Edit
35+
- **THEN** the full link range is selected and the link dialog opens prefilled with the current URL, text, title, and target
36+
- **AND** submitting the dialog updates the existing link without duplicating its text
37+
38+
#### Scenario: Dialog anchored to the link
39+
40+
- **WHEN** the Edit action opens the link dialog
41+
- **THEN** the dialog is positioned relative to the link's DOM element and remains positioned there while editing
42+
43+
### Requirement: Remove a link
44+
45+
The Remove action SHALL strip the link mark across its entire range, converting the linked text back into normal text.
46+
47+
#### Scenario: Remove from a bare caret inside a link
48+
49+
- **WHEN** the caret is inside a link with no text selected and the user activates Remove
50+
- **THEN** the link mark is removed from the whole link range and the text remains as plain text
51+
52+
### Requirement: Single floating layer while editing
53+
54+
While the link dialog is open, the bubble menu SHALL be suppressed so that only one floating layer is visible at a time.
55+
56+
#### Scenario: Bubble hides during edit
57+
58+
- **WHEN** the link dialog is open via the Edit action
59+
- **THEN** the bubble menu is not shown
60+
61+
#### Scenario: Bubble returns after editing
62+
63+
- **WHEN** the link dialog is closed and the caret is still inside a link
64+
- **THEN** the bubble menu reappears
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Editor } from "@tiptap/core";
2+
import { StarterKit } from "@tiptap/starter-kit";
3+
4+
function makeEditor(content: string): Editor {
5+
const element = document.createElement("div");
6+
document.body.appendChild(element);
7+
return new Editor({
8+
element,
9+
extensions: [StarterKit.configure({ link: { openOnClick: false } })],
10+
content
11+
});
12+
}
13+
14+
/** Place a collapsed caret inside the first occurrence of the given text. */
15+
function placeCaretInText(editor: Editor, text: string): void {
16+
let target = -1;
17+
editor.state.doc.descendants((node, pos) => {
18+
if (target === -1 && node.isText && node.text?.includes(text)) {
19+
target = pos + (node.text.indexOf(text) + 1);
20+
return false;
21+
}
22+
return true;
23+
});
24+
editor.commands.setTextSelection(target);
25+
}
26+
27+
// Mirrors the predicate used by LinkBubbleMenu's shouldShow.
28+
function shouldShow(editor: Editor, isEditing: boolean): boolean {
29+
return editor.isEditable && editor.isActive("link") && !isEditing;
30+
}
31+
32+
describe("LinkBubbleMenu behavior", () => {
33+
describe("shouldShow", () => {
34+
it("is true when the caret is inside a link and editable", () => {
35+
const editor = makeEditor('<p><a href="https://a.com">linked</a> plain</p>');
36+
placeCaretInText(editor, "linked");
37+
38+
expect(shouldShow(editor, false)).toBe(true);
39+
});
40+
41+
it("is false when the caret is not inside a link", () => {
42+
const editor = makeEditor('<p><a href="https://a.com">linked</a> plain</p>');
43+
placeCaretInText(editor, "plain");
44+
45+
expect(shouldShow(editor, false)).toBe(false);
46+
});
47+
48+
it("is false while the dialog is open (isEditing)", () => {
49+
const editor = makeEditor('<p><a href="https://a.com">linked</a></p>');
50+
placeCaretInText(editor, "linked");
51+
52+
expect(shouldShow(editor, true)).toBe(false);
53+
});
54+
55+
it("is false when the editor is not editable", () => {
56+
const editor = makeEditor('<p><a href="https://a.com">linked</a></p>');
57+
placeCaretInText(editor, "linked");
58+
editor.setEditable(false);
59+
60+
expect(shouldShow(editor, false)).toBe(false);
61+
});
62+
});
63+
64+
describe("remove", () => {
65+
it("strips the whole link from a bare caret", () => {
66+
const editor = makeEditor('<p><a href="https://a.com">linked</a></p>');
67+
placeCaretInText(editor, "linked");
68+
69+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
70+
71+
expect(editor.isActive("link")).toBe(false);
72+
expect(editor.getHTML()).not.toContain("<a");
73+
expect(editor.getText()).toBe("linked");
74+
});
75+
});
76+
77+
describe("edit", () => {
78+
it("selects the full link range from a bare caret so the dialog can prefill", () => {
79+
const editor = makeEditor('<p><a href="https://a.com">linked</a></p>');
80+
placeCaretInText(editor, "linked");
81+
expect(editor.state.selection.empty).toBe(true);
82+
83+
editor.chain().focus().extendMarkRange("link").run();
84+
85+
const { from, to } = editor.state.selection;
86+
expect(editor.state.doc.textBetween(from, to)).toBe("linked");
87+
expect(editor.getAttributes("link").href).toBe("https://a.com");
88+
});
89+
});
90+
});

packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { forwardRef, ReactElement, useImperativeHandle, useMemo } from "react";
1212
import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";
1313
import { EditorContextProvider, useCurrentEditor } from "./EditorContext";
1414
import { HighlightedCodeEditor } from "./HighlightedCodeEditor";
15+
import { LinkBubbleMenu } from "./LinkBubbleMenu";
1516
import { Toolbar } from "./toolbars";
1617
import { RichTextContainerProps } from "../../typings/RichTextProps";
1718
import { FontFamilyClass } from "../extensions/FontFamilyClass";
@@ -120,7 +121,10 @@ function EditorInner({
120121
readOnly={false}
121122
/>
122123
) : (
123-
<EditorContent editor={editor} className={className} />
124+
<>
125+
<EditorContent editor={editor} className={className} />
126+
{!readOnly && <LinkBubbleMenu />}
127+
</>
124128
)}
125129
</div>
126130
{codeViewState.showConfirm && (

0 commit comments

Comments
 (0)