Skip to content

Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513

Open

Description

@HMarzban

Part of #512. Read the tracker first. Work on main.

Summary

  • Today. The hyperlink mark 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 or null. A mark view puts that element at the start of the <a>, outside contentDOM. With linkIcon unset, 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 a DecorationSet.
  • 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-align by measurement.
  • Ruling 15, tests. This issue owns the mark-view unit and clean-room Cypress tests.
  • Ruling 16, legacy link marks. No production count; a README caveat only.

Depends on / Blocks

Current behaviour

  1. renderHTML blanks an unsafe href and merges the host HTMLAttributes. The DOM is ['a', attrs, 0] (hyperlink.ts:263-272).
  2. The mark is not inclusive, has priority 1000 and no icon option (hyperlink.ts:86-139).
  3. One parse rule, a[href], at default priority (hyperlink.ts:247-261). An inline image's img[src] rule would also match an icon <img> (image.ts:147).
  4. Plugins. addProseMirrorPlugins returns only the interaction plugins (hyperlink.ts:303-305). createInteractions runs three times per editor (lines 296, 300, 304).
  5. Clicks. touchend and click place the caret from the click point (clickHandler.ts:150-168), after the preview mounts (:49-67). The capture-phase mousedown guard swallows every left press on a link (:108-114), so prosemirror-view's own mousedown selection never runs there (read from code).
  6. DOM readers. closest('a') at clickHandler.ts:18 and liveAnchor.ts:13-26, which matches links by textContent. view.posAtDOM(liveLink, 0) at buildPreviewOptionsFromAnchor.ts:38. No reader of a > … or a.firstChild exists (grep).
  7. Unit tests run as bun test src (package.json:42), with no DOM, and share globals and the module cache across files. --isolate gives each file a fresh global and module registry (laptop, plugin only, no view). engines asks for 1.4.0 or later, and extension CI uses setup-bun's default, latest.
  8. jsdom. jsdom@26.1.0 and @types/jsdom@21.1.7 are in bun.lock (lines 2874, 1680) through jest-environment-jsdom. Sibling devDependencies all use catalog: (package.json:84-101).
  9. 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.
  10. Playground. URL params are read at main.ts:41-49; there is no icon resolver. It imports the built dist/ (lines 1-3). cy.visitPlayground knows only popover=custom (e2e.ts:66-73). The support file hand-mirrors the playground Window type (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> is renderSpec(document, toDOM(mark, inline), null, mark.attrs) (:629); the fourth argument turns on an XSS check (to_dom.ts:204-206).
  • Tiptap's HTMLAttributes prop skips renderHTML, and addMarkView() runs per mark construction (ExtensionManager.ts:420-446).
  • With a contentDOM, ignoreMutation falls back to false (viewdesc.ts:495-497, :661-663), so an icon attribute change re-reads the mark (domobserver.ts:291-292).
  • Only contentDOM is rendered and parsed (viewdesc.ts:1051-1070, :633-636). syncToMarks tries spec.update first (:1236-1246). contenteditable=false is set on widgets only (:552-560).
  • Plugin handleDOMEvents run first; returning true skips prosemirror-view's own compositionend (input.ts:48-53, :562-573).
  • 1.42.3 isIgnorable skips only size-0 descs (capturekeys.ts:62-65); 1.42.4 also skips uneditable elements (CHANGELOG).
  • posAtDOM inside the icon: 1.42.3 reads & 2 (viewdesc.ts:251) and returns the link start. 1.42.6 reads & 4 and returns the link end (laptop, jsdom view, no layout).
  • In Safari, posAtCoords drops 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 navigator swap, which then reads vendor "Apple Computer, Inc."; jsdom has webkitFontSmoothing (laptop, jsdom view, no layout). So browser.ts sets safari and webkit true, and chrome, gecko, ios, android false (read from code). The probes used the same swap. jsdom lacks elementFromPoint, caretRangeFromPoint and ClipboardEvent (laptop, jsdom view, no layout).

Target behaviour

  • Hyperlink.configure({ linkIcon }) takes a sync (ctx: { href: string }) => HTMLElement | null. A throw counts as null and logs once per resolver. Only hrefs that pass isSafeHref reach 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

  1. Run bun run --filter @docs.plus/extension-hyperlink test:unit. Record the pass count and run time.
  2. Run grep -n '"prosemirror-view"' package.json bun.lock.
  3. 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/core 3.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 | null

Add 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. linkIconMarkView matches the file name. linkIconCompositionSweep shares pending, 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 real compositionend gave "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 ignore skips 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;
}

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 })?.pos

In 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.

  1. Catalog. In the root catalog (package.json:128), add "jsdom": "^26.1.0" and "@types/jsdom": "^21.1.7". In the package devDependencies, add both as "catalog:". Run bun install at 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 in commands.test.ts lines 1-3 (Decision 6): DOM tests now load jsdom per file.

  2. Isolation. Set test:unit to bun test --isolate src and test:unit:watch to bun test --watch --isolate src. Without it, jsdom globals leak into later files, and an earlier file has already loaded prosemirror-view with Bun's navigator (laptop, plugin only, no view). Record the new run time.

  3. src/link-icons/__tests__/setupDom.ts. Make it the first import of each DOM test file; --isolate makes 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.

  4. src/link-icons/__tests__/linkIconMarkView.test.ts. Use a real Tiptap Editor with StarterKit.configure({ link: false }) and an inline image node parsed from img[src] at default priority. The resolver returns a fresh hidden <img> for https: 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.
  5. Tests. Each names one failure. Prove each by sabotage: break the code, see the assertion fail (not a thrown error), restore. No DOM snapshots.

    1. Icon loads cause no rebuild. Toggle every icon's hidden, then (view as any).domObserver.flush(). Same nodes, same resolver count. Sabotage: drop the slot.contains line.
    2. No Image node from an icon. Parse a desc-less clone of a rendered paragraph with DOMParser.fromSchema(schema), and run view.pasteHTML(html, new Event('paste') as ClipboardEvent). jsdom has no ClipboardEvent; this package's handlePaste ignores the event (pasteHandler.ts:10). Assert 0 new image nodes. Sabotage: drop the Step 3 rule.
    3. No live javascript:, no icon on it. Insert a mark with href: 'javascript:alert(1)' by transaction. Assert href="" and no resolver call for it. Sabotage (a): build from HTMLAttributes. Sabotage (b): drop the isSafeHref check.
    4. A metadata write keeps the icon. Set title by removeMark plus addMark. Same <a> and icon, new title, no resolver call. Clearing title removes the attribute. A new href gives a new <a>. Sabotage: drop update.
    5. Typed order in the hole, one it each for (a,0), (a,1) and (a,2). Insert text "A" at that child index, collapse the selection after it, flush, then insertText('B') and insertText('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.
    6. Composed text is not lost. Set (view as any).input.composing = true, insert "A" at (a,0), flush, then dispatch new CompositionEvent('compositionend', { bubbles: true, data: 'A' }) on view.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).
    7. A removed icon comes back. Remove it and flush. It is again the first child, and the doc is unchanged.
    8. 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.
    9. 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; posAtCoords then returns null under the stubs, so the selection stays at the doc end. Step 9 proves the point branch in a real browser.
  6. Run test:unit. The count is the Step 0 count plus 11 new it blocks. Run bun run --filter @docs.plus/extension-hyperlink typecheck. tsconfig.test.json includes src/**/*, and base types: [] 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) } to hyperlink.ts.
  • In addProseMirrorPlugins (Current behaviour item 4), append linkIconCompositionSweep only when this.options.linkIcon is set. One module instance is fine: it has no state and no key. Not inside createInteractions, which runs three times.
  • Switch the test editor to Hyperlink.configure({ linkIcon: resolver }) and add test 10: unset means today's <a>. With linkIcon unset, no .hyperlink-icon, and <a> attributes equal toDOM for the same mark. Sabotage: make linkIconMarkView(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)

  1. 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 run bun install at the root. The bun.lock diff touches only prosemirror-view. Then run the cold install.
    • Item 2 runs once this item is done or skipped.
  2. With make dev-local up, because some suites need Postgres and Redis: run bun run test:unit at the root (five extension gates plus webapp Jest) and bun run --filter @docs.plus/hocuspocus test. The root package.json and bun.lock change triggers all five extension suites in CI.

Step 9. Playground and clean-room Cypress

  1. Playground (test/playground/main.ts, beside the params at lines 41-49): icons=element returns a fresh <span> with an inline background colour for every http(s) href, with no network. icons=throw throws for throw.example hrefs and returns the span for the rest. Count calls on window._iconCalls. Add _iconCalls?: number to declare global in main.ts and to the mirrored Window in cypress/support/e2e.ts. Add linkIconMap and linkIconFetcher helpers with light and dark pairs and a tile #514 adds map, fetcher, both and tile=on.
  2. Support file. Add query?: string and onBeforeLoad?: (win: Window) => void to VisitPlaygroundOptions. Build the URL from popover and query, and pass { onBeforeLoad } to cy.visit. Keep it one file.
  3. cypress/e2e/link-icons.cy.ts (ends with export {}). Call cy.focusEditor(pos) before every bare cy.realPress or cy.realType; it gates on should('have.focus').
    • typing in and around a link adds 0 icons and 0 resolver calls;
    • getHTML() and getMarkdown() have no hyperlink-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. No realClick: it misses handleDOMEvents.click (AGENTS.md §Test Orchestration);
    • icons=throw: visit with onBeforeLoad: (win) => cy.spy(win.console, 'error').as('err'); one call, plain links.
  4. link-edges.cy.ts: run every case with no query and with query: 'icons=element'. Block-start fixtures are at link-edges.cy.ts:44-56.
  5. styling.cy.ts: add '.hyperlink-icon' to PUBLIC_CLASS_NAMES (styling.cy.ts:20-28). ruleExists matches by substring.
  6. Run bun run --filter @docs.plus/extension-hyperlink test (its pretest builds dist/).
  7. 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). From extensions/extension-hyperlink, run bun 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

  1. In the package, run bunx tsup. Clear apps/webapp/.next/cache, restart the dev server and hard-refresh.
  2. Start make dev-local, and read the webapp port per [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack. From apps/webapp, run bunx cypress run --browser electron --spec 'cypress/e2e/editor/hyperlink/*.cy.js' --config baseUrl=http://localhost:<port>.
  3. In the chat composer, a link renders the same <a> as before, with no .hyperlink-icon.
  4. 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.

  1. 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.
  2. 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.
  3. Decision 3. With linkIcon unset, 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.md meaning of an unset linkIcon: "the default <a>".
    • Alternative: a plugin markViews prop. It keeps "unset means nothing", but costs one extra full redraw at mount on prefilled editors (read from code, not measured). A host addMarkView then wins with no warning.
    • Needed by Steps 7 and 12.
  4. 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 & 4 read. It does not block, and it needs your yes.
    • Alternative: a CSS ::before icon, which overturns ruling 3. Needed by Step 12.
  5. 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.
  6. Decision 6. Add jsdom 26.1.0 and @types/jsdom 21.1.7 to the root catalog, and reference them as catalog: in the extension devDependencies, for unit tests? Recommended: yes. Step 6 item 1 does this. It also rewrites the commands.test.ts#L1-L3 note. 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

  • linkIcon exists, defaults to undefined, and LinkIconContext is a named type export. LinkIconResolver is not exported.
  • With linkIcon unset, links render today's <a>, in the playground and the chat composer.
  • With ?icons=element, every http(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. The commands.test.ts note is rewritten.
  • link-icons.cy.ts, both link-edges.cy.ts runs and styling.cy.ts pass in Electron. Firefox ran once by hand, or the comment says it is not installed.
  • The Step 6 bun.lock diff 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-docs

Cold 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:unit and the hocuspocus suite, with make dev-local up.
  • context7.json: compare the hyperlink rules with the new README Caveats (release-extensions SKILL.md:21).

Docs to update

  • extensions/extension-hyperlink/README.md. Caveats keep only silent failures (release-extensions SKILL.md:20).
    • Common tasks: "Show an icon before each link", with a minimal linkIcon that returns a span.
    • Caveats:
      • without styles.css or 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 link marks get no icon.
  • docs/api.md (§Options, §Caveats, §TypeScript, lines 5-125): a linkIcon row 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; LinkIconContext in the TypeScript list.
  • docs/styling.md line 13: replace "skins the popovers only" and "plain <a>" with the iconed shape, and add a .hyperlink-icon row.
  • extensions/extension-hyperlink/AGENTS.md, new section "Link icons": <a> only through toDOM; isSafeHref before the resolver; guard at priority 1000; keep ignoreMutation; restore the caret after a sweep, and never sweep while composing; the click rule tests the point; test:unit needs --isolate.
  • CHANGELOG.md [Unreleased]: ### Added the linkIcon option and LinkIconContext; ### Changed rendering 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: add link-icons.cy.ts; note that link-edges.cy.ts runs with and without icons.
  • context7.json line 38: "extension-hyperlink ships an optional one" gains "required for icons when linkIcon is set". The Context7 refresh is the maintainer's step on [Tracking] Inline link icons through a hyperlink mark view #512.

Out of scope

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

  1. HMarzban commented on Oct 18, 2026

    @HMarzban
    CollaboratorAuthor

    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.

    Recorded on the tracker #512.

  2. added this to the v2.3.0 milestone on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions