Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
17 kB · 386 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387// src/editor/extensions/markdown-link-click-handler.ts/** * Top-level CM6 click handler for markdown links and wikilinks. * * Runs in source, live-preview, and read modes (registered outside the * livePreviewCompartment). Discriminates by class on the click target's * nearest ancestor: * * - `.cm-md-inline-link-label` → window.open(data-href, '_blank', 'noopener,noreferrer') * - `.cm-md-bare-url` → window.open(slice, ...) where slice is the URL substring * - `.cm-wikilink-chip` → wikilinkHandlers().onNavigateWikilink(data-target) * - `.cm-md-url` (source mode) → window.open(slice, ...) (autolink/bare URL in source) * - raw `[[target]]` text (source) → wikilinkHandlers().onNavigateWikilink(target) * * Plain left-click only. Right-click, middle-click, modified clicks are * never intercepted (return false ⇒ CM6 default behaviour applies). * * The handler factory accepts a thunk so React-side handler refs can be * read at click time (matching how CM6Editor.tsx wires WikilinkConfig). * * TODO(browser-smoke): the source-mode raw-bracket click path uses * view.posAtCoords which depends on real layout. jsdom has empty * bounding rects so synthesized clicks return null; we test the pure * resolveWikilinkAtPosition() helper instead. End-to-end coverage of * coordinate-driven click resolution should land as a browser smoke * test in tests/browser/. */import { EditorView } from '@codemirror/view'import { syntaxTree } from '@codemirror/language'import type { Extension } from '@codemirror/state'import type { EditorState } from '@codemirror/state'import type { WikilinkHandlers } from '../../layout'import { extendBareUrlForPort } from './inline-link-decoration'
const WIKILINK_RE_GLOBAL = /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g
const SAFE_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'] as const
/** * SECURITY: only navigate to URLs whose scheme is in the safe allow-list. * Without this guard, `[click](javascript:alert(document.cookie))` would * execute on click. We reject `javascript:`, `data:`, `file:`, `vbscript:`, * and any other unrecognised scheme. Relative URLs (no scheme) are also * rejected because they have no defined navigation behaviour outside a * page context. The allow-list mirrors the URL paste regex's accepted * schemes — what we render as a hyperlink, we navigate. */export function isSafeUrl(url: string): boolean { const trimmed = url.trim() if (!trimmed) return false // Match the scheme prefix up to the first colon. RFC 3986: scheme = // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed) if (!match) return false const scheme = (match[1] + ':').toLowerCase() return (SAFE_SCHEMES as readonly string[]).includes(scheme)}
function openExternal(url: string): void { // SECURITY: scheme allow-list rejects javascript:/data:/file:/etc. if (!isSafeUrl(url)) return window.open(url, '_blank', 'noopener,noreferrer')}
function isSourceMode(view: EditorView): boolean { const container = view.dom.closest('.cm6-editor-container') as HTMLElement | null // Headless/unit-test views often mount the EditorView directly. Preserve the // historical fallback there; real app views always carry data-mode. return container?.dataset.mode !== undefined ? container.dataset.mode === 'source' : true}
/** * Resolve a document position to a URL by walking the syntax tree and * finding the nearest URL / Autolink ancestor. Returns the URL text * (with angle brackets stripped for Autolink), or null if no URL node * is found at this position. * * Why this exists (vs. reading textContent off the click target): on * narrow viewports CM6 soft-wraps long lines, so a single Autolink / * URL syntax range can render as multiple DOM spans. `closest('.cm-md-bare-url')` * returns ONE fragment; reading its `textContent` would give a partial * URL (e.g. `https://example` for `https://example.com/very/long/path`) * and `window.open` would navigate to the wrong destination. Slicing * the document by the syntax-tree node's range is robust regardless of * DOM fragmentation. Mirrors the source-mode wikilink resolution path * (which already uses posAtCoords + doc.sliceString). */export function resolveUrlAtPosition(state: EditorState, pos: number): string | null { let node = syntaxTree(state).resolveInner(pos, 1) // Walk up to a URL, Autolink, or Link. while (node) { if (node.name === 'URL' || node.name === 'Autolink') { // Autolink covers `<https://…>` including angle brackets — Lezer // respects the closing `>` so the range is already correct. if (node.name === 'Autolink') { const text = state.doc.sliceString(node.from, node.to) if (text.startsWith('<') && text.endsWith('>')) { return text.slice(1, -1) } return text } // Top-level URL: apply the same `:port[/path]` extension that the // decoration uses so click → open targets the full URL. Without // this, clicking a soft-wrapped `https://example.com:8080/foo` // would navigate to `https://example.com` because Lezer's node // range stops at the colon. Skip extension for URL children of a // Link (the parens-bounded destination is already complete). if (node.parent && node.parent.name === 'Link') { return state.doc.sliceString(node.from, node.to) } const docText = state.doc.toString() const extendedTo = extendBareUrlForPort(docText, node.from, node.to) return docText.slice(node.from, extendedTo) } // If we walked up to a Link, descend to find its URL child. This // handles the case where `pos` lands on a LinkMark (one of the // brackets/parens) rather than the URL substring — e.g. clicks on // the trailing `)` of `[label](url)`, or on the external-link icon // immediately after the link (the icon is at `node.to` of the // Link, exclusive; `pos - 1` lands on `)` which is a LinkMark, and // walking up we hit Link before URL). if (node.name === 'Link') { let child = node.firstChild while (child) { if (child.name === 'URL') { return state.doc.sliceString(child.from, child.to) } child = child.nextSibling } return null // reference-style or malformed Link with no URL child } if (!node.parent) break node = node.parent } return null}
/** * Pure resolver — given a doc-like object with `lineAt` and a position, * return the wikilink target if `pos` is inside `[[…]]` (and not inside * an embed `![[…]]`), or null otherwise. * * Exported for direct unit testing because synthetic click events in * jsdom have unusable coordinates (no layout), so the click-event-driven * path can't be exercised end-to-end without a real browser. */export function resolveWikilinkAtPosition( doc: { lineAt: (pos: number) => { from: number; text: string } }, pos: number,): string | null { const line = doc.lineAt(pos) WIKILINK_RE_GLOBAL.lastIndex = 0 let match: RegExpExecArray | null while ((match = WIKILINK_RE_GLOBAL.exec(line.text)) !== null) { const from = line.from + match.index const to = from + match[0].length if (pos >= from && pos < to) { // Skip embeds: `![[image.png]]` should not navigate. if (match.index > 0 && line.text[match.index - 1] === '!') return null return match[1] } } return null}
export function markdownLinkClickHandler(getHandlers: () => WikilinkHandlers): Extension { return EditorView.domEventHandlers({ click(event, view) { // Only plain left clicks. Right/middle clicks or modifier-key // clicks fall through to CM6 default. if (event.button !== 0) return false if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return false
// event.target may be a Text node in real browsers (clicks on // text characters resolve to the text node, not its parent // element). Text doesn't have .closest(), so we promote to the // nearest Element ancestor before any DOM traversal. const rawTarget = event.target as Node | null const target = rawTarget instanceof Element ? (rawTarget as HTMLElement) : (rawTarget?.parentElement ?? null) if (!target) return false
// Rendered plugin results (including fenced Dataview views) use normal // anchors. Reuse the editor's navigation callback for vault-relative // links, while preserving the safe external-link browser behavior. const renderedAnchor = target.closest('.cm-fenced-code-block-processor a[href]') as HTMLAnchorElement | null if (renderedAnchor) { const href = renderedAnchor.getAttribute('href')?.trim() ?? '' if (!href) return false event.preventDefault() if (isSafeUrl(href)) { openExternal(href) return true } if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return true let targetPath = href try { targetPath = decodeURIComponent(href) } catch { /* keep the original path */ } getHandlers().onNavigateWikilink(targetPath) return true }
// 1. Live-preview Link widget label const labelEl = target.closest('.cm-md-inline-link-label') as HTMLElement | null if (labelEl) { const href = labelEl.getAttribute('data-href') ?? '' if (href) { event.preventDefault() openExternal(href) return true } }
// Resolve click → document position. Used by branches 2, 4, 5 // below. posAtCoords is the primary path (uses real mouse // coordinates — robust against soft-wrapped lines where the click // target is one of several DOM fragments rendering the same syntax // range). posAtDOM is a fallback for environments without layout // (e.g. jsdom). Both can throw on disconnected DOM; wrap in try/catch. let pos: number | null = null try { const coordPos = view.posAtCoords( { x: event.clientX, y: event.clientY }, false, ) pos = typeof coordPos === 'number' ? coordPos : null } catch { pos = null } if (pos === null) { try { const domPos = view.posAtDOM(target) pos = typeof domPos === 'number' ? domPos : null } catch { pos = null } }
// 2a. External-link icon widget (live-preview, read mode). // The `ExternalLinkIconWidget` (a small diagonal arrow appended // after each bare URL / link by inline-link-decoration.ts) carries // class `.cm-md-external-link-icon`. Without this branch, a click // on the icon (a ~12px target) lands on a DOM node OUTSIDE the // bare-URL mark's range — `closest('.cm-md-bare-url')` would // return null and the click would silently fail. That matters on // mobile where a fat-finger tap can easily land 1-2px past the // URL text and hit the icon. // // We resolve the URL via `posAtDOM(iconEl)` rather than the // shared `pos` (which uses posAtCoords). The icon is at `node.to` // (just past the link's last char), so `posAtDOM` returns the // URL's end offset; walk one position back via `pos - 1` to land // INSIDE the URL/Autolink node so the syntax-tree resolver finds // it. We deliberately don't reuse the shared `pos` because in // jsdom `posAtCoords({x:0,y:0})` returns `0`, which masks the // real icon position. const iconEl = target.closest('.cm-md-external-link-icon') as HTMLElement | null if (iconEl) { let iconPos: number | null = null try { const p = view.posAtDOM(iconEl) iconPos = typeof p === 'number' ? p : null } catch { iconPos = null } if (iconPos !== null && iconPos > 0) { const url = resolveUrlAtPosition(view.state, iconPos - 1) if (url) { event.preventDefault() openExternal(url) return true } } }
// 2. Bare URL or autolink URL (live-preview). // The `.cm-md-bare-url` mark covers either: // - a top-level GFM URL node (bare `https://example.com`), or // - the WHOLE `<https://…>` Autolink range including angle brackets. // // FRAGMENTATION SAFETY: when the editor soft-wraps a long URL, CM6 // splits the rendered span into multiple DOM nodes that EACH carry // `cm-md-bare-url`. `target.closest('.cm-md-bare-url')` returns a // single fragment, so reading its textContent yields a PARTIAL // URL — e.g. `https://example` for `https://example.com/long/path`. // We instead resolve the click position to the document, walk the // syntax tree to the URL/Autolink node, and slice the doc by the // node's range. This is the same pattern used for source-mode // wikilinks (branch 5 below). See review #4. const bareEl = target.closest('.cm-md-bare-url') as HTMLElement | null if (bareEl) { let url: string | null = null if (pos !== null && pos >= 0) { url = resolveUrlAtPosition(view.state, pos) } if (!url) { // Fallback when posAtCoords / posAtDOM yielded no usable // position (e.g. jsdom synthetic clicks, or layout not // computed yet). For SHORT URLs that fit on one line, the // fragment's textContent IS the full URL and this is safe. // For long soft-wrapped URLs in real browsers, posAtCoords // resolves correctly and we never hit this branch. let txt = (bareEl.textContent ?? '').trim() if (txt.startsWith('<') && txt.endsWith('>')) { txt = txt.slice(1, -1) } if (txt) url = txt } if (url) { event.preventDefault() openExternal(url) return true } }
// 3. Wikilink chip widget (live-preview). const chipEl = target.closest('.cm-wikilink-chip') as HTMLElement | null if (chipEl) { const wikilinkTarget = chipEl.getAttribute('data-target') ?? '' if (wikilinkTarget) { // The widget already has its own click listener that calls // onNavigate. That listener stops propagation, so we should // never actually receive the event here. Defensive: if we DO // get it (widget destroyed mid-event, etc.), navigate. event.preventDefault() getHandlers().onNavigateWikilink(wikilinkTarget) return true } }
// If a rendered wikilink chip exists on this visual line, only the chip // itself is clickable. Browser coordinate mapping can resolve nearby // whitespace on the same line back into the replaced wikilink's document // range; without this guard branch 5 treats the whole line as a link. const lineEl = target.closest('.cm-line') as HTMLElement | null if (lineEl?.querySelector('.cm-wikilink-chip')) return false
// 4. `.cm-md-url` highlight-style class — fires whenever the click // target lacks a `.cm-md-bare-url` ancestor but lands on a URL // span emitted by `darkHighlightStyle` (see highlight-style.ts:48 // mapping `tags.url → cm-md-url`). This covers: // - SOURCE mode: `inlineLinkDecoration` is NOT mounted, so no // `.cm-md-bare-url` exists; the only URL class is `.cm-md-url`. // Both bare URLs and the URL substring of `[label](url)` get // this class. // - LIVE-PREVIEW cursor-near reveal of `[label](url)`: the URL // substring shows raw and gets BOTH classes; this branch is // unreachable because branch 2 matches first. // Same fragmentation safety applies — use posAtCoords + syntax // tree slice, not textContent. const urlEl = target.closest('.cm-md-url') as HTMLElement | null if (urlEl && pos !== null && pos >= 0) { const url = resolveUrlAtPosition(view.state, pos) if (url) { event.preventDefault() openExternal(url) return true } // Fallback to textContent if syntax tree didn't resolve a URL // node (e.g. tree not yet built in tests). The prior production // behaviour relied on textContent; preserve it as a last resort. const fallback = (urlEl.textContent ?? '').trim() if (fallback) { event.preventDefault() openExternal(fallback) return true } }
// 5. Source-mode raw [[target]] text. The wikilink chip widget is // absent in source mode; the [[…]] is rendered as raw text styled // by obsidianSyntaxDecoration. Use the resolved `pos` (from above) // to find a wikilink at this line position. if (isSourceMode(view) && pos !== null && pos >= 0) { const wikilinkTarget = resolveWikilinkAtPosition(view.state.doc, pos) if (wikilinkTarget) { event.preventDefault() getHandlers().onNavigateWikilink(wikilinkTarget) return true } }
return false }, })}