feat(editor): link insertion full UI and flow
This commit is contained in:
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>')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user