feat(editor): link insertion full UI and flow

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

No files matched your search

@@ -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>')
})
})
+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
}