import { syntaxTree } from '@codemirror/language' import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType, type ViewUpdate, } from '@codemirror/view' import { RangeSetBuilder, StateEffect, StateField, type EditorState, type Extension, type ChangeDesc, } from '@codemirror/state' import { Component } from '../../plugin/component' import { isOffsetRangeInTableCell } from './markdown-context' import { isSafeUrl } from './markdown-link-click-handler' import { measurePerfSample } from '../../lib/perf-profile' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { App } from '../../plugin/app' import { BaseSurface } from '../../bases/base-surface' import { getPostProcessorRevision, hasRegisteredCodeBlockProcessor, renderMarkdownCodeBlockToElement, subscribeToPostProcessorChanges, } from '../../plugin/markdown-renderer-impl' export interface FencedCodeBlockProcessorConfig { getSourcePath: () => string onNavigate?: (target: string) => void app?: App } export const invalidateFencedCodeBlockProjection = StateEffect.define() const baseControlWrite = StateEffect.define() interface FencedRange { from: number to: number language: string source: string bodyFrom: number bodyTo: number rawBody: string } function findFencedRanges(state: EditorState, doc: string): FencedRange[] { const result: FencedRange[] = [] syntaxTree(state).iterate({ enter(node) { if (node.name !== 'FencedCode') return const text = doc.slice(node.from, node.to) const firstBreak = text.indexOf('\n') if (firstBreak < 0) return false const opening = text.slice(0, firstBreak).replace(/\r$/, '') const match = opening.match(/^ {0,3}(`{3,}|~{3,})(.*)$/) if (!match) return false const info = match[2].trim() const language = info.split(/[ \t]+/, 1)[0] ?? '' if (!language) return false if (!/^[A-Za-z0-9_-]+$/.test(language) || !hasRegisteredCodeBlockProcessor(language)) return false const fence = match[1] const closing = new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*\\r?$`, 'm') const closingMatch = closing.exec(text.slice(firstBreak + 1)) if (!closingMatch) return false const closingStart = firstBreak + 1 + closingMatch.index const closingEnd = closingStart + closingMatch[0].replace(/\r$/, '').length if (closingEnd > text.length) return false const bodyStart = firstBreak + 1 const lineStart = doc.lastIndexOf('\n', node.from - 1) + 1 const nodePrefix = doc.slice(lineStart, node.from) const indent = (match[0].match(/^ */)?.[0].length ?? 0) || nodePrefix.length const rawBody = text.slice(bodyStart, closingStart).replace(/\r?\n$/, '') const body = indent === 0 ? rawBody : rawBody.replace(new RegExp(`^ {0,${indent}}`, 'gm'), '') if (isOffsetRangeInTableCell(state, node.from, node.to)) return false const bodyFrom = node.from + bodyStart const bodyTo = bodyFrom + rawBody.length result.push({ from: node.from, to: node.to, language, source: body, bodyFrom, bodyTo, rawBody }) return false }, }) return result.sort((a, b) => a.from - b.from) } function selectionTouches(state: EditorState, from: number, to: number): boolean { return state.selection.ranges.some((range) => range.from <= to && range.to >= from) } const MUTATING_DOM_METHODS = new Set([ 'append', 'appendChild', 'before', 'insertAdjacentElement', 'insertAdjacentHTML', 'insertAdjacentText', 'insertBefore', 'prepend', 'remove', 'removeChild', 'replaceChildren', 'replaceWith', 'replaceChild', 'setAttribute', 'removeAttribute', ]) const RENDER_TREE_METHODS = new Set(['closest', 'contains', 'getElementsByClassName', 'getElementsByTagName', 'matches', 'querySelector', 'querySelectorAll']) function guardedRenderTarget(target: HTMLElement, staging: HTMLElement, isActive: () => boolean): HTMLElement { const guard = (value: object): object => new Proxy(value, { get(valueTarget, property, _receiver) { if (property === 'isConnected') return target.isConnected const source = property === 'classList' || property === 'style' || property === 'dataset' || property === 'childNodes' || property === 'children' || property === 'firstChild' || property === 'lastChild' || property === 'innerHTML' || property === 'textContent' || property === 'childElementCount' || property === 'findAllSelf' || property === 'onNodeInserted' || property === 'isShown' || RENDER_TREE_METHODS.has(String(property)) ? staging : valueTarget const value = Reflect.get(source, property, source) if (property === 'classList' || property === 'style' || property === 'dataset') return guard(value as object) if (typeof value !== 'function') return value if (MUTATING_DOM_METHODS.has(String(property))) return (...args: unknown[]) => isActive() ? value.apply(staging, args) : undefined if (RENDER_TREE_METHODS.has(String(property)) || property === 'getClientRects') return (...args: unknown[]) => value.apply(source, args) return value }, set(_valueTarget, property, value, _receiver) { if (!isActive()) return true return Reflect.set(staging, property, value, staging) }, }) return guard(target) as HTMLElement } function linkifyRenderedWikilinks(root: HTMLElement, onNavigate?: (target: string) => void): void { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) const textNodes: Text[] = [] let current: Node | null while ((current = walker.nextNode())) textNodes.push(current as Text) for (const node of textNodes) { const parent = node.parentElement if (!parent || parent.closest('a, code, pre, script, style')) continue const source = node.nodeValue ?? '' const pattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]/g let match: RegExpExecArray | null let last = 0 const fragment = document.createDocumentFragment() let changed = false while ((match = pattern.exec(source))) { changed = true fragment.append(source.slice(last, match.index)) const anchor = document.createElement('a') anchor.href = match[1] anchor.textContent = match[2] ?? match[1] fragment.append(anchor) last = match.index + match[0].length } if (changed) { fragment.append(source.slice(last)) node.replaceWith(fragment) } } for (const anchor of Array.from(root.querySelectorAll('a[href]'))) { const href = anchor.getAttribute('href')?.trim() ?? '' if (isSafeUrl(href)) { anchor.target = '_blank' anchor.rel = 'noopener noreferrer' } else if (onNavigate && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) && !anchor.dataset.reamsInternalLink) { anchor.dataset.reamsInternalLink = 'true' anchor.addEventListener('click', (event) => { event.preventDefault() onNavigate(href) }) } } } class FencedProcessorWidget extends WidgetType { private active = true private owner: Component | null = null private renderTarget: HTMLElement | null = null private linkObserver: MutationObserver | null = null private reactRoot: Root | null = null private baseElement: HTMLElement | null = null private baseView: EditorView | null = null constructor( private range: FencedRange, private readonly documentSource: string, private readonly sourcePath: string, private readonly revision: number, private readonly onNavigate?: (target: string) => void, private readonly baseApp?: App, private readonly changedFence = false, private readonly controlledChange = false, ) { super() } private renderBase(): void { const view = this.baseView const element = this.baseElement if (!this.reactRoot || !view || !element || !this.baseApp) return this.reactRoot.render(createElement(BaseSurface, { app: this.baseApp, source: this.range.source, onChange: (source: string) => { if (!this.active) return null // Mapped decorations may retain this widget without calling eq(). // Resolve its mounted position against the live syntax tree. const position = view.posAtDOM(element) const current = findFencedRanges(view.state, view.state.doc.toString()).find(range => range.language === 'base' && range.from <= position && position <= range.to && range.rawBody === this.range.rawBody) if (!current) return null const { bodyFrom, bodyTo, rawBody } = current if (view.state.doc.sliceString(bodyFrom, bodyTo) !== rawBody) return null const indentation = rawBody.match(/^ */)?.[0] ?? '' const replacement = indentation ? source.split('\n').map(line => `${indentation}${line}`).join('\n') : source view.dispatch({ changes: { from: bodyFrom, to: bodyTo, insert: replacement }, effects: baseControlWrite.of(current.from) }) // CM6 may map a block widget across this transaction without calling // WidgetType.eq(). Refresh the retained React root from the live fence // even in that case, so the next control writes against current YAML. if (this.active) { const updated = findFencedRanges(view.state, view.state.doc.toString()).find(range => range.language === 'base' && range.from === current.from) if (updated) { this.range = updated; this.renderBase() } } return source }, })) } toDOM(view: EditorView): HTMLElement { if (this.range.language === 'base' && this.baseApp) { const element = document.createElement('div') element.className = 'cm-fenced-code-block-processor' element.dataset.language = 'base' // CodeMirror ignores widget events, but the browser can still place its // native caret in the surrounding contenteditable when empty widget // space is clicked. That selection reveals the fence and unmounts the // Base mid-interaction. Keep native controls working; focus the grid // explicitly when its non-control surface is clicked. element.addEventListener('mousedown', event => { if (event.button !== 0 || !(event.target instanceof Element)) return const control = event.target.closest('button, input, select, textarea, a, label, [role="button"], [contenteditable="true"]') if (control && element.contains(control)) return event.preventDefault() event.target.closest('[role="grid"][tabindex]')?.focus() }) element.addEventListener('pointerdown', event => { if (event.pointerType === 'mouse' || !(event.target instanceof Element)) return const control = event.target.closest('button, input, select, textarea, a, label, [role="button"], [contenteditable="true"]') if (control && element.contains(control)) return // Touch-action still allows panning; cancel only the synthetic caret // placement that would expose the fence under a tap/long press. event.preventDefault() }) this.reactRoot = createRoot(element) this.baseElement = element this.baseView = view this.renderBase() return element } const element = document.createElement('div') element.className = 'cm-fenced-code-block-processor cm-fenced-code-block-processor-loading' element.dataset.language = this.range.language element.dataset.sourcePath = this.sourcePath element.setAttribute('role', 'status') const loading = document.createElement('span') loading.textContent = 'Loading…' element.appendChild(loading) const renderTarget = document.createElement('div') const staging = document.createElement('div') this.renderTarget = renderTarget element.appendChild(renderTarget) const guardedTarget = guardedRenderTarget(renderTarget, staging, () => this.active) if (this.onNavigate) { element.addEventListener('click', (event) => { const anchor = (event.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null const href = anchor?.getAttribute('href')?.trim() ?? '' if (!anchor || !href || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return event.preventDefault() this.onNavigate?.(href) }) } const owner = new Component() owner.load() this.owner = owner const showError = (): void => { if (!this.active) return loading.remove() element.classList.remove('cm-fenced-code-block-processor-loading') element.classList.add('cm-fenced-code-block-processor-error') renderTarget.textContent = 'Unable to render this block' } void Promise.resolve().then(() => { if (!this.active) return 'error' as const return renderMarkdownCodeBlockToElement({ language: this.range.language, source: this.range.source, documentSource: this.documentSource, el: guardedTarget, sourcePath: this.sourcePath, component: owner, }) }).then((outcome) => { if (!this.active) return loading.remove() element.classList.remove('cm-fenced-code-block-processor-loading') if (outcome === 'empty') { element.classList.add('cm-fenced-code-block-processor-empty') renderTarget.textContent = 'No output' } else if (outcome === 'error') { element.classList.add('cm-fenced-code-block-processor-error') renderTarget.textContent = 'Unable to render this block' } else { linkifyRenderedWikilinks(staging, this.onNavigate) renderTarget.replaceChildren(staging) this.linkObserver = new MutationObserver(() => linkifyRenderedWikilinks(staging, this.onNavigate)) this.linkObserver.observe(staging, { childList: true, subtree: true, characterData: true }) element.classList.add('cm-fenced-code-block-processor-rendered') element.removeAttribute('role') } }).catch(showError) return element } destroy(): void { this.active = false if (this.reactRoot) { const root = this.reactRoot this.reactRoot = null queueMicrotask(() => root.unmount()) } this.baseElement = null this.baseView = null this.linkObserver?.disconnect() this.linkObserver = null this.owner?.unload() this.owner = null if (this.renderTarget) Element.prototype.remove.call(this.renderTarget) this.renderTarget = null } eq(other: FencedProcessorWidget): boolean { if (other.changedFence) return false if (this.range.language === 'base' && other.range.language === 'base' && this.baseApp && this.baseApp === other.baseApp && this.sourcePath === other.sourcePath && (this.range.source === other.range.source || other.controlledChange)) { // CM6 maps the replacement to its new position. Keep the mounted Base // when edits elsewhere move it, or when its own controls rewrite YAML. // External edits inside the fence still rebuild from the changed source. const changedSource = this.range.source !== other.range.source this.range = other.range if (changedSource) this.renderBase() return true } return this.range.from === other.range.from && this.range.to === other.range.to && this.range.language === other.range.language && this.range.source === other.range.source && this.documentSource === other.documentSource && this.sourcePath === other.sourcePath && this.revision === other.revision && this.baseApp === other.baseApp } ignoreEvent(event: Event): boolean { // Keep pointer, keyboard, and focus events inside rendered controls out of // CM6's selection handling. The widget remains a normal DOM interaction // surface while the editor retains its own document selection. return [ 'beforeinput', 'blur', 'click', 'compositionend', 'compositionstart', 'compositionupdate', 'dblclick', 'focus', 'input', 'keydown', 'keyup', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'touchend', 'touchstart', ].includes(event.type) } } function changedInsideFence(changes: ChangeDesc | undefined, range: FencedRange): boolean { let changed = false changes?.iterChangedRanges((_fromA, _toA, fromB, toB) => { if (fromB < range.to && toB > range.from) changed = true }) return changed } function buildDecorationsUnprofiled(state: EditorState, config: FencedCodeBlockProcessorConfig, changes?: ChangeDesc, controlledFrom?: number): DecorationSet { const builder = new RangeSetBuilder() const documentSource = state.doc.toString() for (const range of findFencedRanges(state, documentSource)) { if (selectionTouches(state, range.from, range.to)) continue builder.add( range.from, range.to, // A block replacement's default inclusive end makes a click at the // rendered fence's trailing boundary resolve through the widget and // skip the following source line in CM6's coordinate mapping. Decoration.replace({ block: true, inclusiveEnd: false, widget: new FencedProcessorWidget(range, documentSource, config.getSourcePath(), getPostProcessorRevision(), config.onNavigate, config.app, changedInsideFence(changes, range) && controlledFrom !== range.from, controlledFrom === range.from), }), ) } return builder.finish() } function buildDecorations(state: EditorState, config: FencedCodeBlockProcessorConfig, changes?: ChangeDesc, controlledFrom?: number): DecorationSet { return measurePerfSample('live-preview.decoration', 'fenced-code-block', () => buildDecorationsUnprofiled(state, config, changes, controlledFrom), { docLength: state.doc.length }) } function createField(config: FencedCodeBlockProcessorConfig): StateField { return StateField.define({ create: (state) => buildDecorations(state, config), update(value, transaction) { if (transaction.docChanged || transaction.selection || transaction.effects.some((effect) => effect.is(invalidateFencedCodeBlockProjection))) { const controlled = transaction.effects.find(effect => effect.is(baseControlWrite)) return buildDecorations(transaction.state, config, transaction.docChanged ? transaction.changes : undefined, controlled?.value) } return value }, provide: (field) => EditorView.decorations.from(field), }) } export function fencedCodeBlockProcessor(config: FencedCodeBlockProcessorConfig): Extension { const field = createField(config) const observer = ViewPlugin.fromClass(class { private unsubscribe: (() => void) | null = null private destroyed = false private invalidationPending = false constructor(private readonly view: EditorView) { this.unsubscribe = subscribeToPostProcessorChanges(() => this.invalidate()) this.invalidate() } update(update: ViewUpdate): void { if (syntaxTree(update.startState) !== syntaxTree(update.state)) this.invalidate() } private invalidate(): void { if (this.destroyed || this.invalidationPending) return this.invalidationPending = true queueMicrotask(() => { this.invalidationPending = false if (!this.destroyed) this.view.dispatch({ effects: invalidateFencedCodeBlockProjection.of(getPostProcessorRevision()) }) }) } destroy(): void { this.destroyed = true this.unsubscribe?.() this.unsubscribe = null } }) return [field, observer] }