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
+2860 -9

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.
+10
View File
@@ -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%;
+75 -3
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import LinkModal from '@/components/LinkModal.vue'
import LinkPopover from '@/components/LinkPopover.vue'
import { useArticleLink } from '@/composables/useArticleLink'
import { useScrollEdges } from '@/composables/useScrollEdges'
import { ArticleLink } from '@/lib/articleLink'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleTextAlign } from '@/lib/articleAlign'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
@@ -25,10 +30,18 @@ const emit = defineEmits<{
'request-remove-image': [imageId: number]
}>()
const editorInstance = ref<Editor | null>(null)
const editorInstance = shallowRef<Editor | null>(null)
const toolbarRef = ref<{ $el: HTMLElement } | null>(null)
const toolbarElement = computed(() => toolbarRef.value?.$el ?? null)
const { hiddenBefore, hiddenAfter } = useScrollEdges(toolbarElement)
const link = useArticleLink(editorInstance, () => props.disabled)
const extensions = [
ArticleIndent,
ArticleTextAlign,
ArticleLink.configure({
onRequestDialog: () => link.openDialog(),
onRequestPopover: (target) => link.openPopover(target),
}),
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
@@ -276,6 +289,7 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
},
],
[
link.toolbarItem(editor),
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
@@ -342,6 +356,7 @@ defineExpose({
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
:starter-kit="{ link: false }"
:image="false"
content-type="html"
:editable="!props.disabled"
@@ -351,18 +366,51 @@ defineExpose({
>
<span class="hidden">{{ captureEditor(editor) }}</span>
<UEditorDragHandle :editor="editor" />
<div class="relative col-span-full row-start-1 min-w-0">
<UEditorToolbar
ref="toolbarRef"
:editor="editor"
:items="buildItems(editor)"
layout="fixed"
class="col-span-full row-start-1 min-w-0 overflow-x-auto border-b border-default bg-default px-6 py-1.5 [&>*]:shrink-0"
class="article-toolbar min-w-0 overflow-x-auto border-b border-default bg-default px-2 py-1.5 sm:px-6 [&>*]:shrink-0"
/>
<span
v-if="hiddenBefore"
data-testid="toolbar-fade-start"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 w-6 bg-linear-to-r from-default to-transparent"
/>
<span
v-if="hiddenAfter"
data-testid="toolbar-fade-end"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 w-8 bg-linear-to-l from-default to-transparent"
/>
</div>
<div class="col-start-1 row-start-3">
<slot name="footer" />
</div>
<div class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block">
<slot name="aside" />
</div>
<LinkModal
v-model:open="link.dialogOpen.value"
:mode="link.dialog.value.mode"
:text="link.dialog.value.text"
:href="link.dialog.value.href"
:new-tab="link.dialog.value.newTab"
@submit="link.apply"
@remove="link.removeFromDialog"
/>
<LinkPopover
v-model:open="link.popoverOpen.value"
:href="link.popover.value.href"
:text="link.popover.value.text"
:reference="link.popover.value.element"
@edit="link.editFromPopover"
@open-link="link.openFromPopover"
@remove="link.removeFromPopover"
/>
</UEditor>
</template>
@@ -380,6 +428,30 @@ defineExpose({
padding: 2.5rem 3rem;
outline: none;
}
.article-editor :deep(.ProseMirror ul),
.article-editor :deep(.ProseMirror ol) {
padding-left: 1.75rem;
}
.article-editor :deep(.ProseMirror li) {
margin: 0;
padding-left: 0;
}
.article-editor :deep(.ProseMirror h2) {
margin-top: 2rem;
}
.article-toolbar {
scrollbar-width: none;
}
.article-toolbar::-webkit-scrollbar {
display: none;
}
@media (max-width: 39.999rem) {
.article-toolbar :deep(button) {
min-width: 2.5rem;
min-height: 2.5rem;
justify-content: center;
}
}
@media (max-width: 47.999rem) {
.article-editor :deep(.ProseMirror) {
padding: 1.5rem 1rem;
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { normalizeLinkUrl } from '@/lib/articleLinkUrl'
const SHEET_QUERY = '(max-width: 39.999rem)'
const TEXT_PLACEHOLDER = 'What the reader sees'
const URL_PLACEHOLDER = 'https://example.com, /page, mailto: or #anchor'
const DIALOG_CONTENT_CLASS =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0'
const SHEET_CONTENT_CLASS =
'divide-y-0 shadow-(--shadow-modal) ring-0 !inset-x-0 !top-auto !bottom-0 !left-0 !w-auto !max-w-none !translate-x-0 !translate-y-0 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-accented'
export interface LinkModalSubmit {
text: string
href: string
newTab: boolean
}
const props = defineProps<{
mode: 'insert' | 'edit'
text: string
href: string
newTab: boolean
}>()
const emit = defineEmits<{
submit: [value: LinkModalSubmit]
remove: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isSheet = useMediaQuery(SHEET_QUERY)
const textId = useId()
const urlId = useId()
const formId = useId()
const textValue = ref('')
const urlValue = ref('')
const newTabValue = ref(false)
const textError = ref<MessageKey | ''>('')
const urlError = ref<MessageKey | ''>('')
const focusedField = ref<'text' | 'url' | ''>('')
const editing = computed(() => props.mode === 'edit')
const title = computed(() => (editing.value ? t('Edit link') : t('Insert link')))
const confirmLabel = computed(() => (editing.value ? t('Save link') : t('Insert link')))
const presentation = computed(() => (isSheet.value ? 'sheet' : 'dialog'))
const modalUi = computed(() => ({
content: isSheet.value ? SHEET_CONTENT_CLASS : DIALOG_CONTENT_CLASS,
header: isSheet.value
? 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6'
: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: isSheet.value ? 'pe-10 max-sm:pe-18' : 'pe-10',
close: isSheet.value ? 'end-8 top-9 size-11' : '',
title: 'text-lg/6 font-semibold',
description: 'text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}))
const modalContent = computed(() => ({
'data-presentation': presentation.value,
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
focusInitialField()
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}))
function focusField(id: string) {
document.getElementById(id)?.focus()
}
async function focusInitialField() {
await nextTick()
focusField(props.text === '' ? textId : urlId)
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
textValue.value = props.text
urlValue.value = props.href
newTabValue.value = props.newTab
textError.value = ''
urlError.value = ''
focusedField.value = ''
},
{ immediate: true },
)
function validateText() {
textError.value = textValue.value.trim() === '' ? 'Enter the link text.' : ''
}
function resolveHref() {
const keptAsStored =
props.mode === 'edit' &&
props.href.trim() !== '' &&
urlValue.value.trim() === props.href.trim()
return keptAsStored ? urlValue.value.trim() : normalizeLinkUrl(urlValue.value)
}
function validateUrl() {
urlError.value =
resolveHref() === null ? 'Enter a valid URL. Use http(s), mailto, a path or #anchor.' : ''
}
function onUrlBlur() {
focusedField.value = ''
if (urlValue.value.trim() !== '') {
validateUrl()
}
}
function onSubmit() {
validateText()
validateUrl()
if (textError.value) {
focusField(textId)
return
}
if (urlError.value) {
focusField(urlId)
return
}
emit('submit', {
text: textValue.value.trim(),
href: resolveHref() ?? '',
newTab: newTabValue.value,
})
}
function onRemove() {
emit('remove')
}
function onCancel() {
open.value = false
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="t('Links open in the same tab unless you turn on a new tab.')"
:content="modalContent"
:ui="modalUi"
>
<template #body>
<UForm
:id="formId"
:loading-auto="false"
:state="{ text: textValue, url: urlValue }"
class="flex flex-col gap-4"
@submit="onSubmit"
>
<UFormField
:label="t('Link text')"
name="text"
:error="textError ? t(textError) : undefined"
@focusin="focusedField = 'text'"
@focusout="focusedField = ''"
>
<template v-if="textError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
:id="textId"
v-model="textValue"
:placeholder="t(TEXT_PLACEHOLDER)"
color="primary"
autocomplete="off"
:highlight="focusedField === 'text' || !!textError"
:aria-invalid="textError ? 'true' : undefined"
:ui="{ base: 'h-11 text-base md:h-10' }"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('URL')"
name="url"
:error="urlError ? t(urlError) : undefined"
@focusin="focusedField = 'url'"
@focusout="onUrlBlur"
>
<template v-if="urlError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
:id="urlId"
v-model="urlValue"
:placeholder="t(URL_PLACEHOLDER)"
:title="urlValue"
color="primary"
autocomplete="off"
spellcheck="false"
:highlight="focusedField === 'url' || !!urlError"
:aria-invalid="urlError ? 'true' : undefined"
:ui="{ root: 'min-w-0', base: 'h-11 text-base md:h-10' }"
class="w-full min-w-0"
/>
</UFormField>
<USwitch
v-model="newTabValue"
:label="t('Open in new tab')"
:ui="{
root: 'min-h-11 items-center gap-3 md:min-h-10',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
wrapper: 'ms-0',
label: 'text-base font-medium text-highlighted',
}"
/>
<UButton
v-if="editing"
color="error"
variant="link"
icon="i-lucide-unlink"
class="min-h-11 self-start px-0 md:min-h-10"
@click="onRemove"
>
{{ t('Remove link') }}
</UButton>
</UForm>
</template>
<template #footer>
<div
class="flex w-full gap-2"
:class="isSheet ? 'flex-col-reverse' : 'flex-row justify-end'"
>
<UButton
color="neutral"
variant="outline"
:block="isSheet"
:ui="{ base: 'h-11 justify-center md:h-8' }"
@click="onCancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
type="submit"
:form="formId"
:block="isSheet"
:ui="{ base: 'h-11 justify-center md:h-8' }"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useMediaQuery } from '@vueuse/core'
const COARSE_POINTER_QUERY = '(pointer: coarse)'
const ACTION_CLASS = 'h-8 gap-1.5 px-2.5 text-sm font-normal'
const ROW_CLASS = 'h-12 justify-start gap-3 px-4 text-md font-normal'
defineProps<{
href: string
text: string
reference: HTMLElement | null
}>()
const emit = defineEmits<{
edit: []
'open-link': []
remove: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isTouch = useMediaQuery(COARSE_POINTER_QUERY)
const popoverContent = {
side: 'bottom' as const,
align: 'start' as const,
sideOffset: 8,
onOpenAutoFocus: (event: Event) => event.preventDefault(),
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}
const sheetContent = {
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
const container = event.currentTarget as HTMLElement | null
container?.focus()
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}
function choose(action: 'edit' | 'openLink' | 'remove') {
open.value = false
if (action === 'edit') {
emit('edit')
} else if (action === 'openLink') {
emit('open-link')
} else {
emit('remove')
}
}
</script>
<template>
<USlideover
v-if="isTouch"
v-model:open="open"
side="bottom"
:title="text"
:description="href"
:close="false"
:content="sheetContent"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div role="dialog" :aria-label="t('Link')">
<div class="mx-auto mt-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="flex flex-col gap-0.5 border-b border-default px-4 pt-3 pb-3">
<span class="text-lg/6 font-semibold text-highlighted">{{ text }}</span>
<span class="truncate font-mono text-xs/4.5 text-toned">{{ href }}</span>
</div>
<div class="flex flex-col pt-2 pb-4">
<UButton
color="neutral"
variant="ghost"
block
icon="i-lucide-pencil"
:class="[ROW_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="choose('edit')"
>
{{ t('Edit link') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
block
icon="i-lucide-external-link"
:class="[ROW_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="choose('openLink')"
>
{{ t('Open link') }}
</UButton>
<UButton
color="error"
variant="ghost"
block
icon="i-lucide-unlink"
:class="ROW_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="choose('remove')"
>
{{ t('Remove link') }}
</UButton>
</div>
</div>
</template>
</USlideover>
<UPopover
v-else
v-model:open="open"
:reference="reference ?? undefined"
:content="popoverContent"
:ui="{
content:
'w-72 rounded-none bg-default shadow-(--rc-shadow-pop) ring-0 border border-default',
}"
>
<template #content>
<div role="dialog" :aria-label="t('Link')" class="flex flex-col text-sm text-default">
<div
class="flex items-center gap-2 border-b border-default px-3 py-2 font-mono text-xs/4.5 text-toned"
>
<UIcon name="i-lucide-link" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate" :title="href">{{ href }}</span>
</div>
<div class="flex gap-0.5 p-1">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-pencil"
:class="[ACTION_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-3.5' }"
@click="choose('edit')"
>
{{ t('Edit') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-external-link"
:class="[ACTION_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-3.5' }"
@click="choose('openLink')"
>
{{ t('Open') }}
</UButton>
<UButton
color="error"
variant="ghost"
icon="i-lucide-unlink"
:class="ACTION_CLASS"
:ui="{ leadingIcon: 'size-3.5' }"
@click="choose('remove')"
>
{{ t('Remove') }}
</UButton>
</div>
</div>
</template>
</UPopover>
</template>
File diff suppressed because it is too large. Load diff
@@ -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<typeof useScrollEdges>
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])
})
})
+174
View File
@@ -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<Editor | null>, isDisabled: () => boolean) {
const isTouch = useMediaQuery(COARSE_POINTER_QUERY)
const dialogOpen = ref(false)
const dialog = ref<LinkDialogDraft>({
mode: 'insert',
text: '',
href: '',
newTab: false,
range: { from: 0, to: 0 },
})
const popoverOpen = ref(false)
const popover = ref<LinkPopoverState>({
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,
}
}
+47
View File
@@ -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<HTMLElement | null>) {
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 }
}
+21
View File
@@ -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',
@@ -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)',
'java&#115;cript: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,<b>',
'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(
'<p>a <a href="javascript:alert(1)">x</a> <a href="data:text/html,b">y</a> <a href="vbscript:z">w</a></p>',
)
expect(html).toBe('<p>a x y w</p>')
})
it('keeps safe anchors untouched', () => {
const html = neutralizeUnsafeLinks('<p><a href="/a" target="_blank">x</a></p>')
expect(html).toBe('<p><a href="/a" target="_blank">x</a></p>')
})
})
+273
View File
@@ -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<HTMLElement>('a[href]')
}
function linkMarkAt(doc: ProseMirrorNode, pos: number, editor: Pick<Editor, 'schema'>) {
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<ArticleLinkOptions>({
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()
}
+39
View File
@@ -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
}
+46 -5
View File
@@ -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 () => {
</script>
<template>
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
<DashboardShell
:mobile-title="t('Edit article')"
back-to="/article"
:back-label="t('Back to Articles')"
full-bleed
>
<template v-if="loadState === 'loaded'" #navbar-right>
<UDropdownMenu :items="overflowItems">
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
class="size-11 justify-center lg:hidden"
:ui="{ leadingIcon: 'size-5' }"
:aria-label="t('More actions')"
/>
</UDropdownMenu>
</template>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[
@@ -467,17 +504,19 @@ onBeforeRouteLeave(async () => {
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
class="hidden size-8 justify-center text-toned lg:inline-flex"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
<div
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
:title="t('The title can\'t be changed after creation.')"
@@ -491,6 +530,7 @@ onBeforeRouteLeave(async () => {
:saved-at="draft.savedAt.value"
@retry="retrySave"
/>
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
@@ -545,7 +585,7 @@ onBeforeRouteLeave(async () => {
<h2 class="text-2xl font-semibold text-highlighted">
{{ article.title }}
</h2>
<div v-html="draft.body.value" />
<div v-html="previewBody" />
</div>
</div>
<div
@@ -566,6 +606,7 @@ onBeforeRouteLeave(async () => {
>
<template #footer>
<EditorStatusBar
class="max-lg:hidden"
:words="counts.words"
:characters="counts.characters"
:state="draft.state.value"
+217 -1
View File
@@ -396,6 +396,63 @@ async function markVeryLongDirty(page: Page) {
await focusBoldSample(page)
}
const linkLongUrl =
'https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf'
const linkSentence =
'<p>We also check the <a href="/articles/winter-checklist" target="_blank" rel="noopener noreferrer">timber windows</a> before any tiling starts.</p>'
const linkedBody = bathroomBody.replace('</p><h2>', `</p>${linkSentence}<h2>`)
const linkImageSrc = '/static/uploadedImages/blank.svg'
const linkedImageBody = `${linkedBody}<img src="${linkImageSrc}" alt="" data-image-id="1">`
const MOBILE_MAX_WIDTH = 640
const TOOLTIP_SETTLE_MS = 700
function linkedApi(body = linkedBody) {
return editorApi({
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
})
}
function isNarrow(page: Page) {
return (page.viewportSize()?.width ?? MOBILE_MAX_WIDTH) < MOBILE_MAX_WIDTH
}
function linkButton(page: Page) {
return page.getByRole('button', { name: 'Insert link', exact: true }).first()
}
function linkDialog(page: Page) {
return page.locator('[data-presentation]')
}
async function typeIntoLinkField(page: Page, label: string, value: string) {
await linkDialog(page).getByLabel(label, { exact: true }).fill(value)
}
async function openLinkDialogOnWord(page: Page) {
await markDirty(page)
await page.getByText('choose yourself').dblclick()
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
}
async function openLinkDialogOnLink(page: Page) {
await markDirty(page)
await page.locator('.article-content a').click()
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await linkDialog(page).waitFor()
}
async function dirtyWithLinks(page: Page) {
await focusBoldSample(page)
await markDirty(page)
await focusBoldSample(page)
}
async function useTouchPointer(page: Page) {
const session = await page.context().newCDPSession(page)
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 })
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
@@ -4946,7 +5003,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.getByRole('button', { name: 'Back to Articles' }).click()
await page.locator('[aria-label="Back to Articles"]:visible').click()
await page.getByRole('alertdialog').waitFor()
},
},
@@ -4975,6 +5032,165 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/article/9999': { status: 404 },
},
},
'Link: create from selection': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await openLinkDialogOnWord(page)
await typeIntoLinkField(page, 'Link text', 'timber windows')
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
},
},
'Link: create with no selection': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
},
},
'Link: edit existing': {
path: '/article/12/edit',
api: linkedApi(),
steps: openLinkDialogOnLink,
},
'Link: invalid URL': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await openLinkDialogOnWord(page)
await typeIntoLinkField(page, 'Link text', 'timber windows')
await typeIntoLinkField(page, 'URL', 'www.example')
await page.keyboard.press('Tab')
await linkDialog(page).getByText('Enter a valid URL').waitFor()
await linkDialog(page).getByText('Links open in the same tab').click()
},
},
'Link: empty text': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
await typeIntoLinkField(page, 'URL', '#measuring')
await linkDialog(page).getByRole('button', { name: 'Insert link' }).click()
await linkDialog(page).getByText('Enter the link text.').waitFor()
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
await linkDialog(page).getByText('Links open in the same tab').click()
},
},
'Link: long URL': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await openLinkDialogOnLink(page)
await typeIntoLinkField(page, 'Link text', 'planning guidance')
await typeIntoLinkField(page, 'URL', linkLongUrl)
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
},
},
'Link button: default': {
path: '/article/12/edit',
api: linkedApi(),
steps: dirtyWithLinks,
},
'Link button: hover and tooltip': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await linkButton(page).hover()
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
},
},
'Link button: focus-visible': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await focusBoldSample(page)
await markDirty(page)
await linkButton(page).focus()
},
},
'Link button: active (cursor in a link)': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').click()
await page.keyboard.press('Escape')
},
},
'Link button: disabled': {
path: '/article/12/edit',
api: linkedApi(linkedImageBody),
steps: async (page) => {
await dirtyWithLinks(page)
await page.getByTestId('article-editor-image').click({ force: true })
await page.addStyleTag({
content: '[data-testid="article-editor-image"] { display: none; }',
})
},
},
'Link popover and sheet': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
if (isNarrow(page)) {
await useTouchPointer(page)
}
await page.locator('.article-content a').click()
await page.locator('[aria-label="Link"]').waitFor()
},
},
'Rendered link: default': {
path: '/article/12/edit',
api: linkedApi(),
steps: dirtyWithLinks,
},
'Rendered link: hover': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').hover()
},
},
'Rendered link: focus': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await markDirty(page)
await page.getByText('before any tiling starts').click()
await page.keyboard.press('Home')
for (let step = 0; step < 21; step += 1) {
await page.keyboard.press('ArrowRight')
}
},
},
'Rendered link: selected': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').selectText()
},
},
'Toolbar overflow (390 px)': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
if (isNarrow(page)) {
await page
.locator('.article-toolbar')
.evaluate((toolbar) => toolbar.scrollTo({ left: toolbar.scrollWidth }))
}
},
},
'Upload: choose': {
path: '/article/12/edit',
now: editorNow,