feat(editor): link insertion full UI and flow

This commit is contained in:
Florian Sylvain committed 2026-10-12 17:12:36 +02:00
1 parent 03b39e64f2
commit 6f9ae6207a
16 files changed
+2865 -14

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
Feature: Inserting, editing and removing a link in the article body
As a verified RenewCMS owner writing an article
I want to turn text into a link from the toolbar or with Ctrl/Cmd+K, and to edit or remove it later
So that readers can follow references, and no link I add can run script
Background:
Given this slice is SPA only; no Go code, route or API contract changes
And the screen is /article/:id/edit (EditorPage in DashboardShell), per docs/design/screens/editor.md
rows 11 to 27, docs/design/components.md "Editor: link" and the Editor group of the board
"RenewCMS Screens Back-office.dc.html"; docs/design/README.md says "11 states" but all 17 rows are in scope
And a link change is a body edit only: it moves useArticleDraft to "dirty" ("Unsaved changes", Save enabled,
PublishControl "Save and publish" on a draft) and sends no request; the existing PATCH /v1/article/{id}
with {"body": "<html>"} saves it, unchanged
And the body stays Tiptap HTML, a link being <a href="...">; ArticleEditor keeps its props, events and
exposed focus, insertImage, imageSrc and removeImage
And the toolbar has a link button (icon i-lucide-link) after the list buttons, before "Clear formatting"
and "Image"; 28 px on desktop, 40 px on mobile; text colour --ui-text-toned
And Ctrl+K (Cmd+K on macOS) with the editor focused opens the same dialog as the button and prevents the
browser's own shortcut
And LinkModal is a dialog at desktop width and a bottom sheet at mobile width (viewport); fields "Link text"
and "URL", toggle "Open in new tab" (off by default) with the helper text "Links open in the same tab unless
you turn on a new tab."; title "Insert link" or "Edit link"; Cancel left, "Insert link" or "Save link" right;
in edit mode a text button "Remove link"
And a valid URL, after trimming, is http(s)://host..., mailto:address, a path with exactly one leading "/",
or #anchor; everything else fails with "Enter a valid URL. Use http(s), mailto, a path or #anchor."
(so javascript:, data:, vbscript:, file:, ftp:, tel:, //host, page.html and ../x are all invalid, in any
letter case, with leading whitespace, control characters or entity escapes)
And "Link text" must not be blank, else "Enter the link text."
And validation runs on submit, and on blur for the URL; an invalid submit inserts nothing, keeps the dialog
open and focuses the first invalid field
And "Open in new tab" writes target="_blank" rel="noopener noreferrer" on the anchor; off writes neither
And the link popover and its sheet follow the input type, not the width: a popover for a fine pointer, a bottom
sheet for pointer: coarse
And the popover is 288 px wide under the link: the URL in mono, then "Edit", "Open", "Remove" (error colour);
the sheet shows the link text, the URL and 48 px rows "Edit link", "Open link", "Remove link"
And Remove takes no confirmation and is undone by the editor's undo; Open opens a new tab with
rel="noopener noreferrer"
And selection edge cases follow the editor's default mark behaviour; no custom rules
And automatic URL linking is untouched by this slice
And target and rel exist only in ?format=html; the json and markdown reads carry only the href (accepted)
And every new English string has a French entry in messages.ts
Scenario Outline: The link button across its states
Given the user is on /article/12/edit in the Saved state, <situation>
Then the link button shows <expected>
Examples:
| situation | expected |
| the cursor is in plain text | aria-pressed "false", --ui-text-toned |
| the pointer rests on it for 400 ms (Windows or Linux) | the tooltip "Insert link (Ctrl+K)" |
| the pointer rests on it for 400 ms (macOS) | the tooltip "Insert link (⌘K)" |
| the input is touch | no tooltip |
| it takes keyboard focus | a 2 px --ui-primary outline, 1 px offset; Enter or Space opens the dialog |
| the cursor is inside a link | aria-pressed "true", --rc-sand fill, --rc-on-sand icon; pressing it opens the edit dialog |
| part or all of a link's text is selected | the same active look |
| an image is selected | 40% opacity, aria-disabled "true", no tooltip; pressing it and Ctrl/Cmd+K open nothing |
| Preview is on, or ArticleEditor has disabled true | Ctrl/Cmd+K opens nothing |
Scenario Outline: Creating a link
Given the user is on /article/12/edit with the body "<p>We also check the timber windows before any tiling starts.</p>"
And <start>
When the user presses Ctrl+K, or activates the link button
Then LinkModal opens titled "Insert link" with "Link text" <text-field>, the toggle off, and "Cancel" and "Insert link"
And <focus-target> has focus
When the user sets "Link text" to "<text>", "URL" to "/articles/winter-checklist" and activates "Insert link"
Then the dialog closes and the body holds <a href="/articles/winter-checklist"><text></a> at that place
And focus is back in the editor, SaveStatus reads "Unsaved changes", and no request has been sent
Examples:
| start | text-field | focus-target | text |
| "timber windows" is selected | prefilled "timber windows" | "URL" | timber windows |
| "timber windows" is selected | prefilled, then edited | "URL" | timber sash windows |
| nothing is selected | empty, placeholder "What the reader sees" | "Link text" | winter checklist |
Scenario Outline: Editing and removing an existing link
Given the body has the link "timber windows" to "/articles/winter-checklist" opening in a new tab
When the user <opens-edit>
Then LinkModal opens titled "Edit link" with the text, the URL (focused) and the toggle on prefilled, and "Remove link", "Cancel", "Save link"
When the user <action>
Then <result>
Examples:
| opens-edit | action | result |
| presses Edit in the link popover | sets the URL to "https://example.org/windows" and activates "Save link" | the same anchor has that href and still opens in a new tab; SaveStatus "Unsaved changes" |
| presses Ctrl+K with the cursor in the link | activates "Save link" with nothing changed | the dialog closes, the body is identical and a Saved draft stays Saved |
| presses Ctrl+K with the cursor in the link | activates "Remove link" | the text stays without the link, no confirmation, "Unsaved changes"; undo brings the link back with its target |
Scenario Outline: Validation in the dialog
Given the dialog is open
When the user sets "Link text" to "<text>", "URL" to "<url>" and activates "Insert link"
Then <outcome>
And nothing is inserted, the dialog stays open and the draft's dirty state is unchanged
Examples:
| text | url | outcome |
| timber windows | www.example | "URL" shows "Enter a valid URL. Use http(s), mailto, a path or #anchor." |
| timber windows | example.com | the same URL error |
| timber windows | javascript:alert(1) | the same URL error |
| timber windows | JaVaScRiPt:alert(1) | the same URL error |
| timber windows | javascript:alert(1) | the same URL error |
| timber windows | java&#115;cript:alert(1) | the same URL error |
| timber windows | data:text/html;base64,PHA+ | the same URL error |
| timber windows | vbscript:msgbox(1) | the same URL error |
| timber windows | file:///etc/passwd | the same URL error |
| timber windows | ftp://example.org/a | the same URL error |
| timber windows | tel:+33123456789 | the same URL error |
| timber windows | //evil.example | the same URL error |
| timber windows | page.html | the same URL error |
| timber windows | ../x | the same URL error |
| timber windows | http:// | the same URL error |
| timber windows | https://exa mple.org | the same URL error |
| | #measuring | "Link text" shows "Enter the link text." and has focus |
| (spaces) | #measuring | the same text error |
| | www.example | both errors show and focus goes to "Link text" |
Scenario Outline: Accepted URL forms
Given the dialog is open with "Link text" "x"
When the user enters "<url>" and activates "Insert link"
Then the link is inserted with href "<href>"
Examples:
| url | href |
| https://example.org/a?b=1#c | https://example.org/a?b=1#c |
| HTTP://Example.org | HTTP://Example.org |
| mailto:hello@example.org | mailto:hello@example.org |
| /articles/winter-checklist | /articles/winter-checklist |
| #measuring | #measuring |
| (padded) " /page " | /page |
Scenario: The URL validates on blur
Given the dialog is open and the user typed "www.example" in "URL"
When focus leaves "URL"
Then the URL error shows and nothing is inserted
Scenario: Link: long URL
Given the edit dialog is open on "planning guidance" with the 90-character URL
"https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf"
Then "URL" stays on one line, does not widen the dialog, scrolls to reveal the whole value, and its title attribute holds the full URL
And "Save link" keeps its place and stays reachable at desktop and mobile width
Scenario Outline: Leaving the dialog without saving
Given the dialog is open with changed fields
When the user <leaves>
Then it closes, the body and the draft's dirty state are unchanged, and focus returns to the editor with the earlier selection
Examples:
| leaves |
| activates "Cancel" |
| presses Esc |
| clicks or taps outside it |
Scenario Outline: Dialog or sheet by viewport, popover or sheet by input
Given a link "timber windows" to "/articles/winter-checklist" in the body
When <action>
Then <result>
Examples:
| action | result |
| the user opens the link dialog at 1440 px wide | a dialog |
| the user opens the link dialog at 390 px wide | a bottom sheet with the same fields, errors and footer |
| a fine pointer clicks the link, or Enter on the focused link | a 288 px popover under the link with the URL, "Edit", "Open", "Remove"; the link button is active; Esc closes it and keeps the cursor in the link |
| a coarse pointer taps the link | a bottom sheet with "timber windows", the URL and 48 px rows "Edit link", "Open link", "Remove link"; Esc or a tap outside closes it |
| the user activates "Open" or "Open link" | the URL opens in a new tab with rel "noopener noreferrer" |
| the user activates "Remove" or "Remove link" | the link is removed with no confirmation, "Unsaved changes", and undo restores it |
Scenario Outline: Rendered link states in both themes
Given the body has the link "timber windows" and the theme is <theme>
Then the link <state>
Examples:
| state | theme |
| is --ui-text-highlighted with a 1 px underline at a 3 px offset | light |
| is --ui-text-highlighted with a 1 px underline at a 3 px offset | dark |
| shows a 2 px underline on pointer hover | light |
| shows a 2 px underline on pointer hover | dark |
| shows a 2 px --ui-primary outline at 2 px offset when reached by Tab or arrows | light |
| shows a 2 px --ui-primary outline at 2 px offset when reached by Tab or arrows | dark |
| has an --rc-sand fill and --rc-on-sand text when the selection crosses it | light |
| has an --rc-sand fill and --rc-on-sand text when the selection crosses it | dark |
Scenario: Toolbar overflow at 390 px
Given the viewport is 390 px wide
Then the full toolbar scrolls sideways in one row without wrapping
And a 24 or 32 px fade marks each side that has hidden buttons, and none on a side with nothing hidden
When the toolbar is scrolled to the end
Then the link, "Clear formatting" and "Image" buttons are visible, each 40 px
And at 1440 px the toolbar is unchanged and shows no fade
Scenario Outline: Selections that straddle a link or span blocks
Given the body is "<p>We also check the <a href=\"/a\">timber windows</a> before</p><p>any tiling starts.</p>"
And the selection is <selection>
When the user opens the dialog with the text unchanged and submits "/b"
Then the new link covers the whole selected range and replaces any partial link inside it
And no error occurs, and no text of the body is lost
Examples:
| selection |
| "check the timber" (plain text and part of a link)|
| "windows before" (part of a link and plain text) |
| "before" to "any tiling" (across two paragraphs) |
Scenario: Links survive the HTML body format and reach the other formats
Given a link "timber windows" to "/articles/winter-checklist" opening in a new tab is in the body
When the user saves and reloads /article/12/edit
Then PATCH /v1/article/12 carried the anchor with href, target="_blank" and rel="noopener noreferrer"
And the reloaded editor shows the link, and its edit dialog has the toggle on
And a link saved without the toggle carries neither target nor rel
When a client sends GET /v1/article/12?format=json, or ?format=markdown
Then json holds a link mark with href "/articles/winter-checklist" on "timber windows", and markdown holds [timber windows](/articles/winter-checklist), with no target or rel
Scenario Outline: An unsafe href already in a body is neutralised, and the next save rewrites the stored body
Given GET /v1/article/12 resolves a body containing <a href="<href>">x</a>, or the user pastes that HTML into the body
When the editor loads the body
Then the text "x" is kept without a link, the link button is not active on it, and clicking it navigates nowhere
And the load alone does not mark the draft dirty and sends no request, so the stored body is still unchanged
When the user makes any edit and saves
Then PATCH /v1/article/12 carries the neutralised body, without the unsafe href, so the stored body is silently rewritten without the link
And the body the editor serialises never contains "javascript:", "data:" or "vbscript:" in an href
And a paste is itself an edit, so in the paste case the draft is dirty straight away
Examples:
| href |
| javascript:alert(1) |
| data:text/html,<b> |
| vbscript:msgbox(1) |
+60
View File
@@ -0,0 +1,60 @@
# QA: Inserting, editing and removing a link in the article body
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage at `/article/12/edit` with the `editor_core.qa.md` fixture and the body
`<p>Every bathroom is different. We also check the timber windows before any tiling starts.</p>`.
Mock `request` for GET and PATCH /v1/article/12 only. Tiptap runs for real in jsdom; drive selection
through the editor instance. `request` is never called except by steps 8 and 9. Mock `matchMedia` for
`pointer: coarse` and for the viewport width.
1. **Button states, Examples-driven** - default, tooltip text per platform (Windows and Linux "Insert link (Ctrl+K)", macOS "Insert link (⌘K)", none on touch), focus ring class, active for cursor-in and for partial or full selection, disabled for an image selection, and Ctrl/Cmd+K inert in Preview and with `disabled`.
2. **Create, Examples-driven (3 rows)** - Ctrl+K and the button: title, prefill, focus target, toggle off; submit; body HTML holds the anchor; "Unsaved changes"; zero requests.
3. **Edit and remove, Examples-driven (3 rows)** - Edit and Ctrl+K entry, prefilled fields incl. the toggle, change href in place, no-op leaves a Saved draft Saved, "Remove link" with no confirm and undo restoring href and target.
4. **Validation, Examples-driven** - every row of both validation Outlines, plus the accepted-forms Outline: exact error text, dialog open, body and dirty state unchanged, focus target, and the trimmed href; the blur case. For the unsafe rows assert the body HTML never has "javascript:", "data:" or "vbscript:" in an href.
5. **Long URL** - the `title` attribute holds the full 90-character value, the dialog has no horizontal overflow, "Save link" is enabled.
6. **Leave without saving, Examples-driven** - Cancel, Esc, outside click: nothing changes; focus returns.
7. **Dialog/sheet and popover/sheet, Examples-driven** - width mock gives dialog or sheet; fine pointer gives popover (Esc closes), coarse gives sheet with three 48 px rows; Open calls `window.open(url, '_blank', 'noopener,noreferrer')`; Remove has no confirm and is undoable; "Open in new tab" gives `target="_blank" rel="noopener noreferrer"`, off gives neither.
8. **Round trip** - save with a new-tab link: the PATCH body has the anchor with target and rel; remount with it: link and toggle restored; without the toggle no target or rel.
9. **Unsafe hrefs, Examples-driven** - on load: text kept, no link, button not active, draft not dirty, zero requests; then an edit and Save: the PATCH body has no unsafe href. Paste of the same HTML: same neutralising, draft dirty.
10. **Selection edge cases, Examples-driven (3 rows)** - the new link covers the range, a partial link is replaced, no throw, and the text content before and after is identical. Then the French completeness check: every new key is in `messages.ts` and the dialog title and two error strings are spot-checked.
## Follow-ups and notes (not asserted here)
- **Follow-up Go slice (out of scope):** `?format=html` returns the stored HTML as is, and `safeHref` runs only for markdown and json. An API client that PATCHes `<a href="javascript:...">` gets it back in the html read. A separate Go slice should sanitise href schemes in the converter and probably reject them on write. The SPA sanitising here is defence in depth only.
- **Board-vs-reality note for the next Claude Design pass:** row 16 says the URL field "truncates in the middle of the line", and the board draws an ellipsis in the text. CSS cannot truncate the middle of an `<input>`. The implementation is a single scrolling line with the full URL in `title`.
- **Design gap for the next pass:** the helper text "Links open in the same tab unless you turn on a new tab." is in the board only. `components.md` ("LinkModal") needs it.
- **README undercount:** `docs/design/README.md` says "11 states"; `screens/editor.md` has 17 link rows (11-27). All 17 are covered.
## Visual: `task visual:check -- editor`
Setups go in `web/visual/states.ts` with the board's sample data: article "How we price a bathroom renovation", SaveStatus "Unsaved changes", link text "timber windows", URL "/articles/winter-checklist" (rows 13, 20, 22-26), "www.example" (14), "#measuring" (15), and for row 16 the text "planning guidance" with the 90-character URL above. Hover, focus and selection (rows 18, 23-26) are forced pseudo-states in the setup. If a row cannot be matched within 10 %, stop and ask the user: no per-state exception without `approvedBy`. No row is clock-sensitive.
| Row | State |
|---|---|
| 11 | Link: create from selection |
| 12 | Link: create with no selection |
| 13 | Link: edit existing (toggle on, Remove link) |
| 14 | Link: invalid URL |
| 15 | Link: empty text |
| 16 | Link: long URL |
| 17 | Link button: default |
| 18 | Link button: hover and tooltip (desktop only) |
| 19 | Link button: focus-visible |
| 20 | Link button: active |
| 21 | Link button: disabled (image selected) |
| 22 | Link popover (desktop) and sheet (mobile) |
| 23-26 | Rendered link: default, hover, focus, selected |
| 27 | Toolbar overflow (390 px, scrolled to the end; desktop unchanged) |
- [ ] Keyboard and focus: Tab order, Enter/Space on the button, focus trap and return, Esc on the popover, Enter on a focused link.
- [ ] Hover and motion: tooltip delay, underline thickening, sheet slide.
- [ ] Screen reader: aria-pressed, dialog labelling, error association.
## Manual verification
- **Ctrl/Cmd+K** in Chrome, Firefox, Safari and Edge: the browser shortcut is prevented only inside the focused editor.
- **macOS tooltip** on a real Mac.
- **Real touch** on a phone: a tap on a link opens the sheet and does not follow the link; the sideways toolbar scroll and fades work, including iOS momentum.
- **IME** in the Link text field.
- **Save and public read** against `task dev`: save a new-tab link, then GET ?format=html, ?format=json and ?format=markdown, and open the public page.