Repository navigation
Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513
Description
Part of #512. Read the tracker first. Work on main.
Summary
- Today. The
hyperlinkmark renders a plain<a>. The package cannot draw an icon before a link. - After. A new optional option,
linkIcon, takes a sync function. It receives{ href }and returns a fresh element ornull. A mark view puts that element at the start of the<a>, outsidecontentDOM. WithlinkIconunset, the<a>is the same as today.
Measured before filing. A real-browser build of this design passed every gate in Chromium 151 and WebKit 26.5: a keystroke costs no more with icons on, typing builds 0 mark views, and 30 editor create-and-destroy cycles leave nothing behind. #512 §Tests and benchmarks has the numbers, the method and the limits.
Maintainer rulings that bind this issue
#512 quotes every ruling with its number and date. All below are from 2026-10-17.
- Ruling 9, mark view. Not a
Decoration.widget, for its per-keystroke cost. A mark view adds nothing to aDecorationSet. - Ruling 7, sync, one function. The resolver returns an element at once, and the element does its own fetching.
- Ruling 8, where the code lives. The mechanism goes into
@docs.plus/extension-hyperlink. The webapp uses it and is where it is tested. - Ruling 3, glyph freedom. A host may use any glyph, not only lucide. So the option accepts any element.
- Ruling 2, every pad link shows an icon. This goes against Google Docs, Word and Notion on purpose, like Revise the heading size scale to follow a documented typographic convention #369. It is a recorded parity exception.
- Ruling 13, icon box. 1em box, 0.25em gap,
vertical-alignby measurement. - Ruling 15, tests. This issue owns the mark-view unit and clean-room Cypress tests.
- Ruling 16, legacy
linkmarks. No production count; a README caveat only.
Depends on / Blocks
- Steps 8 and 10 wait for the Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ruling. Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 must merge or close before Step 8. Steps 0 to 7 do not wait for Bump TipTap packages to 3.32.0 for the remote-cursor fix #473.
- Blocks Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 (it imports
LinkIconContextfrom thetypes.tsmade here) and Show a link icon before every pad link in the webapp #516 (it passes its chain tolinkIcon). The webapp does not importstyles.css(grep ofapps/webapp/src: 0 hits), so Show a link icon before every pad link in the webapp #516 adds its own.hyperlink-iconSCSS. - Related: Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 and Stop link preview metadata writes from reaching every peer #519. This issue fixes neither.
- Overlaps open PR Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 (TipTap 3.32.0), which also moves the prosemirror-view override to 1.42.6 (Decision 2). [Tracking] Inline link icons through a hyperlink mark view #512 sets the order. Steps 0 and 8 handle either order.
- Reaches the webapp at the next extension build. The webapp reads the package's
./distentry points (package.json:5-21) through the workspace symlink. With Decision 3, every webapp editor, the composer included, gets the plain mark view, so Step 11 runs the webapp specs.
Current behaviour
renderHTMLblanks an unsafe href and merges the hostHTMLAttributes. The DOM is['a', attrs, 0](hyperlink.ts:263-272).- The mark is not inclusive, has priority 1000 and no icon option (
hyperlink.ts:86-139). - One parse rule,
a[href], at default priority (hyperlink.ts:247-261). An inline image'simg[src]rule would also match an icon<img>(image.ts:147). - Plugins.
addProseMirrorPluginsreturns only the interaction plugins (hyperlink.ts:303-305).createInteractionsruns three times per editor (lines 296, 300, 304). - Clicks.
touchendandclickplace the caret from the click point (clickHandler.ts:150-168), after the preview mounts (:49-67). The capture-phasemousedownguard swallows every left press on a link (:108-114), so prosemirror-view's own mousedown selection never runs there (read from code). - DOM readers.
closest('a')atclickHandler.ts:18andliveAnchor.ts:13-26, which matches links bytextContent.view.posAtDOM(liveLink, 0)atbuildPreviewOptionsFromAnchor.ts:38. No reader ofa > …ora.firstChildexists (grep). - Unit tests run as
bun test src(package.json:42), with no DOM, and share globals and the module cache across files.--isolategives each file a fresh global and module registry (laptop, plugin only, no view).enginesasks for 1.4.0 or later, and extension CI usessetup-bun's default,latest. - jsdom.
jsdom@26.1.0and@types/jsdom@21.1.7are inbun.lock(lines 2874, 1680) throughjest-environment-jsdom. Sibling devDependencies all usecatalog:(package.json:84-101). - prosemirror-view pin. The root override is
"1.42.3"(package.json:52). See [Tracking] Inline link icons through a hyperlink mark view #512 §prosemirror-view floor. - Playground. URL params are read at
main.ts:41-49; there is no icon resolver. It imports the builtdist/(lines 1-3).cy.visitPlaygroundknows onlypopover=custom(e2e.ts:66-73). The support file hand-mirrors the playgroundWindowtype (lines 19-36).
Installed source
prosemirror-view lines are 1.42.3 (node_modules/.bun/prosemirror-view@1.42.3); Tiptap lines are @tiptap/core 3.31.3. 1.42.6 lines come from https://unpkg.com/prosemirror-view@1.42.6/src/viewdesc.ts. After #473 the installed versions are 1.42.6 and 3.32.0 (Step 0.3).
- A mark view gets
(mark, view, inline), no content (viewdesc.ts:625-627). The default<a>isrenderSpec(document, toDOM(mark, inline), null, mark.attrs)(:629); the fourth argument turns on an XSS check (to_dom.ts:204-206). - Tiptap's
HTMLAttributesprop skipsrenderHTML, andaddMarkView()runs per mark construction (ExtensionManager.ts:420-446). - With a
contentDOM,ignoreMutationfalls back to false (viewdesc.ts:495-497,:661-663), so an icon attribute change re-reads the mark (domobserver.ts:291-292). - Only
contentDOMis rendered and parsed (viewdesc.ts:1051-1070,:633-636).syncToMarkstriesspec.updatefirst (:1236-1246).contenteditable=falseis set on widgets only (:552-560). - Plugin
handleDOMEventsrun first; returning true skips prosemirror-view's owncompositionend(input.ts:48-53,:562-573). - 1.42.3
isIgnorableskips only size-0 descs (capturekeys.ts:62-65); 1.42.4 also skips uneditable elements (CHANGELOG). posAtDOMinside the icon: 1.42.3 reads& 2(viewdesc.ts:251) and returns the link start. 1.42.6 reads& 4and returns the link end (laptop, jsdom view, no layout).- In Safari,
posAtCoordsdrops the caret result under a draggable ancestor such as<a href>(domcoords.ts:292-295). An icon point may then resolve to the link end on 1.42.6 (UNVERIFIED in Safari). - jsdom acts as Safari. Bun 1.4.3 accepts the global
navigatorswap, which then reads vendor "Apple Computer, Inc."; jsdom haswebkitFontSmoothing(laptop, jsdom view, no layout). Sobrowser.tssetssafariandwebkittrue, andchrome,gecko,ios,androidfalse (read from code). The probes used the same swap. jsdom lackselementFromPoint,caretRangeFromPointandClipboardEvent(laptop, jsdom view, no layout).
Target behaviour
Hyperlink.configure({ linkIcon })takes a sync(ctx: { href: string }) => HTMLElement | null. A throw counts asnulland logs once per resolver. Only hrefs that passisSafeHrefreach it.- Iconed DOM:
<a href rel title class><icon class="hyperlink-icon" contenteditable="false" aria-hidden="true"><span>text</span></a>. Plain DOM: exactly today's<a>text</a>. - The icon sits at the start of every iconed
<a>, image-led and image-only links included (Decision 1). - Text typed or composed beside the icon reaches the model once. Typed order holds; the IME order is a merge gate (Step 10).
- A metadata write keeps the
<a>and icon. An icon click puts the caret at the link start. - No icon reaches
getHTML(), Markdown, copy or drag (clipboard.ts:17,input.ts:656,:754), or a pasted Image node. Screen readers read only the link text.
Implementation steps
Steps 1 to 5 need no ruling. Steps 6, 7, 8, 10 and 12 wait for rulings.
Step 0. Baseline
- Run
bun run --filter @docs.plus/extension-hyperlink test:unit. Record the pass count and run time. - Run
grep -n '"prosemirror-view"' package.json bun.lock. - Confirm each installed-source line above. If Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 is on
main, expect@tiptap/core3.32.0 and prosemirror-view 1.42.6. Then re-cite each line from the installed path in a comment here, and read the 1.42.3 lines from https://unpkg.com/prosemirror-view@1.42.3/src/. Stop and comment only if a behaviour differs.
Step 1. Types
Create extensions/extension-hyperlink/src/link-icons/types.ts:
/** What linkIcon receives. One field today; new fields are additive. */
export interface LinkIconContext {
href: string
}
/** Sync. A fresh element with no text, or null for a plain link. */
export type LinkIconResolver = (ctx: LinkIconContext) => HTMLElement | null#514 adds LinkIconSource and LinkIconFetcherOptions here. In hyperlink.ts, add import type { LinkIconContext } from './link-icons/types' (a type import, so export * from './hyperlink' does not re-export it). Add to HyperlinkOptions, written out so the public .d.ts names only LinkIconContext:
/** Sync. A fresh element with no text, or null for a plain link. A throw counts as null and logs once. */
linkIcon?: (ctx: LinkIconContext) => HTMLElement | nullAdd linkIcon: undefined to addOptions (hyperlink.ts:187-210). In src/index.ts, put the export just before // Utilities, in a new group:
// Link icons: resolver helpers for HyperlinkOptions.linkIcon.
export type { LinkIconContext } from './link-icons/types'A new root export is a minor release (release-extensions SKILL.md:23).
Step 2. The mark view
Create src/link-icons/linkIconMarkView.ts. The design version typechecked against @tiptap/core 3.31.3 with 0 errors (laptop, plugin only, no view). Then run bunx prettier --write on it.
import type { MarkViewRenderer } from '@tiptap/core'
import { DOMSerializer, type Mark } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { isSafeHref } from '../url-decisions'
import { logger } from '../utils/logger'
import type { LinkIconResolver } from './types'
// Security: build <a> only through toDOM, with prosemirror-view's own arguments (viewdesc.ts:629).
// renderHTML blanks unsafe hrefs; the attrs argument keeps renderSpec's XSS check.
const drawAnchor = (m: Mark, inline: boolean): { dom: HTMLElement; contentDOM?: HTMLElement } =>
(DOMSerializer.renderSpec as any)(document, m.type.spec.toDOM!(m, inline), null, m.attrs)
const plainAnchor: MarkViewRenderer = ({ mark, inline }) => drawAnchor(mark, inline)
const renderers = new WeakMap<LinkIconResolver, MarkViewRenderer>() // addMarkView() runs per mark view
const pending = new WeakMap<EditorView, Set<() => void>>() // sweeps held back during IME, one set per view
// Our handler runs before prosemirror-view's own (input.ts:50-52); the microtask runs after it, with composing false.
export const linkIconCompositionSweep = new Plugin({
props: {
handleDOMEvents: {
compositionend(view) {
const s = pending.get(view)
if (s?.size) Promise.resolve().then(() => { for (const f of s) f(); s.clear() })
return false // true would skip prosemirror-view's own compositionend
}
}
}
})
export function linkIconMarkView(linkIcon?: LinkIconResolver): MarkViewRenderer {
if (!linkIcon) return plainAnchor
const cached = renderers.get(linkIcon)
if (cached) return cached
let warned = false
const warn = (msg: string, e?: unknown) => { if (!warned) { warned = true; logger.error(msg, e) } }
const resolve = (href: unknown): HTMLElement | null => {
if (typeof href !== 'string' || !href || !isSafeHref(href)) return null // renderHTML blanks it to ''
let el: unknown
try { el = linkIcon({ href }) } catch (e) { warn('linkIcon threw; the link stays plain', e); return null }
if (el instanceof HTMLElement && !el.isConnected && !el.textContent) return el
if (el != null) warn('linkIcon must return a fresh element with no text')
return null
}
const renderer: MarkViewRenderer = ({ mark, view, inline }) => {
const spec = drawAnchor(mark, inline), slot = resolve(mark.attrs.href)
if (!slot) return spec // plain link: the same DOM as today
const { dom } = spec, hole = spec.contentDOM ?? dom, contentDOM = document.createElement('span')
slot.classList.add('hyperlink-icon'); slot.setAttribute('aria-hidden', 'true')
slot.contentEditable = 'false' // ProseMirror sets this on widgets only
if (slot instanceof HTMLImageElement) slot.draggable = false
hole.append(slot, contentDOM)
let owned = dom.getAttributeNames()
// Move stray text into contentDOM. insertBefore resets a caret inside it (DOM remove steps), so put it back.
const sweep = () => {
if (!hole.isConnected) return
const ds = (view.root as Document).getSelection()
const a = ds?.anchorNode, ao = ds?.anchorOffset ?? 0, f = ds?.focusNode, fo = ds?.focusOffset ?? 0
for (const n of Array.from(hole.childNodes))
if (n !== slot && n !== contentDOM)
contentDOM.insertBefore(n, n.compareDocumentPosition(contentDOM) & Node.DOCUMENT_POSITION_FOLLOWING ? contentDOM.firstChild : null)
if (!slot.parentNode) hole.prepend(slot) // a native delete removed the icon
if (ds && a && f && a.isConnected && f.isConnected &&
(ds.anchorNode !== a || ds.anchorOffset !== ao || ds.focusNode !== f || ds.focusOffset !== fo))
ds.setBaseAndExtent(a, ao, f, fo)
}
return {
dom, contentDOM,
update(next) { // keeps the icon on a title or image write; never call linkIcon here (new request)
if (next.attrs.href !== mark.attrs.href) return false
const fresh = drawAnchor(next, inline).dom
for (const n of owned) if (!fresh.hasAttribute(n)) dom.removeAttribute(n)
owned = fresh.getAttributeNames()
for (const n of owned) dom.setAttribute(n, fresh.getAttribute(n)!)
return true
},
ignoreMutation(m) {
if (m.type === 'selection') return false
if (slot.contains(m.target)) return true // img load, hidden toggle
if (m.type !== 'childList' || m.target !== hole) return false
if (view.composing) { // a sweep mid-IME would break the composition
let s = pending.get(view)
if (!s) pending.set(view, (s = new Set()))
s.add(sweep)
return false
}
sweep()
return false
}
}
}
renderers.set(linkIcon, renderer)
return renderer
}- Exports.
linkIconMarkViewmatches the file name.linkIconCompositionSweepsharespending, so it lives here too. Neither goes into the barrel. ignoreMutation. Without it, one flush that unhid every icon rebuilt every view: 49.8 / 630.7 / 4911.8 ms at 500 / 2000 / 5000 links (laptop, jsdom view, no layout).- The sweep is a main path. prosemirror-view leaves a caret at
(a, 0)(dom.ts:46-69), and on 1.42.6 ArrowLeft at a block-start link parks it there. Without the caret restore, three typed characters came out reversed. Without the plugin, composed text in the hole never reached the model (both laptop, jsdom view, no layout). - Results with the sweep (laptop, jsdom view, no layout; both versions). "A" typed into the hole of
pre <a>mid</a> post, then "B" and "C", gave"pre ABCmid post"at(a,0)and(a,1), and"pre midABC post"at(a,2). A realcompositionendgave"pre BCAmid post": no loss, wrong order (real browsers UNVERIFIED; Step 10).
Step 3. Parse guard
In parseHTML (Current behaviour item 3), add a first rule: { tag: '.hyperlink-icon', ignore: true, priority: 1000 },.
- It matches only that class, so a high priority is safe. Rules sort by priority, and
ignoreskips the element (from_dom.ts:278-303,:487-492; Tiptap keeps it,injectExtensionAttributesToParseRule.ts:12-45). - At priority 100, a native-split clone and a paste of rendered HTML gave 2 and 1 image nodes without the guard, and 1 and 0 with it; the fixture held 1 real image (laptop, jsdom view, no layout). Priority 1000 is read from code, not measured. Unit test 2 measures it.
Step 4. CSS in styles.css
Add after the :root block:
/* Inline link icon from the linkIcon option. Two classes beat a host `.ProseMirror img` rule. */
.ProseMirror .hyperlink-icon {
display: inline-block;
width: 1em;
height: 1em;
margin-inline-end: 0.25em;
vertical-align: -0.125em;
pointer-events: none;
-webkit-touch-callout: none;
}
.ProseMirror .hyperlink-icon > svg {
display: block;
width: 100%;
height: 100%;
}
.ProseMirror .hyperlink-icon[hidden] {
display: none;
}- Tailwind preflight sets
img, svgtodisplay: block(tailwindcss@4.3.3preflight.css:214-223). Withoutinline-block, the icon breaks the line. vertical-alignis provisional. This issue may merge with it. The family release waits for the Show a link icon before every pad link in the webapp #516 measurement, which copies the value back here.- A host without this CSS gets an icon at its natural size: a silent failure, so a README caveat (Step 12).
- Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 owns the tile CSS. Tiles and pairs are backgrounds and add no text, so
liveAnchorstill matches.
Step 5. Icon click puts the caret at the link start
In interactions/clickHandler.ts, add this helper and use it in touchend and click (Current behaviour item 5):
// The icon has pointer-events: none, so a real click targets the <a>. Test the point as well.
function isOnLinkIcon(link: HTMLAnchorElement, target: EventTarget | null, x: number, y: number): boolean {
if (target instanceof Element && target.closest('.hyperlink-icon')) return true
const icon = link.querySelector(':scope > .hyperlink-icon')
if (!icon) return false
const r = icon.getBoundingClientRect()
return r.width > 0 && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom
}const pos = isOnLinkIcon(link, event.target, clientX, clientY)
? view.posAtDOM(link, 0)
: view.posAtCoords({ left: clientX, top: clientY })?.posIn click, pass event.clientX and event.clientY; in touchend, use the destructured clientX and clientY. posAtDOM(link, 0) gives the link start on both versions (laptop, jsdom view, no layout). The point test keeps icon clicks away from posAtCoords and its Safari path. The mousedown guard makes this helper the only caret path for a left click on the icon (read from code).
Step 6. DOM unit tests 1 to 9 (stop until a Maintainer ruling comment exists for Decision 6)
If Decision 6 is ruled no, skip Step 6 and test 10 in Step 7, and comment here.
-
Catalog. In the root
catalog(package.json:128), add"jsdom": "^26.1.0"and"@types/jsdom": "^21.1.7". In the packagedevDependencies, add both as"catalog:". Runbun installat the repo root. The ranges resolve to the locked versions, so the diff must add no package entry. If it does, stop and comment. Then run the cold install in Verification. Rewrite the headless note incommands.test.tslines 1-3 (Decision 6): DOM tests now load jsdom per file. -
Isolation. Set
test:unittobun test --isolate srcandtest:unit:watchtobun test --watch --isolate src. Without it, jsdom globals leak into later files, and an earlier file has already loaded prosemirror-view with Bun'snavigator(laptop, plugin only, no view). Record the new run time. -
src/link-icons/__tests__/setupDom.ts. Make it the first import of each DOM test file;--isolatemakes prosemirror-view load after it. No global preload.import { JSDOM } from 'jsdom' const dom = new JSDOM('<!doctype html><div id="ed"></div>', { pretendToBeVisual: true }) const w = dom.window const names = ['window', 'document', 'navigator', 'MutationObserver', 'getComputedStyle', 'Node', 'HTMLElement', 'HTMLImageElement', 'Element', 'Text', 'DOMParser', 'getSelection', 'Event', 'CompositionEvent', 'MouseEvent', 'KeyboardEvent', 'requestAnimationFrame', 'cancelAnimationFrame'] as const for (const k of names) { const v = (w as any)[k] ;(globalThis as any)[k] = k === 'window' ? w : typeof v === 'function' && /^[a-z]/.test(k) ? v.bind(w) : v } // jsdom has no layout. prosemirror-view asks for rects when it scrolls the selection. w.Range.prototype.getClientRects = () => [] as any w.Range.prototype.getBoundingClientRect = () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }) as any // jsdom has no elementFromPoint; posAtCoords then returns null outside the zero editor rect. ;(w.document as any).elementFromPoint = () => null
Keep the jsdom
navigator, so prosemirror-view runs its Safari branches, as the probes did. -
src/link-icons/__tests__/linkIconMarkView.test.ts. Use a real TiptapEditorwithStarterKit.configure({ link: false })and an inline image node parsed fromimg[src]at default priority. The resolver returns a fresh hidden<img>forhttps:hrefs and counts calls.- Temporary wiring until Step 7:
Hyperlink.extend({ addMarkView: () => linkIconMarkView(resolver), addProseMirrorPlugins() { return [...(this.parent?.() ?? []), linkIconCompositionSweep] } }). - Preview stub:
popovers: { previewHyperlink: () => document.createElement('div') }. If it cannot mount in jsdom, stop and comment.
- Temporary wiring until Step 7:
-
Tests. Each names one failure. Prove each by sabotage: break the code, see the assertion fail (not a thrown error), restore. No DOM snapshots.
- Icon loads cause no rebuild. Toggle every icon's
hidden, then(view as any).domObserver.flush(). Same nodes, same resolver count. Sabotage: drop theslot.containsline. - No Image node from an icon. Parse a desc-less clone of a rendered paragraph with
DOMParser.fromSchema(schema), and runview.pasteHTML(html, new Event('paste') as ClipboardEvent). jsdom has noClipboardEvent; this package'shandlePasteignores the event (pasteHandler.ts:10). Assert 0 new image nodes. Sabotage: drop the Step 3 rule. - No live
javascript:, no icon on it. Insert a mark withhref: 'javascript:alert(1)'by transaction. Asserthref=""and no resolver call for it. Sabotage (a): build fromHTMLAttributes. Sabotage (b): drop theisSafeHrefcheck. - A metadata write keeps the icon. Set
titlebyremoveMarkplusaddMark. Same<a>and icon, newtitle, no resolver call. Clearingtitleremoves the attribute. A new href gives a new<a>. Sabotage: dropupdate. - Typed order in the hole, one
iteach for(a,0),(a,1)and(a,2). Insert text"A"at that child index, collapse the selection after it, flush, theninsertText('B')andinsertText('C'). Assert the paragraph text, the caret after the last character, and no text node as a direct child of the<a>. Sabotage (a): drop the sweep. Sabotage (b): drop the caret restore. - Composed text is not lost. Set
(view as any).input.composing = true, insert"A"at(a,0), flush, then dispatchnew CompositionEvent('compositionend', { bubbles: true, data: 'A' })onview.dom. Await two microtasks and 40 ms, flush."A"is in the model once, with no text directly in the<a>. Sabotage: leave out the plugin. Do not assert the order (Step 10 owns it). - A removed icon comes back. Remove it and flush. It is again the first child, and the doc is unchanged.
- A bad resolver logs once. Spy on
console.error. A throwing resolver across five links logs once, and every link stays plain. A connected element, or one with text, gives a plain link and one log. - Icon click. Put the selection at the doc end. Dispatch
new MouseEvent('click', { bubbles: true, button: 0, clientX: 500, clientY: 500 })on the icon. Assert the selection is at the link start. Sabotage: drop the helper;posAtCoordsthen returnsnullunder the stubs, so the selection stays at the doc end. Step 9 proves the point branch in a real browser.
- Icon loads cause no rebuild. Toggle every icon's
-
Run
test:unit. The count is the Step 0 count plus 11 newitblocks. Runbun run --filter @docs.plus/extension-hyperlink typecheck.tsconfig.test.jsonincludessrc/**/*, and basetypes: []does not block types for an explicit import (read from code). If it fails, stop and comment.
Step 7. Wire the mark view (stop until a Maintainer ruling comment exists for Decision 3)
With the recommended answer to Decision 3:
- Add
addMarkView() { return linkIconMarkView(this.options.linkIcon) }tohyperlink.ts. - In
addProseMirrorPlugins(Current behaviour item 4), appendlinkIconCompositionSweeponly whenthis.options.linkIconis set. One module instance is fine: it has no state and no key. Not insidecreateInteractions, which runs three times. - Switch the test editor to
Hyperlink.configure({ linkIcon: resolver })and add test 10: unset means today's<a>. WithlinkIconunset, no.hyperlink-icon, and<a>attributes equaltoDOMfor the same mark. Sabotage: makelinkIconMarkView(undefined)return an iconed renderer.
If the alternative wins, add props.markViews: { hyperlink: linkIconMarkView(linkIcon) } to the plugin instead of addMarkView. Test 10 then asserts no mark view. In Step 12, api.md says "unset means nothing", and a host mark view wins with no warning. Comment here first.
Step 8. Move the root override (stop until the #473 ruling (#520 Decision 1) exists)
- Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 open: stop and comment here. Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 on
main: skip this item. Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 closed unmerged: comment here, then wait for a Maintainer ruling comment on Decision 2.- In that last case, after a yes, change the override to
"1.42.6"and runbun installat the root. Thebun.lockdiff touches onlyprosemirror-view. Then run the cold install. - Item 2 runs once this item is done or skipped.
- In that last case, after a yes, change the override to
- With
make dev-localup, because some suites need Postgres and Redis: runbun run test:unitat the root (five extension gates plus webapp Jest) andbun run --filter @docs.plus/hocuspocus test. The rootpackage.jsonandbun.lockchange triggers all five extension suites in CI.
Step 9. Playground and clean-room Cypress
- Playground (
test/playground/main.ts, beside the params at lines 41-49):icons=elementreturns a fresh<span>with an inline background colour for everyhttp(s)href, with no network.icons=throwthrows forthrow.examplehrefs and returns the span for the rest. Count calls onwindow._iconCalls. Add_iconCalls?: numbertodeclare globalinmain.tsand to the mirroredWindowincypress/support/e2e.ts. Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 addsmap,fetcher,bothandtile=on. - Support file. Add
query?: stringandonBeforeLoad?: (win: Window) => voidtoVisitPlaygroundOptions. Build the URL frompopoverandquery, and pass{ onBeforeLoad }tocy.visit. Keep it one file. cypress/e2e/link-icons.cy.ts(ends withexport {}). Callcy.focusEditor(pos)before every barecy.realPressorcy.realType; it gates onshould('have.focus').- typing in and around a link adds 0 icons and 0 resolver calls;
getHTML()andgetMarkdown()have nohyperlink-icon;setContent(getHTML())keeps the doc JSON;- select-all, copy and paste of three iconed links keeps every text and href;
- the Edit form Text equals the link text; undo after a link delete restores the icon;
- arrows move one position per press; Delete and Backspace beside the icon remove one character;
- three characters at a block-start and a mid-paragraph link start keep their order;
- icon click: dispatch a native
MouseEvent('click', { bubbles: true, button: 0, clientX, clientY })at the icon centre on the<a>; the selection is at the link start. This proves the point branch. NorealClick: it misseshandleDOMEvents.click(AGENTS.md§Test Orchestration); icons=throw: visit withonBeforeLoad: (win) => cy.spy(win.console, 'error').as('err'); one call, plain links.
link-edges.cy.ts: run every case with no query and withquery: 'icons=element'. Block-start fixtures are atlink-edges.cy.ts:44-56.styling.cy.ts: add'.hyperlink-icon'toPUBLIC_CLASS_NAMES(styling.cy.ts:20-28).ruleExistsmatches by substring.- Run
bun run --filter @docs.plus/extension-hyperlink test(itspretestbuildsdist/). - Firefox, by hand. Run
bunx cypress info; if Firefox is missing, say so in the Step 10 comment. Stop any running playground (extensions/CLAUDE.md§Standalone Extension Development). Fromextensions/extension-hyperlink, runbun run build && bunx start-server-and-test playground http://127.0.0.1:5173 'bunx cypress run --browser firefox --spec cypress/e2e/link-icons.cy.ts'.
Step 10. Browser, caret and IME checks (blocks the merge; stop until the #473 ruling and a Maintainer ruling comment for Decision 5 exist)
With the recommended answer to Decision 5, the executor runs desktop Chrome, Safari and Firefox, macOS Pinyin and the iOS Simulator. The maintainer runs the real-device legs (#512 Device legs). If the alternative wins, the named person runs them.
Use ?icons=element on the version that ships (Decision 2). The playground binds 127.0.0.1:5173; the iOS Simulator reaches it, and Android needs adb reverse tcp:5173 tcp:5173. Post a leg with no device as "not run", never as passed.
- Typing beside the icon (Chrome, Safari, Firefox; block-start and mid-paragraph link). Click the icon's left half, click just right of it, press Home, and press ArrowLeft from inside the link. Each time, type three characters. Text once, in order, none left over.
- Block-start caret (Decision 4): record where the caret is drawn and where text appears, also in a list that starts with a link.
- Arrows: one position per press; no stuck or flickering caret. Delete and Backspace: one character; icon kept.
- Android Chrome: Backspace after the icon at a block-start link removes one character; Enter inside and after a link.
- Swipe: spacebar-swipe across three iconed links on Gboard and iOS.
- IME (inside, at both edges, at a block-start link): Gboard word and punctuation, iOS kana and dictation, macOS Pinyin, Windows Chrome Chinese punctuation. Then type two plain characters. No vanished or doubled icon, no lost or doubled text, typed order kept.
- Native Enter inside a link on Android and iOS: no new Image node in
editor.getJSON(). - Icon click in Safari, Chrome and iOS Safari: caret at the link start. iOS long-press: link callout, not "Save Image".
- Line-start inline images (1.42.6 only), Safari and Firefox: no extra gap; arrows move past.
- Native copy from outside the editor into an iconed link, pasted into a mail client: record whether the icon comes along.
- Light and dark (
html[data-theme='dark']): the box and gap by eye.
Post the results here. Stop rule: if the IME order or a caret check fails, stop and report. The only known fallback, a CSS ::before icon, overturns ruling 3.
Step 11. Webapp and sibling-package checks
- In the package, run
bunx tsup. Clearapps/webapp/.next/cache, restart the dev server and hard-refresh. - Start
make dev-local, and read the webapp port per [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack. Fromapps/webapp, runbunx cypress run --browser electron --spec 'cypress/e2e/editor/hyperlink/*.cy.js' --config baseUrl=http://localhost:<port>. - In the chat composer, a link renders the same
<a>as before, with no.hyperlink-icon. - Run
bun run --filter @docs.plus/extension-hypermultimedia test. Its suite imports this package (ALSO_WATCHES).
Step 12. Docs and CHANGELOG (stop until Maintainer ruling comments exist for Decisions 1, 3 and 4)
Edit every file in Docs to update, then run bunx prettier --write on each.
Review checkpoint
Show the touched files and test output, then stop. No git add, git commit, git push or git stash, and no branch.
Decisions
Owner: the maintainer. Recommended answer first.
- Decision 1. Put the icon at the start of every iconed link, image-led and image-only links included? Recommended: yes. A mark view gets no content, so it cannot find the first text node. This fits the ruling 2 parity exception. Alternative: a CSS
:has()rule, which cannot detect a leading text node. Needed by Step 12. - Decision 2. Only if Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 closes without a merge: move the root prosemirror-view override to 1.42.6? Recommended: yes.
- A fresh extension install resolves 1.42.6 today, but our clean-room tests run 1.42.3. 1.42.4 fixes the arrow and delete catch at mid-paragraph links.
- The pin (
36f9e4114, 2026-09-09) took the then-latest version; its commit body explains onlyprosemirror-changeset. - Whole-editor effects: 1.42.5 changes Android Enter and delete. 1.42.6 adds a dummy node before atomic inline nodes at a block start in Safari and Firefox; inline images are such nodes (
TipTap.tsx:214). Steps 2 and 5 cover the hole caret and theposAtDOMchange. The server gets 1.42.6 too. - Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 (open) makes the same change, with TipTap 3.32.0, whose
@tiptap/pmasks for^1.42.6. A yes on Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520 Decision 1 (merge Bump TipTap packages to 3.32.0 for the remote-cursor fix #473) answers this Decision too ([Tracking] Inline link icons through a hyperlink mark view #512 §prosemirror-view floor). If Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 merges first, Step 8 item 1 is done. - Alternative: stay on 1.42.3, and api.md recommends 1.42.4 or later. Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 cannot then merge as it stands. Needed by Step 8.
- Decision 3. With
linkIconunset, return the plain mark view, and load the composition plugin only when it is set? Recommended: yes.- This adds no redraw at reconfigure, and a host override stays visible.
- Cost: every webapp editor, the composer included, gets a plain mark view with today's DOM in production. It also fixes the public
api.mdmeaning of an unsetlinkIcon: "the default<a>". - Alternative: a plugin
markViewsprop. It keeps "unset means nothing", but costs one extra full redraw at mount on prefilled editors (read from code, not measured). A hostaddMarkViewthen wins with no warning. - Needed by Steps 7 and 12.
- Decision 4. Accept the block-start caret jump, and record it as a deliberate parity exception? Recommended: yes.
- At a block-start link, the caret is drawn after the icon, but a typed character lands before the link (
viewdesc.ts:413-423). No cheap fix is known. - Parity flag (
AGENTS.md§Workflow And Review Expectations). Plain links in Google Docs, Word and Notion carry no icon, so their caret never jumps there (UNVERIFIED; no primary source). An accepted Decision 4 is recorded in the ruling comment as a deliberate exception. - Optional: a discuss.prosemirror.net post on the caret and the
posAtDOM& 4read. It does not block, and it needs your yes. - Alternative: a CSS
::beforeicon, which overturns ruling 3. Needed by Step 12.
- At a block-start link, the caret is drawn after the icon, but a typed character lands before the link (
- Decision 5. Should the maintainer run the real iPhone, Android with Gboard and Windows Chrome Chinese legs, with the merge waiting for them? Recommended: yes.
- The executor runs desktop Chrome, Safari and Firefox, macOS Pinyin and the iOS Simulator. The repo lists no executor devices, and the IME order blocks the merge.
- Cost: the merge waits for the maintainer's own device time ([Tracking] Inline link icons through a hyperlink mark view #512 Device legs).
- Alternative: name another person with those devices. Needed by Step 10.
- Decision 6. Add
jsdom26.1.0 and@types/jsdom21.1.7 to the rootcatalog, and reference them ascatalog:in the extensiondevDependencies, for unit tests? Recommended: yes. Step 6 item 1 does this. It also rewrites thecommands.test.ts#L1-L3note. Reason: ruling 15 asks for mark-view unit tests, and a mark view needs a DOM. Alternative: no jsdom. Mark-view unit tests 1 to 10 here and the 11 Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 unit tests (map 1-7, fetcher helper 1-4) are not written. Only their clean-room Cypress cases remain. This drops the unit-test part of Ruling 15 for both issues. Needed by Steps 6 and 7 (test 10), and by Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514.
Acceptance criteria
-
linkIconexists, defaults toundefined, andLinkIconContextis a named type export.LinkIconResolveris not exported. - With
linkIconunset, links render today's<a>, in the playground and the chat composer. - With
?icons=element, everyhttp(s)link has one icon as the first child of its<a>, and its text sits in one<span>. - With Decision 6 yes, unit tests 1 to 10 pass under
--isolate, each failed once under its sabotage, and the old tests keep the Step 0 count. Thecommands.test.tsnote is rewritten. -
link-icons.cy.ts, bothlink-edges.cy.tsruns andstyling.cy.tspass in Electron. Firefox ran once by hand, or the comment says it is not installed. - The Step 6
bun.lockdiff adds no package entry, and the cold install passes. - Decisions 1, 3, 4, 5 and 6 have Maintainer ruling comments. The Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 ruling exists, or Decision 2 has its own ruling. An accepted Decision 4 records the parity exception.
- The Step 10 comment exists, and every leg that ran passed, IME order included.
- The webapp hyperlink specs and the hypermultimedia suite pass after Step 11.
- Every file in Docs to update is edited.
Verification
bun run --filter @docs.plus/extension-hyperlink typecheck
bun run --filter @docs.plus/extension-hyperlink test:unit
bun run --filter @docs.plus/extension-hyperlink test
bun run --filter @docs.plus/extension-hypermultimedia test
bun run --filter @docs.plus/webapp typecheck
bun run lint
bun run lint:styles
bun run format
bun run check:agent-docsCold install after any bun.lock change: the manifest-only install in AGENTS.md:202, in a mktemp -d folder. Run its rsync with -a --prune-empty-dirs, and add --exclude='node_modules/' --exclude='.git/' --exclude='.next/' before its filters. Check that no node_modules was copied (read from code, not measured).
- Rerun Step 8 item 2: the root
bun run test:unitand the hocuspocus suite, withmake dev-localup. context7.json: compare the hyperlink rules with the new README Caveats (release-extensionsSKILL.md:21).
Docs to update
extensions/extension-hyperlink/README.md. Caveats keep only silent failures (release-extensionsSKILL.md:20).- Common tasks: "Show an icon before each link", with a minimal
linkIconthat returns a span. - Caveats:
- without
styles.cssor host CSS for.hyperlink-icon, the icon shows at natural size, and a Tailwind host breaks the line; - a resolver result that is not a fresh element with no text gives a plain link and one console error;
- a host mark above priority 1000 splits one link into two icons;
- a host
Hyperlink.extend({ addMarkView })replaces the icon view (this.parent?.()can wrap it); - legacy
linkmarks get no icon.
- without
- Common tasks: "Show an icon before each link", with a minimal
docs/api.md(§Options, §Caveats, §TypeScript, lines 5-125): alinkIconrow with "unset means the default<a>" (Decision 3); the iconed DOM shape; only safe hrefs reach the resolver; the icon starts every iconed<a>, image-led links included (Decision 1); at a block-start link, typed text appears before the icon (Decision 4); the prosemirror-view line from [Tracking] Inline link icons through a hyperlink mark view #512 §prosemirror-view floor; every README caveat;LinkIconContextin the TypeScript list.docs/styling.mdline 13: replace "skins the popovers only" and "plain<a>" with the iconed shape, and add a.hyperlink-iconrow.extensions/extension-hyperlink/AGENTS.md, new section "Link icons":<a>only throughtoDOM;isSafeHrefbefore the resolver; guard at priority 1000; keepignoreMutation; restore the caret after a sweep, and never sweep while composing; the click rule tests the point;test:unitneeds--isolate.CHANGELOG.md[Unreleased]:### AddedthelinkIconoption andLinkIconContext;### Changedrendering through a mark view and the iconed DOM shape; with Decision 2, the tested prosemirror-view moves to 1.42.6.cypress/e2e/README.md: addlink-icons.cy.ts; note thatlink-edges.cy.tsruns with and without icons.context7.jsonline 38: "extension-hyperlink ships an optional one" gains "required for icons whenlinkIconis set". The Context7 refresh is the maintainer's step on [Tracking] Inline link icons through a hyperlink mark view #512.
Out of scope
linkIconMap,linkIconFetcher, pairs, the tile class and--hl-icon-tile: Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514.- The webapp chain and SCSS, print rule, privacy paragraph, design-system row,
vertical-alignmeasurement and Chrome gates: Show a link icon before every pad link in the webapp #516. - The configurable byte cap (
LINK_ICON_MAX_BYTES, default 16 KiB): Add an icon route to link-metadata with an operator-set byte cap #515. - The y-tiptap cascade: Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520. The edit-form prefill reads: Fill the link Edit forms from the document, not from innerText #518.
- The designs [Tracking] Inline link icons through a hyperlink mark view #512 lists as rejected.
- The family release ([Tracking] Inline link icons through a hyperlink mark view #512).
Read first
AGENTS.md§Code Quality, §Test Policy, §Workflow And Review Expectations, §Dependencies, §Test Orchestration And Authoring, §Simplified English Mandate.extensions/CLAUDE.md§Standalone Extension Development.extensions/extension-hyperlink/AGENTS.md.apps/webapp/src/components/TipTap/CLAUDE.md§Editor Performance..cursor/skills/release-extensions/SKILL.md..cursor/rules/tiptap.mdc.
Activity
HMarzban commentedon Oct 18, 2026
Maintainer ruling, 2026-10-18
The maintainer answered these Decisions through a multiple-choice prompt in the agent session. Each answer below is the option the maintainer picked; the quoted label is the option text.
prosemirror-viewoverride to 1.42.6 here.linkIconunset, return the plain mark view and load no composition plugin.docs/api.md, and record it as a deliberate parity exception. Do not post on discuss.prosemirror.net.jsdom26.1.0 and@types/jsdom21.1.7 to the rootcatalog, and reference them ascatalog:in the extensiondevDependencies.Recorded on the tracker #512.