diff --git a/features/editor_insert_link.feature b/features/editor_insert_link.feature new file mode 100644 index 0000000..b8b00d6 --- /dev/null +++ b/features/editor_insert_link.feature @@ -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": ""} saves it, unchanged + And the body stays Tiptap HTML, a link being ; 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, + Then the link button shows + + 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 "

We also check the timber windows before any tiling starts.

" + And + When the user presses Ctrl+K, or activates the link button + Then LinkModal opens titled "Insert link" with "Link text" , the toggle off, and "Cancel" and "Insert link" + And has focus + When the user sets "Link text" to "", "URL" to "/articles/winter-checklist" and activates "Insert link" + Then the dialog closes and the body holds
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 + 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 + Then + + 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 "", "URL" to "" and activates "Insert link" + Then + 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 | javascript: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 "" and activates "Insert link" + Then the link is inserted with 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 + 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 + Then + + 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 + Then the link + + 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 "

We also check the timber windows before

any tiling starts.

" + And the selection is + 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 x, 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, | + | vbscript:msgbox(1) | diff --git a/features/editor_insert_link.qa.md b/features/editor_insert_link.qa.md new file mode 100644 index 0000000..2525082 --- /dev/null +++ b/features/editor_insert_link.qa.md @@ -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 +`

Every bathroom is different. We also check the timber windows before any tiling starts.

`. +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 `` 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 ``. 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. diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 1bbf0f2..46cf9eb 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -315,6 +315,16 @@ body { .article-content a:hover { text-decoration-thickness: 2px; } +.article-content a:focus-visible, +.article-content .article-link-focus { + outline: 2px solid var(--ui-primary); + outline-offset: 2px; +} +.article-content a::selection, +.article-content a *::selection { + background: var(--rc-sand); + color: var(--rc-on-sand); +} .article-content img { display: block; max-width: 100%; diff --git a/web/src/components/ArticleEditor.vue b/web/src/components/ArticleEditor.vue index 7771a28..16eacf8 100644 --- a/web/src/components/ArticleEditor.vue +++ b/web/src/components/ArticleEditor.vue @@ -1,9 +1,14 @@ + + diff --git a/web/src/components/LinkPopover.vue b/web/src/components/LinkPopover.vue new file mode 100644 index 0000000..5b2d789 --- /dev/null +++ b/web/src/components/LinkPopover.vue @@ -0,0 +1,168 @@ + + + diff --git a/web/src/components/__tests__/ArticleEditorLink.spec.ts b/web/src/components/__tests__/ArticleEditorLink.spec.ts new file mode 100644 index 0000000..1c057c4 --- /dev/null +++ b/web/src/components/__tests__/ArticleEditorLink.spec.ts @@ -0,0 +1,1075 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { mount, flushPromises } from '@vue/test-utils' +import type { VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import UApp from '@nuxt/ui/components/App.vue' +import type { Editor } from '@tiptap/core' +import { NodeSelection } from '@tiptap/pm/state' +import router from '@/router' +import { useSessionStore } from '@/stores/session' +import RootComponent from '@/App.vue' +import ArticleEditor from '@/components/ArticleEditor.vue' +import { messages } from '@/i18n/messages' +import { setLocale } from '@/i18n' + +class TestPointerEvent extends MouseEvent { + pointerType: string + + constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) { + super(type, init) + this.pointerType = init.pointerType ?? 'mouse' + } +} + +vi.stubGlobal('PointerEvent', TestPointerEvent) + +Range.prototype.getClientRects = () => + Object.assign([], { item: () => null }) as unknown as DOMRectList +Range.prototype.getBoundingClientRect = () => new DOMRect() + +const DESKTOP = { width: 1440, coarse: false } +const PHONE = { width: 390, coarse: true } +const URL_ERROR = 'Enter a valid URL. Use http(s), mailto, a path or #anchor.' +const TEXT_ERROR = 'Enter the link text.' +const SENTENCE = '

We also check the timber windows before any tiling starts.

' +const LINKED = + '

We also check the timber windows before any tiling starts.

' +const LINKED_SAME_TAB = + '

We also check the timber windows before any tiling starts.

' +const LONG_URL = + 'https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/updates/2026-update.pdf' + +interface Media { + width: number + coarse: boolean +} + +function mockMedia({ width, coarse }: Media) { + window.matchMedia = ((query: string) => { + const max = query.match(/max-width:\s*([\d.]+)(rem|px)/) + const min = query.match(/min-width:\s*([\d.]+)(rem|px)/) + const px = (m: RegExpMatchArray) => Number(m[1]) * (m[2] === 'rem' ? 16 : 1) + const matches = query.includes('pointer: coarse') + ? coarse + : (!max || width <= px(max)) && (!min || width >= px(min)) + return { + matches, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + } + }) as unknown as typeof window.matchMedia +} + +function articleResponse(body: string) { + return { + id: 12, + title: 'How we price a bathroom renovation', + body, + is_online: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } +} + +let fetchMock: ReturnType +let wrapper: VueWrapper | null = null + +function patchBodies() { + return fetchMock.mock.calls + .filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH') + .map(([, init]) => JSON.parse(String((init as RequestInit).body)).body as string) +} + +async function mountEditor(body = SENTENCE, media: Media = DESKTOP) { + mockMedia(media) + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true, username: 'marta' } + session.status = 'ready' + fetchMock = vi.fn((_url: string, init?: RequestInit) => { + const sent = init?.method === 'PATCH' ? JSON.parse(String(init.body)).body : body + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => articleResponse(sent), + } as unknown as Response) + }) + global.fetch = fetchMock as unknown as typeof fetch + await router.push('/article/12/edit') + wrapper = mount(RootComponent, { + attachTo: document.body, + global: { plugins: [router, uiPlugin] }, + }) + await flushPromises() + return wrapper +} + +async function settle() { + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))), + ) + await new Promise((resolve) => setTimeout(resolve, 40)) + await flushPromises() +} + +function editor(): Editor { + const vm = wrapper!.findComponent(ArticleEditor).vm as unknown as { + $: { setupState: { editorInstance: Editor } } + } + return vm.$.setupState.editorInstance +} + +function positionOf(text: string) { + let found = -1 + editor().state.doc.descendants((node, pos) => { + if (found < 0 && node.isText && node.text!.includes(text)) { + found = pos + node.text!.indexOf(text) + } + }) + return found +} + +function select(text: string, endText = text) { + const from = positionOf(text) + const to = positionOf(endText) + endText.length + editor().commands.setTextSelection({ from, to }) +} + +function placeCursorIn(text: string) { + const at = positionOf(text) + 3 + editor().commands.setTextSelection(at) +} + +function linkButton() { + return document.body.querySelector('button[aria-label="Insert link"]')! +} + +function clickLink(anchor: HTMLElement = editor().view.dom.querySelector('a')!) { + const view = editor().view + anchor.addEventListener( + 'click', + (event) => { + const pos = view.posAtDOM(anchor, 0) + 1 + editor().commands.setTextSelection(pos) + view.someProp('handleClick', (handle) => handle(view, pos, event)) + }, + { once: true }, + ) + anchor.click() +} + +function dialog() { + return document.body.querySelector('[role="dialog"][data-presentation]') +} + +function pressCtrlK() { + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + bubbles: true, + cancelable: true, + }) + editor().view.dom.dispatchEvent(event) + return event +} + +function field(name: 'text' | 'url') { + const inputs = dialog()!.querySelectorAll( + 'input[type="text"], input:not([type])', + ) + return inputs[name === 'text' ? 0 : 1]! +} + +function toggle() { + return dialog()!.querySelector('[role="switch"]')! +} + +function type(input: HTMLInputElement, value: string) { + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function buttonLabelled(label: string) { + return [...document.body.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === label, + ) +} + +async function press(label: string) { + buttonLabelled(label)!.click() + await settle() +} + +async function fillAndSubmit(text: string, url: string, submit = 'Insert link') { + type(field('text'), text) + type(field('url'), url) + await flushPromises() + await press(submit) +} + +async function openDialog() { + pressCtrlK() + await settle() +} + +function isDirty() { + return document.body.textContent!.includes('Unsaved changes') +} + +function editorHtml() { + return editor().getHTML() +} + +beforeEach(() => { + setLocale('en') +}) + +afterEach(async () => { + wrapper?.unmount() + wrapper = null + document.body.innerHTML = '' + await router.push('/login') +}) + +describe('The link button across its states', () => { + it('is not pressed in plain text and uses the toned text colour', async () => { + await mountEditor() + placeCursorIn('timber windows') + await settle() + + expect(linkButton().getAttribute('aria-pressed')).toBe('false') + expect(linkButton().className).toContain('text-toned') + expect(linkButton().className).not.toContain('--rc-sand') + }) + + it('sits after the list buttons and before Clear formatting and Image', async () => { + await mountEditor() + const buttons = [ + ...document.body.querySelectorAll('.article-editor [role="toolbar"] button'), + ] + const at = buttons.indexOf(linkButton()) + + expect(at).toBeGreaterThan(0) + expect(buttons.length - at).toBe(3) + expect(buttons[at + 2]!.textContent).toContain('Image') + }) + + it('shows the tooltip with Ctrl+K after resting on it for 400 ms', async () => { + vi.useFakeTimers() + try { + await mountEditor() + linkButton().dispatchEvent( + new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }), + ) + await vi.advanceTimersByTimeAsync(399) + expect(document.body.textContent).not.toContain('Insert link (Ctrl+K)') + await vi.advanceTimersByTimeAsync(2) + expect(document.body.textContent).toContain('Insert link (Ctrl+K)') + } finally { + vi.useRealTimers() + } + }) + + it('shows the tooltip with the command key on macOS', async () => { + vi.useFakeTimers() + const platform = vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel') + try { + await mountEditor() + linkButton().dispatchEvent( + new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }), + ) + await vi.advanceTimersByTimeAsync(401) + expect(document.body.textContent).toContain('Insert link (⌘K)') + } finally { + platform.mockRestore() + vi.useRealTimers() + } + }) + + it('has no tooltip when the input is touch', async () => { + vi.useFakeTimers() + try { + await mountEditor(SENTENCE, PHONE) + linkButton().dispatchEvent( + new PointerEvent('pointermove', { bubbles: true, pointerType: 'touch' }), + ) + await vi.advanceTimersByTimeAsync(1000) + expect(document.body.textContent).not.toContain('Insert link (') + expect(linkButton().hasAttribute('data-grace-area-trigger')).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('opens the dialog from the keyboard with Enter', async () => { + await mountEditor() + linkButton().focus() + linkButton().click() + await settle() + + expect(dialog()).not.toBeNull() + }) + + it('is pressed with the sand fill when the cursor is inside a link', async () => { + await mountEditor(LINKED) + placeCursorIn('timber windows') + await settle() + + expect(linkButton().getAttribute('aria-pressed')).toBe('true') + expect(linkButton().className).toContain('bg-(--rc-sand)') + expect(linkButton().className).toContain('text-(--rc-on-sand)') + }) + + it('has the same active look when part of the link text is selected', async () => { + await mountEditor(LINKED) + select('timber') + await settle() + + expect(linkButton().getAttribute('aria-pressed')).toBe('true') + }) + + it('opens the edit dialog when pressed inside a link', async () => { + await mountEditor(LINKED) + placeCursorIn('timber windows') + await settle() + linkButton().click() + await settle() + + expect(dialog()!.textContent).toContain('Edit link') + }) + + it('is disabled with an image selected and opens nothing from the button or Ctrl+K', async () => { + await mountEditor(`${SENTENCE}`) + const imagePos = editor().state.doc.firstChild!.nodeSize + editor().view.dispatch( + editor().state.tr.setSelection(NodeSelection.create(editor().state.doc, imagePos)), + ) + await settle() + + expect(linkButton().getAttribute('aria-disabled')).toBe('true') + expect(linkButton().getAttribute('aria-pressed')).toBe('false') + expect(linkButton().hasAttribute('data-grace-area-trigger')).toBe(false) + linkButton().click() + pressCtrlK() + await settle() + expect(dialog()).toBeNull() + }) +}) + +describe('Ctrl+K', () => { + it('opens the dialog and prevents the browser shortcut', async () => { + await mountEditor() + const event = pressCtrlK() + await settle() + + expect(event.defaultPrevented).toBe(true) + expect(dialog()!.textContent).toContain('Insert link') + }) + + it('opens nothing when the editor is disabled', async () => { + wrapper = null + mockMedia(DESKTOP) + const Host = defineComponent({ + render: () => + h(UApp, null, () => + h(ArticleEditor, { modelValue: '

Hello

', articleId: 1, disabled: true }), + ), + }) + const host = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] } }) + await settle() + const vm = host.findComponent(ArticleEditor).vm as unknown as { + $: { setupState: { editorInstance: Editor } } + } + vm.$.setupState.editorInstance.view.dom.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }), + ) + await settle() + + expect(dialog()).toBeNull() + host.unmount() + }) + + it('opens nothing in Preview', async () => { + await mountEditor() + const preview = document.body.querySelector('[role="switch"]')! + preview.click() + await settle() + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }), + ) + await settle() + + expect(dialog()).toBeNull() + }) +}) + +describe.each([ + { + start: 'timber windows is selected', + selected: 'timber windows', + prefill: 'timber windows', + focusedId: 'url', + text: 'timber windows', + }, + { + start: 'timber windows is selected, then the text is edited', + selected: 'timber windows', + prefill: 'timber windows', + focusedId: 'url', + text: 'timber sash windows', + }, + { + start: 'nothing is selected', + selected: '', + prefill: '', + focusedId: 'text', + text: 'winter checklist', + }, +])('Creating a link: $start', ({ selected, prefill, focusedId, text }) => { + async function start() { + await mountEditor() + if (selected) { + select(selected) + } else { + editor().commands.setTextSelection(positionOf('before')) + } + await openDialog() + } + + it('opens the dialog titled Insert link with the right prefill and focus', async () => { + await start() + + expect(dialog()!.textContent).toContain('Insert link') + expect(field('text').value).toBe(prefill) + if (!selected) { + expect(field('text').placeholder).toBe('What the reader sees') + } + expect(document.activeElement).toBe(field(focusedId as 'text' | 'url')) + expect(toggle().getAttribute('data-state')).toBe('unchecked') + expect(buttonLabelled('Cancel')).toBeDefined() + expect(buttonLabelled('Remove link')).toBeUndefined() + }) + + it('inserts the anchor, returns focus to the editor, marks the draft dirty and sends nothing', async () => { + await start() + const requestsBefore = fetchMock.mock.calls.length + await fillAndSubmit(text, '/articles/winter-checklist') + + expect(editorHtml()).toContain(`${text}`) + expect(dialog()).toBeNull() + expect(document.activeElement).toBe(editor().view.dom) + expect(isDirty()).toBe(true) + expect(fetchMock.mock.calls.length).toBe(requestsBefore) + }) +}) + +describe('Editing and removing an existing link', () => { + async function openEdit(how: 'ctrl-k' | 'popover') { + await mountEditor(LINKED) + if (how === 'ctrl-k') { + placeCursorIn('timber windows') + await openDialog() + } else { + clickLink() + await settle() + await press('Edit') + } + } + + it.each(['ctrl-k', 'popover'] as const)( + 'opens the edit dialog from %s with the fields and the toggle prefilled', + async (how) => { + await openEdit(how) + + expect(dialog()!.textContent).toContain('Edit link') + expect(field('text').value).toBe('timber windows') + expect(field('url').value).toBe('/articles/winter-checklist') + expect(document.activeElement).toBe(field('url')) + expect(toggle().getAttribute('data-state')).toBe('checked') + expect(buttonLabelled('Remove link')).toBeDefined() + expect(buttonLabelled('Save link')).toBeDefined() + }, + ) + + it('changes the href of the same anchor and keeps the new tab', async () => { + await openEdit('popover') + type(field('url'), 'https://example.org/windows') + await settle() + await press('Save link') + + expect(editorHtml()).toContain( + 'timber windows', + ) + expect(editor().state.doc.textContent).toContain('timber windows') + expect(isDirty()).toBe(true) + }) + + it('leaves a Saved draft Saved when nothing changed', async () => { + await openEdit('ctrl-k') + await press('Save link') + + expect(dialog()).toBeNull() + expect(isDirty()).toBe(false) + expect(editorHtml()).toContain('href="/articles/winter-checklist"') + }) + + it('removes the link without a confirmation, and undo brings it back with its target', async () => { + await openEdit('ctrl-k') + await press('Remove link') + + expect(dialog()).toBeNull() + expect(document.body.querySelector('[role="alertdialog"]')).toBeNull() + expect(editorHtml()).not.toContain(' { + await mountEditor() + select('timber windows') + await openDialog() + type(field('url'), '/a') + toggle().click() + await settle() + await press('Insert link') + + expect(editorHtml()).toContain('target="_blank"') + expect(editorHtml()).toContain('rel="noopener noreferrer"') + + placeCursorIn('timber windows') + await openDialog() + toggle().click() + await settle() + await press('Save link') + + expect(editorHtml()).not.toContain('target=') + expect(editorHtml()).not.toContain('rel=') + }) +}) + +describe('Validation in the dialog', () => { + async function submitWith(text: string, url: string) { + await mountEditor() + select('timber windows') + await openDialog() + await fillAndSubmit(text, url) + } + + it.each([ + 'www.example', + 'example.com', + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + ' javascript:alert(1)', + 'javascript:alert(1)', + 'data:text/html;base64,PHA+', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'ftp://example.org/a', + 'tel:+33123456789', + '//evil.example', + 'page.html', + '../x', + 'http://', + 'https://exa mple.org', + ])('rejects the URL %s and inserts nothing', async (url) => { + await submitWith('timber windows', url) + + expect(dialog()!.textContent).toContain(URL_ERROR) + expect(editorHtml()).not.toContain('x`) + }) +}) + +describe('Editing a legacy URL', () => { + const LEGACY = '

See call us now.

' + + it.each(['tel:+33123456789'])('saves a text-only edit with the unchanged URL %s', async () => { + await mountEditor(LEGACY) + placeCursorIn('call us') + await openDialog() + type(field('text'), 'phone us') + await flushPromises() + await press('Save link') + + expect(dialog()).toBeNull() + expect(editorHtml()).toContain('href="tel:+33123456789"') + expect(editorHtml()).toContain('phone us') + }) + + it('rejects a changed URL that breaks the strict rule', async () => { + await mountEditor(LEGACY) + placeCursorIn('call us') + await openDialog() + type(field('url'), 'tel:+33999') + await flushPromises() + await press('Save link') + + expect(dialog()!.textContent).toContain(URL_ERROR) + expect(editorHtml()).toContain('href="tel:+33123456789"') + }) +}) + +describe('Preview neutralises unsafe links', () => { + it.each(['javascript:alert(1)', 'data:text/html,b', 'vbscript:msgbox(1)'])( + 'renders no live %s link', + async (href) => { + await mountEditor( + `

Before x after

ok

`, + ) + document.body.querySelector('[role="switch"]')!.click() + await settle() + const preview = document.body.querySelector('[data-testid="article-preview"]')! + + expect(preview.innerHTML).not.toMatch(/href="(javascript|data|vbscript)/i) + expect(preview.textContent).toContain('Before x after') + expect(preview.querySelector('a[href="/ok"]')).not.toBeNull() + }, + ) +}) + +describe('Long URL', () => { + it('keeps the URL on one line with the full value in its title and Save link reachable', async () => { + await mountEditor(`

See planning guidance now.

`) + placeCursorIn('planning guidance') + await openDialog() + + expect(field('url').value).toBe(LONG_URL) + expect(field('url').getAttribute('title')).toBe(LONG_URL) + expect(field('url').tagName).toBe('INPUT') + expect(buttonLabelled('Save link')!.disabled).toBe(false) + expect(dialog()!.className).not.toContain('overflow-x') + }) +}) + +describe('Leaving the dialog without saving', () => { + async function openWithChanges() { + await mountEditor() + select('timber windows') + await openDialog() + type(field('text'), 'changed') + type(field('url'), '/changed') + await settle() + } + + function expectUntouched() { + expect(dialog()).toBeNull() + expect(editorHtml()).not.toContain(' { + await openWithChanges() + await press('Cancel') + expectUntouched() + }) + + it('on Esc', async () => { + await openWithChanges() + field('text').dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ) + await settle() + expectUntouched() + }) + + it('on a click outside', async () => { + await openWithChanges() + const overlay = document.body.querySelector( + '[data-state="open"][data-slot="overlay"]', + ) + overlay!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + overlay!.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + overlay!.click() + await settle() + expectUntouched() + }) +}) + +describe('Dialog or sheet by viewport, popover or sheet by input', () => { + it('is a dialog at 1440 px wide', async () => { + await mountEditor(LINKED, DESKTOP) + await openDialog() + + expect(dialog()!.getAttribute('data-presentation')).toBe('dialog') + }) + + it('is a bottom sheet at 390 px wide with the same fields and footer', async () => { + await mountEditor(LINKED, PHONE) + select('timber windows') + await openDialog() + + expect(dialog()!.getAttribute('data-presentation')).toBe('sheet') + expect(field('text')).not.toBeNull() + expect(field('url')).not.toBeNull() + expect(buttonLabelled('Cancel')).toBeDefined() + }) + + it('follows the viewport, not the pointer, for the dialog', async () => { + await mountEditor(LINKED, { width: 1440, coarse: true }) + select('timber windows') + await openDialog() + + expect(dialog()!.getAttribute('data-presentation')).toBe('dialog') + }) + + it('opens a 288 px popover under the link for a fine pointer, with the link button active', async () => { + await mountEditor(LINKED_SAME_TAB) + clickLink() + await settle() + + const popover = document.body.querySelector( + '[role="dialog"][aria-label="Link"]', + )! + expect(popover.textContent).toContain('/articles/winter-checklist') + expect(buttonLabelled('Edit')).toBeDefined() + expect(buttonLabelled('Open')).toBeDefined() + expect(buttonLabelled('Remove')).toBeDefined() + expect(popover.closest('[data-slot="content"]')!.className).toContain('w-72') + expect(linkButton().getAttribute('aria-pressed')).toBe('true') + }) + + it('closes the popover with Esc and keeps the cursor in the link', async () => { + await mountEditor(LINKED_SAME_TAB) + clickLink() + await settle() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await settle() + + expect(document.body.querySelector('[aria-label="Link"]')).toBeNull() + expect(linkButton().getAttribute('aria-pressed')).toBe('true') + }) + + it('opens the popover on Enter from the link element', async () => { + await mountEditor(LINKED_SAME_TAB) + editor() + .view.dom.querySelector('a')! + .dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ) + await settle() + + expect(document.body.querySelector('[aria-label="Link"]')).not.toBeNull() + }) + + it('opens a sheet with three rows for a coarse pointer', async () => { + await mountEditor(LINKED_SAME_TAB, PHONE) + clickLink() + await settle() + + const sheet = document.body.querySelector( + '[role="dialog"][aria-label="Link"]', + )! + expect(sheet.textContent).toContain('timber windows') + expect(sheet.textContent).toContain('/articles/winter-checklist') + for (const label of ['Edit link', 'Open link', 'Remove link']) { + expect(buttonLabelled(label)!.className).toContain('h-12') + } + }) + + it('opens the URL in a new tab without opener or referrer on Open', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + await mountEditor(LINKED_SAME_TAB) + clickLink() + await settle() + await press('Open') + + expect(open).toHaveBeenCalledWith( + '/articles/winter-checklist', + '_blank', + 'noopener,noreferrer', + ) + open.mockRestore() + }) + + it.each([ + ['Remove', DESKTOP], + ['Remove link', PHONE], + ])('removes the link with %s, undoably and without a confirmation', async (label, media) => { + await mountEditor(LINKED_SAME_TAB, media) + clickLink() + await settle() + await press(label) + + expect(editorHtml()).not.toContain(' { + function toolbar() { + return document.body.querySelector('.article-toolbar')! + } + + function layout(scrollLeft: number) { + const element = toolbar() + Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 }) + Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 }) + element.scrollLeft = scrollLeft + element.dispatchEvent(new Event('scroll')) + } + + it('shows no fade when nothing is hidden', async () => { + await mountEditor() + + expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).toBeNull() + expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).toBeNull() + }) + + it('fades only the side that has hidden buttons', async () => { + await mountEditor(SENTENCE, PHONE) + layout(0) + await settle() + expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).toBeNull() + expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).not.toBeNull() + + layout(100) + await settle() + expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).not.toBeNull() + expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).not.toBeNull() + + layout(710) + await settle() + expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).not.toBeNull() + expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).toBeNull() + }) + + it('keeps the toolbar in one scrolling row', async () => { + await mountEditor(SENTENCE, PHONE) + + expect(toolbar().className).toContain('overflow-x-auto') + expect(toolbar().className).not.toContain('flex-wrap') + }) +}) + +describe('Selections that straddle a link or span blocks', () => { + const BODY = + '

We also check the timber windows before

any tiling starts.

' + + it.each(['check the timber', 'windows before'])( + 'covers the selection "%s" with the new link', + async (selection) => { + await mountEditor(BODY) + const [first, last] = [selection.split(' ')[0]!, selection.split(' ').at(-1)!] + const from = positionOf(first) + editor().commands.setTextSelection({ from, to: positionOf(last) + last.length }) + const before = editor().state.doc.textContent + await openDialog() + type(field('url'), '/b') + await settle() + await press('Insert link') + + expect(editor().state.doc.textContent).toBe(before) + expect(editorHtml()).toContain('href="/b"') + }, + ) + + it('spans two paragraphs without losing text', async () => { + await mountEditor(BODY) + editor().commands.setTextSelection({ + from: positionOf('before'), + to: positionOf('any tiling') + 'any tiling'.length, + }) + const before = editor().state.doc.textContent + await openDialog() + type(field('url'), '/b') + await settle() + await press('Insert link') + + expect(editor().state.doc.textContent).toBe(before) + expect(editorHtml()).toContain('before') + expect(editorHtml()).toContain('any tiling') + }) +}) + +describe('Links survive the HTML body format', () => { + it('sends the anchor with href, target and rel on save and restores the toggle on reload', async () => { + await mountEditor() + select('timber windows') + await openDialog() + type(field('url'), '/articles/winter-checklist') + toggle().click() + await settle() + await press('Insert link') + document.body.querySelector('[data-testid="save-button"]')!.click() + await settle() + + const [sent] = patchBodies() + expect(sent).toContain('href="/articles/winter-checklist"') + expect(sent).toContain('target="_blank"') + expect(sent).toContain('rel="noopener noreferrer"') + + wrapper!.unmount() + await mountEditor(sent) + placeCursorIn('timber windows') + await openDialog() + expect(toggle().getAttribute('data-state')).toBe('checked') + }) + + it('sends neither target nor rel without the toggle', async () => { + await mountEditor() + select('timber windows') + await openDialog() + await fillAndSubmit('timber windows', '/articles/winter-checklist') + document.body.querySelector('[data-testid="save-button"]')!.click() + await settle() + + const [sent] = patchBodies() + expect(sent).toContain('timber windows') + expect(sent).not.toContain('target') + expect(sent).not.toContain('rel=') + }) +}) + +describe.each(['javascript:alert(1)', 'data:text/html,', 'vbscript:msgbox(1)'])( + 'An unsafe href already in a body: %s', + (href) => { + const unsafe = `

Before x after

` + + it('keeps the text without a link, leaves the button off, and stays clean without a request', async () => { + await mountEditor(unsafe) + const requests = fetchMock.mock.calls.length + + expect(editorHtml()).not.toContain(' { + await mountEditor(unsafe) + editor().commands.insertContentAt(1, 'N') + await settle() + document.body.querySelector('[data-testid="save-button"]')!.click() + await settle() + + const [sent] = patchBodies() + expect(sent).not.toMatch(/href="(javascript|data|vbscript)/i) + expect(sent).not.toContain(' { + await mountEditor('

Hello

') + editor().view.pasteHTML( + `
x`, + new Event('paste') as ClipboardEvent, + ) + await settle() + + expect(editorHtml()).not.toMatch(/href="(javascript|data|vbscript)/i) + expect(editorHtml()).toContain('x') + expect(isDirty()).toBe(true) + }) + }, +) + +describe('French completeness', () => { + const NEW_STRINGS = [ + 'Insert link', + 'Insert link (Ctrl+K)', + 'Insert link (⌘K)', + 'Edit link', + 'Save link', + 'Remove link', + 'Open link', + 'Link', + 'Link text', + 'What the reader sees', + 'https://example.com, /page, mailto: or #anchor', + 'Open in new tab', + 'Links open in the same tab unless you turn on a new tab.', + TEXT_ERROR, + URL_ERROR, + 'Open', + 'Remove', + ] + + it.each(NEW_STRINGS)('has a French entry for %s', (key) => { + const entry = messages[key as keyof typeof messages] + expect(typeof entry).toBe('string') + expect(entry).not.toBe('') + expect(entry).not.toBe(key) + }) + + it('shows the French dialog title and both errors', async () => { + await mountEditor() + setLocale('fr') + select('timber windows') + await openDialog() + expect(dialog()!.textContent).toContain('Insérer un lien') + await fillAndSubmit('', 'www.example', 'Insérer un lien') + expect(dialog()!.textContent).toContain('Saisissez le texte du lien.') + expect(dialog()!.textContent).toContain('Saisissez une URL valide.') + }) +}) diff --git a/web/src/composables/__tests__/useScrollEdges.spec.ts b/web/src/composables/__tests__/useScrollEdges.spec.ts new file mode 100644 index 0000000..4c12683 --- /dev/null +++ b/web/src/composables/__tests__/useScrollEdges.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' +import { mount } from '@vue/test-utils' +import { useScrollEdges } from '../useScrollEdges' + +function mountEdges() { + const element = document.createElement('div') + let edges!: ReturnType + const Host = defineComponent({ + setup() { + edges = useScrollEdges(ref(element)) + return () => h('span') + }, + }) + mount(Host) + return { element, edges } +} + +function layout(element: HTMLElement, scrollLeft: number) { + Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 }) + Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 }) + element.scrollLeft = scrollLeft + element.dispatchEvent(new Event('scroll')) +} + +describe('useScrollEdges', () => { + it('hides nothing at the start except the end', async () => { + const { element, edges } = mountEdges() + layout(element, 0) + await nextTick() + + expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([false, true]) + }) + + it('hides both sides in the middle', async () => { + const { element, edges } = mountEdges() + layout(element, 100) + await nextTick() + + expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([true, true]) + }) + + it('hides only the start at the end, within one pixel', async () => { + const { element, edges } = mountEdges() + layout(element, 710) + await nextTick() + expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([true, false]) + + layout(element, 709) + await nextTick() + expect(edges.hiddenAfter.value).toBe(false) + }) + + it('shows no fade when the content fits', async () => { + const { element, edges } = mountEdges() + Object.defineProperty(element, 'clientWidth', { configurable: true, value: 500 }) + Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 500 }) + element.dispatchEvent(new Event('scroll')) + await nextTick() + + expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([false, false]) + }) +}) diff --git a/web/src/composables/useArticleLink.ts b/web/src/composables/useArticleLink.ts new file mode 100644 index 0000000..83e73dc --- /dev/null +++ b/web/src/composables/useArticleLink.ts @@ -0,0 +1,174 @@ +import { ref, watch } from 'vue' +import type { Ref } from 'vue' +import { useMediaQuery } from '@vueuse/core' +import type { EditorToolbarItem } from '@nuxt/ui' +import type { Editor } from '@tiptap/core' +import { t } from '@/i18n' +import { + applyLinkEdit, + describeLinkDialog, + imageIsSelected, + linkRangeAtPos, + linkTouchesSelection, + removeLinkInRange, + textOfRange, +} from '@/lib/articleLink' +import type { LinkDialogDraft, LinkEdit, LinkPopoverTarget, TextRange } from '@/lib/articleLink' + +const LINK_TOOLTIP_DELAY_MS = 400 +const COARSE_POINTER_QUERY = '(pointer: coarse)' +const MAC_PLATFORM = /mac|iphone|ipad|ipod/i +const LINK_BUTTON_FOCUS_CLASS = + 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary' +const LINK_BUTTON_CLASS = `text-toned hover:text-highlighted ${LINK_BUTTON_FOCUS_CLASS}` +const LINK_BUTTON_ACTIVE_CLASS = `bg-(--rc-sand) text-(--rc-on-sand) hover:bg-(--rc-sand) hover:text-(--rc-on-sand) ${LINK_BUTTON_FOCUS_CLASS}` + +export type LinkToolbarItem = EditorToolbarItem & { + 'aria-label': string + 'aria-pressed': 'true' | 'false' + 'aria-disabled'?: 'true' +} + +interface LinkPopoverState { + element: HTMLElement | null + text: string + href: string + range: TextRange +} + +function isMacPlatform() { + return MAC_PLATFORM.test(navigator.platform) +} + +export function useArticleLink(editorRef: Ref, isDisabled: () => boolean) { + const isTouch = useMediaQuery(COARSE_POINTER_QUERY) + const dialogOpen = ref(false) + const dialog = ref({ + mode: 'insert', + text: '', + href: '', + newTab: false, + range: { from: 0, to: 0 }, + }) + const popoverOpen = ref(false) + const popover = ref({ + element: null, + text: '', + href: '', + range: { from: 0, to: 0 }, + }) + + function editableEditor(): Editor | null { + const editor = editorRef.value + if (!editor || isDisabled() || !editor.isEditable || imageIsSelected(editor)) { + return null + } + return editor + } + + function openDialog() { + const editor = editableEditor() + if (!editor) { + return + } + popoverOpen.value = false + dialog.value = describeLinkDialog(editor) + dialogOpen.value = true + } + + function openPopover(target: LinkPopoverTarget) { + const editor = editableEditor() + const range = editor ? linkRangeAtPos(editor, target.pos) : null + if (!editor || !range) { + return + } + popover.value = { + element: target.element, + text: textOfRange(editor, range), + href: range.attrs.href, + range: { from: range.from, to: range.to }, + } + popoverOpen.value = true + } + + function apply(edit: LinkEdit) { + const editor = editorRef.value + dialogOpen.value = false + if (editor) { + applyLinkEdit(editor, dialog.value, edit) + } + } + + function removeFromDialog() { + dialogOpen.value = false + if (editorRef.value) { + removeLinkInRange(editorRef.value, dialog.value.range) + } + } + + function editFromPopover() { + editorRef.value?.commands.setTextSelection(popover.value.range) + openDialog() + } + + function openFromPopover() { + window.open(popover.value.href, '_blank', 'noopener,noreferrer') + } + + function removeFromPopover() { + if (editorRef.value) { + removeLinkInRange(editorRef.value, popover.value.range) + } + } + + watch(dialogOpen, (isOpen, wasOpen) => { + if (wasOpen && !isOpen) { + editorRef.value?.commands.focus() + } + }) + + watch(popoverOpen, (isOpen, wasOpen) => { + if (wasOpen && !isOpen && !dialogOpen.value) { + editorRef.value?.commands.focus() + } + }) + + function toolbarItem(editor: Editor): LinkToolbarItem { + const blocked = imageIsSelected(editor) + const active = !blocked && linkTouchesSelection(editor) + return { + icon: 'i-lucide-link', + class: active ? LINK_BUTTON_ACTIVE_CLASS : LINK_BUTTON_CLASS, + 'aria-label': t('Insert link'), + 'aria-pressed': active ? 'true' : 'false', + 'aria-disabled': blocked ? 'true' : undefined, + tooltip: + blocked || isTouch.value + ? undefined + : { + delayDuration: LINK_TOOLTIP_DELAY_MS, + get text() { + return isMacPlatform() + ? t('Insert link (⌘K)') + : t('Insert link (Ctrl+K)') + }, + }, + onClick: () => openDialog(), + } + } + + return { + dialogOpen, + dialog, + popoverOpen, + popover, + openDialog, + openPopover, + apply, + removeFromDialog, + editFromPopover, + openFromPopover, + removeFromPopover, + toolbarItem, + } +} diff --git a/web/src/composables/useScrollEdges.ts b/web/src/composables/useScrollEdges.ts new file mode 100644 index 0000000..1688937 --- /dev/null +++ b/web/src/composables/useScrollEdges.ts @@ -0,0 +1,47 @@ +import { onBeforeUnmount, ref, watch } from 'vue' +import type { Ref } from 'vue' + +const EDGE_TOLERANCE_PX = 1 + +export function useScrollEdges(target: Ref) { + const hiddenBefore = ref(false) + const hiddenAfter = ref(false) + let observer: ResizeObserver | null = null + let attached: HTMLElement | null = null + + function measure() { + const element = attached + if (!element) { + return + } + hiddenBefore.value = element.scrollLeft > EDGE_TOLERANCE_PX + hiddenAfter.value = + element.scrollLeft + element.clientWidth < element.scrollWidth - EDGE_TOLERANCE_PX + } + + function detach() { + attached?.removeEventListener('scroll', measure) + observer?.disconnect() + observer = null + attached = null + } + + function attach(element: HTMLElement | null) { + detach() + if (!element) { + return + } + attached = element + element.addEventListener('scroll', measure, { passive: true }) + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(measure) + observer.observe(element) + } + measure() + } + + watch(target, attach, { immediate: true, flush: 'post' }) + onBeforeUnmount(detach) + + return { hiddenBefore, hiddenAfter } +} diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index de2b564..697a307 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -336,6 +336,27 @@ export const messages = { Indent: 'Augmenter le retrait', Outdent: 'Réduire le retrait', 'Clear formatting': 'Effacer la mise en forme', + 'More actions': 'Plus d’actions', + 'Insert link': 'Insérer un lien', + 'Insert link (Ctrl+K)': 'Insérer un lien (Ctrl+K)', + 'Insert link (⌘K)': 'Insérer un lien (⌘K)', + 'Edit link': 'Modifier le lien', + 'Save link': 'Enregistrer le lien', + 'Remove link': 'Retirer le lien', + 'Open link': 'Ouvrir le lien', + Link: 'Lien', + 'Link text': 'Texte du lien', + 'What the reader sees': 'Ce que voit le lecteur', + 'https://example.com, /page, mailto: or #anchor': + 'https://exemple.com, /page, mailto: ou #ancre', + 'Open in new tab': 'Ouvrir dans un nouvel onglet', + 'Links open in the same tab unless you turn on a new tab.': + 'Les liens s’ouvrent dans le même onglet, sauf si vous activez un nouvel onglet.', + 'Enter the link text.': 'Saisissez le texte du lien.', + 'Enter a valid URL. Use http(s), mailto, a path or #anchor.': + 'Saisissez une URL valide. Utilisez http(s), mailto, un chemin ou #ancre.', + Open: 'Ouvrir', + Remove: 'Retirer', Image: 'Image', 'Add an image': 'Ajouter une image', 'Remove image': 'Retirer l’image', diff --git a/web/src/lib/__tests__/articleLinkUrl.spec.ts b/web/src/lib/__tests__/articleLinkUrl.spec.ts new file mode 100644 index 0000000..fe946bd --- /dev/null +++ b/web/src/lib/__tests__/articleLinkUrl.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { isStoredHrefSafe, neutralizeUnsafeLinks, normalizeLinkUrl } from '../articleLinkUrl' + +describe('normalizeLinkUrl accepts', () => { + it.each([ + ['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'], + [' /page ', '/page'], + ])('%s', (raw, href) => { + expect(normalizeLinkUrl(raw)).toBe(href) + }) +}) + +describe('normalizeLinkUrl rejects', () => { + it.each([ + 'www.example', + 'example.com', + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + ' javascript:alert(1)', + 'javascript:alert(1)', + '\u0001javascript:alert(1)', + 'java\tscript:alert(1)', + 'data:text/html;base64,PHA+', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'ftp://example.org/a', + 'tel:+33123456789', + '//evil.example', + '/\\evil.example', + 'page.html', + '../x', + 'http://', + 'https://exa mple.org', + '', + '#', + 'https://exa\u0001mple.org', + '/\u0001evil', + '#\u0001', + '/a\u007fb', + '/a\u0085b', + ])('%j', (raw) => { + expect(normalizeLinkUrl(raw)).toBeNull() + }) +}) + +describe('isStoredHrefSafe', () => { + it.each([ + '/a', + '#a', + 'page.html', + '../x', + 'https://a.example', + 'mailto:a@b.example', + 'tel:+3312', + ])('keeps %s', (href) => { + expect(isStoredHrefSafe(href)).toBe(true) + }) + + it.each([ + 'javascript:alert(1)', + ' JavaScript:alert(1)', + 'java\nscript:alert(1)', + '\u0001javascript:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + '', + ])('neutralises %s', (href) => { + expect(isStoredHrefSafe(href)).toBe(false) + }) +}) + +describe('neutralizeUnsafeLinks', () => { + it('unwraps unsafe anchors and keeps their text', () => { + const html = neutralizeUnsafeLinks( + '

a x y w

', + ) + + expect(html).toBe('

a x y w

') + }) + + it('keeps safe anchors untouched', () => { + const html = neutralizeUnsafeLinks('

x

') + + expect(html).toBe('

x

') + }) +}) diff --git a/web/src/lib/articleLink.ts b/web/src/lib/articleLink.ts new file mode 100644 index 0000000..77fcd76 --- /dev/null +++ b/web/src/lib/articleLink.ts @@ -0,0 +1,273 @@ +import { getMarkRange } from '@tiptap/core' +import type { Editor } from '@tiptap/core' +import Link from '@tiptap/extension-link' +import { Plugin } from '@tiptap/pm/state' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { isStoredHrefSafe } from '@/lib/articleLinkUrl' + +export const NEW_TAB_TARGET = '_blank' +export const NEW_TAB_REL = 'noopener noreferrer' +export const LINK_FOCUS_CLASS = 'article-link-focus' +const INSIDE_FIRST_CHARACTER = 1 + +export interface LinkPopoverTarget { + element: HTMLElement + pos: number +} + +export interface ArticleLinkOptions { + onRequestDialog: () => void + onRequestPopover: (target: LinkPopoverTarget) => void +} + +export interface LinkAttributes { + href: string + target: string | null + rel: string | null +} + +export interface LinkRange { + from: number + to: number + attrs: LinkAttributes +} + +export function linkAttributes(href: string, newTab: boolean): LinkAttributes { + return { + href, + target: newTab ? NEW_TAB_TARGET : null, + rel: newTab ? NEW_TAB_REL : null, + } +} + +function anchorFrom(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) { + return null + } + return target.closest('a[href]') +} + +function linkMarkAt(doc: ProseMirrorNode, pos: number, editor: Pick) { + const type = editor.schema.marks.link + if (!type) { + return null + } + const range = getMarkRange(doc.resolve(pos), type) + if (!range) { + return null + } + const node = doc.nodeAt(range.from) + const mark = node?.marks.find((candidate) => candidate.type === type) + if (!mark) { + return null + } + return { + from: range.from, + to: range.to, + attrs: { + href: String(mark.attrs.href ?? ''), + target: (mark.attrs.target as string | null) ?? null, + rel: (mark.attrs.rel as string | null) ?? null, + }, + } +} + +export function linkRangeAtPos(editor: Editor, pos: number): LinkRange | null { + return linkMarkAt(editor.state.doc, pos, editor) +} + +export function linkRangeAtSelection(editor: Editor): LinkRange | null { + const { from, to, empty } = editor.state.selection + const found = linkMarkAt(editor.state.doc, empty ? from : from + INSIDE_FIRST_CHARACTER, editor) + if (!found || found.to < to) { + return null + } + return found +} + +export function linkTouchesSelection(editor: Editor): boolean { + const { from, to, empty } = editor.state.selection + const type = editor.schema.marks.link + if (!type) { + return false + } + if (empty) { + return editor.isActive('link') + } + return editor.state.doc.rangeHasMark(from, to, type) +} + +export function imageIsSelected(editor: Editor): boolean { + return editor.isActive('image') +} + +export const ArticleLink = Link.extend({ + addOptions() { + return { + ...this.parent?.(), + openOnClick: false, + HTMLAttributes: { target: null, rel: null, class: null }, + isAllowedUri: (url: string) => isStoredHrefSafe(url), + onRequestDialog: () => {}, + onRequestPopover: () => {}, + } + }, + addAttributes() { + return { + ...this.parent?.(), + target: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('target') === NEW_TAB_TARGET ? NEW_TAB_TARGET : null, + }, + rel: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('target') === NEW_TAB_TARGET ? NEW_TAB_REL : null, + }, + } + }, + parseHTML() { + return [ + { + tag: 'a[href]', + getAttrs: (element) => + isStoredHrefSafe((element as HTMLElement).getAttribute('href') ?? '') + ? null + : false, + }, + ] + }, + addKeyboardShortcuts() { + return { + 'Mod-k': () => { + this.options.onRequestDialog() + return true + }, + } + }, + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + props: { + decorations: (state) => { + const type = state.schema.marks.link + if (!type || !state.selection.empty) { + return null + } + const range = getMarkRange(state.selection.$from, type) + if (!range) { + return null + } + return DecorationSet.create(state.doc, [ + Decoration.inline(range.from, range.to, { class: LINK_FOCUS_CLASS }), + ]) + }, + handleClick: (view, pos, event) => { + const element = anchorFrom(event.target) + if (!element || !view.editable) { + return false + } + event.preventDefault() + this.options.onRequestPopover({ element, pos }) + return false + }, + handleKeyDown: (view, event) => { + const element = anchorFrom(event.target) + if (event.key !== 'Enter' || !element || !view.editable) { + return false + } + event.preventDefault() + this.options.onRequestPopover({ element, pos: view.posAtDOM(element, 0) }) + return true + }, + }, + }), + ] + }, +}) + +export interface TextRange { + from: number + to: number +} + +export interface LinkDialogDraft { + mode: 'insert' | 'edit' + text: string + href: string + newTab: boolean + range: TextRange +} + +export interface LinkEdit { + text: string + href: string + newTab: boolean +} + +export function textOfRange(editor: Editor, range: TextRange) { + return editor.state.doc.textBetween(range.from, range.to, ' ') +} + +export function describeLinkDialog(editor: Editor): LinkDialogDraft { + const existing = linkRangeAtSelection(editor) + if (existing) { + return { + mode: 'edit', + text: textOfRange(editor, existing), + href: existing.attrs.href, + newTab: existing.attrs.target === NEW_TAB_TARGET, + range: { from: existing.from, to: existing.to }, + } + } + const { from, to } = editor.state.selection + return { + mode: 'insert', + text: textOfRange(editor, { from, to }), + href: '', + newTab: false, + range: { from, to }, + } +} + +export function isUnchangedLinkEdit(draft: LinkDialogDraft, edit: LinkEdit) { + return ( + draft.mode === 'edit' && + edit.text === draft.text.trim() && + edit.href === draft.href && + edit.newTab === draft.newTab + ) +} + +export function applyLinkEdit(editor: Editor, draft: LinkDialogDraft, edit: LinkEdit) { + if (isUnchangedLinkEdit(draft, edit)) { + editor.commands.focus() + return + } + const attrs = linkAttributes(edit.href, edit.newTab) + const chain = editor.chain().focus().setTextSelection(draft.range) + if (edit.text === draft.text.trim()) { + chain.setMark('link', attrs) + } else { + const at = draft.range.from === draft.range.to ? draft.range.from : draft.range + chain.insertContentAt(at, { + type: 'text', + text: edit.text, + marks: [{ type: 'link', attrs }], + }) + } + chain.run() +} + +export function removeLinkInRange(editor: Editor, range: TextRange) { + editor + .chain() + .focus() + .setTextSelection(range) + .unsetMark('link') + .setTextSelection(range.to) + .run() +} diff --git a/web/src/lib/articleLinkUrl.ts b/web/src/lib/articleLinkUrl.ts new file mode 100644 index 0000000..239e388 --- /dev/null +++ b/web/src/lib/articleLinkUrl.ts @@ -0,0 +1,39 @@ +const WEB_URL = /^https?:\/\/[^\s\\/?#]+(?:[/?#]\S*)?$/i +const MAIL_URL = /^mailto:[^\s@/?#]+@[^\s@]+$/i +const SITE_PATH = /^\/(?![/\\])\S*$/ +const ANCHOR = /^#\S+$/ +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g +const HAS_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/ +const SCHEME = /^([a-z][a-z0-9+.-]*):/i +const STORED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'ftp'] + +export function normalizeLinkUrl(raw: string): string | null { + const url = raw.trim() + if (HAS_CONTROL_CHARACTER.test(url)) { + return null + } + if (WEB_URL.test(url) || MAIL_URL.test(url) || SITE_PATH.test(url) || ANCHOR.test(url)) { + return url + } + return null +} + +export function isStoredHrefSafe(raw: string): boolean { + const visible = raw.replace(CONTROL_CHARACTERS, '').trim().replace(/\s/g, '') + const scheme = SCHEME.exec(visible) + if (scheme === null) { + return visible !== '' + } + return STORED_SCHEMES.includes(scheme[1]!.toLowerCase()) +} + +export function neutralizeUnsafeLinks(html: string): string { + const document = new DOMParser().parseFromString(html, 'text/html') + for (const anchor of document.body.querySelectorAll('a')) { + if (isStoredHrefSafe(anchor.getAttribute('href') ?? '')) { + continue + } + anchor.replaceWith(...anchor.childNodes) + } + return document.body.innerHTML +} diff --git a/web/src/views/EditorView.vue b/web/src/views/EditorView.vue index 7764a9e..1dcb227 100644 --- a/web/src/views/EditorView.vue +++ b/web/src/views/EditorView.vue @@ -11,6 +11,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue' import ArticleDetails from '@/components/ArticleDetails.vue' import SaveStatus from '@/components/SaveStatus.vue' import PublishControl from '@/components/PublishControl.vue' +import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl' import EditorStatusBar from '@/components/EditorStatusBar.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue' import ImageUploader from '@/components/ImageUploader.vue' @@ -90,6 +91,25 @@ async function loadArticle() { } const counts = computed(() => bodyCounts(draft.body.value)) +const previewBody = computed(() => neutralizeUnsafeLinks(draft.body.value)) +const countText = computed(() => + t('{0} words · {1} characters', [ + counts.value.words.toLocaleString('en-US'), + counts.value.characters.toLocaleString('en-US'), + ]), +) +const overflowItems = computed(() => [ + [ + { + icon: 'i-lucide-trash-2', + color: 'error' as const, + label: t('Delete article'), + onSelect: () => { + handleDelete() + }, + }, + ], +]) const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded') @@ -403,7 +423,24 @@ onBeforeRouteLeave(async () => {