feat(editor): link insertion full UI and flow
This commit is contained in:
1 parent
03b39e64f2
commit
6f9ae6207a
16 files changed
+2860
-9
No files matched your search
@@ -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 | 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 "<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) |
|
||||
@@ -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.
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)',
|
||||
'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,<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>')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user