|
@@ -41,12 +41,16 @@ interface ParsedToken {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Cached token for a single textblock (paragraph).
|
|
|
|
|
|
|
+ * Cached token for the combined content of all paragraphs in a block.
|
|
|
* The token array is the source of truth — decorations are derived.
|
|
* The token array is the source of truth — decorations are derived.
|
|
|
*/
|
|
*/
|
|
|
interface BlockTokenCache {
|
|
interface BlockTokenCache {
|
|
|
- /** Text content this cache was built from */
|
|
|
|
|
|
|
+ /** Combined text of all paragraphs (joined with \n) */
|
|
|
text: string
|
|
text: string
|
|
|
|
|
+ /** Cumulative start offsets of each paragraph in the combined text.
|
|
|
|
|
+ * For N paragraphs, boundaries has N+1 entries — the last is the
|
|
|
|
|
+ * combined text length (excluding the trailing \n on the last para). */
|
|
|
|
|
+ boundaries: number[]
|
|
|
/** Lezer AST tree for incremental re-parsing */
|
|
/** Lezer AST tree for incremental re-parsing */
|
|
|
tree?: Tree
|
|
tree?: Tree
|
|
|
/** Inline tokens (bold, links, refs, etc.) */
|
|
/** Inline tokens (bold, links, refs, etc.) */
|
|
@@ -63,17 +67,19 @@ interface BlockTokenCache {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Parse tokens for a textblock. Returns cached structure.
|
|
|
|
|
|
|
+ * Parse tokens for the combined text of a block. Returns cached structure.
|
|
|
* Only runs when document content changes.
|
|
* Only runs when document content changes.
|
|
|
*/
|
|
*/
|
|
|
function parseBlockTokens(
|
|
function parseBlockTokens(
|
|
|
text: string,
|
|
text: string,
|
|
|
|
|
+ boundaries: number[],
|
|
|
engine: MarkdownRuleEngine,
|
|
engine: MarkdownRuleEngine,
|
|
|
oldTree?: Tree,
|
|
oldTree?: Tree,
|
|
|
): BlockTokenCache {
|
|
): BlockTokenCache {
|
|
|
const result = parseMarkdown(text, engine, oldTree)
|
|
const result = parseMarkdown(text, engine, oldTree)
|
|
|
return {
|
|
return {
|
|
|
text,
|
|
text,
|
|
|
|
|
+ boundaries,
|
|
|
tree: result.tree,
|
|
tree: result.tree,
|
|
|
content: result.content as ParsedToken[],
|
|
content: result.content as ParsedToken[],
|
|
|
blocks: result.blocks as ParsedToken[],
|
|
blocks: result.blocks as ParsedToken[],
|
|
@@ -81,6 +87,74 @@ function parseBlockTokens(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Collect all non-code textblocks from a PM doc.
|
|
|
|
|
+ */
|
|
|
|
|
+interface ParagraphInfo {
|
|
|
|
|
+ node: ProsemirrorNode
|
|
|
|
|
+ pos: number
|
|
|
|
|
+ text: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function collectParagraphs(doc: ProsemirrorNode): ParagraphInfo[] {
|
|
|
|
|
+ const paragraphs: ParagraphInfo[] = []
|
|
|
|
|
+ doc.descendants((node: ProsemirrorNode, pos: number) => {
|
|
|
|
|
+ if (!node.isTextblock) return
|
|
|
|
|
+ if (node.type.spec.code) return
|
|
|
|
|
+ paragraphs.push({ node, pos, text: node.textContent })
|
|
|
|
|
+ })
|
|
|
|
|
+ return paragraphs
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Build the combined text (joined by \n) and boundary offsets.
|
|
|
|
|
+ * boundaries[0] = 0, boundaries[i] = start offset of paragraph i,
|
|
|
|
|
+ * boundaries[N] = total length (last para end, no trailing \n).
|
|
|
|
|
+ */
|
|
|
|
|
+function buildCombinedText(paragraphs: ParagraphInfo[]): {
|
|
|
|
|
+ text: string
|
|
|
|
|
+ boundaries: number[]
|
|
|
|
|
+} {
|
|
|
|
|
+ const parts: string[] = []
|
|
|
|
|
+ const boundaries: number[] = [0]
|
|
|
|
|
+ for (const p of paragraphs) {
|
|
|
|
|
+ parts.push(p.text)
|
|
|
|
|
+ boundaries.push(boundaries[boundaries.length - 1]! + p.text.length + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+ return { text: parts.join("\n"), boundaries }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Map a combined-text offset to PM document position.
|
|
|
|
|
+ * Returns null if the offset falls on a \n separator between paragraphs.
|
|
|
|
|
+ */
|
|
|
|
|
+interface ResolvedOffset {
|
|
|
|
|
+ paraIndex: number
|
|
|
|
|
+ paraOffset: number
|
|
|
|
|
+ pmPos: number
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveOffset(
|
|
|
|
|
+ combinedOffset: number,
|
|
|
|
|
+ boundaries: number[],
|
|
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
|
|
+): ResolvedOffset | null {
|
|
|
|
|
+ for (let i = 0; i < paragraphs.length; i++) {
|
|
|
|
|
+ const start = boundaries[i]!
|
|
|
|
|
+ const end = boundaries[i + 1]!
|
|
|
|
|
+ // end includes the \n separator, so para text is [start, end - 1)
|
|
|
|
|
+ // <= accepts end - 1 (the \n) for block-wrapper positioning
|
|
|
|
|
+ if (combinedOffset >= start && combinedOffset <= end - 1) {
|
|
|
|
|
+ const paraOffset = combinedOffset - start
|
|
|
|
|
+ const pmPos = paragraphs[i]!.pos + 1 + paraOffset
|
|
|
|
|
+ return { paraIndex: i, paraOffset, pmPos }
|
|
|
|
|
+ }
|
|
|
|
|
+ // combinedOffset === end - 1 means the \n separator
|
|
|
|
|
+ // combinedOffset > end - 1 means beyond this paragraph
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Build decorations from cached tokens.
|
|
* Build decorations from cached tokens.
|
|
|
* Runs lazily in props.decorations — only when ProseMirror asks for them.
|
|
* Runs lazily in props.decorations — only when ProseMirror asks for them.
|
|
@@ -94,131 +168,283 @@ function buildDecorationsFromCache(
|
|
|
): DecorationSet {
|
|
): DecorationSet {
|
|
|
const decorationSpecs: Decoration[] = []
|
|
const decorationSpecs: Decoration[] = []
|
|
|
|
|
|
|
|
- const paragraphs: {
|
|
|
|
|
- node: ProsemirrorNode
|
|
|
|
|
- pos: number
|
|
|
|
|
- cache: BlockTokenCache
|
|
|
|
|
- }[] = []
|
|
|
|
|
|
|
+ const paragraphs = collectParagraphs(doc)
|
|
|
|
|
+ if (paragraphs.length === 0) return DecorationSet.empty
|
|
|
|
|
|
|
|
- doc.descendants((node: ProsemirrorNode, pos: number) => {
|
|
|
|
|
- if (!node.isTextblock) return
|
|
|
|
|
- if (node.type.spec.code) return
|
|
|
|
|
- const cache = contentCaches.get(node.textContent)
|
|
|
|
|
- if (!cache) return
|
|
|
|
|
- paragraphs.push({ node, pos, cache })
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const { text: combinedText, boundaries } = buildCombinedText(paragraphs)
|
|
|
|
|
+
|
|
|
|
|
+ const cache = contentCaches.get(combinedText)
|
|
|
|
|
+ if (!cache) return DecorationSet.empty
|
|
|
|
|
|
|
|
let calloutType: string | null = null
|
|
let calloutType: string | null = null
|
|
|
|
|
|
|
|
- for (const { node, pos, cache } of paragraphs) {
|
|
|
|
|
- const blockStart = pos + 1
|
|
|
|
|
-
|
|
|
|
|
- // Track whether this paragraph has callout or blockquote blocks
|
|
|
|
|
- // so we can correctly reset calloutType between separate callout groups.
|
|
|
|
|
- let hasCallout = false
|
|
|
|
|
- let hasBlockquote = false
|
|
|
|
|
- for (const block of cache.blocks) {
|
|
|
|
|
- if (block.type === "callout") {
|
|
|
|
|
- hasCallout = true
|
|
|
|
|
- calloutType =
|
|
|
|
|
- (block as BlockDecoration & { type: "callout" }).metadata
|
|
|
|
|
- ?.calloutType ?? null
|
|
|
|
|
- }
|
|
|
|
|
- if (block.type === "blockquote") {
|
|
|
|
|
- hasBlockquote = true
|
|
|
|
|
|
|
+ for (const block of cache.blocks) {
|
|
|
|
|
+ // Track callout type from callout blocks
|
|
|
|
|
+ if (block.type === "callout") {
|
|
|
|
|
+ calloutType =
|
|
|
|
|
+ (block as BlockDecoration & { type: "callout" }).metadata
|
|
|
|
|
+ ?.calloutType ?? null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // If this is a blockquote that should be rendered as callout continuation
|
|
|
|
|
+ if (calloutType && block.type === "blockquote") {
|
|
|
|
|
+ const continuationBlock = {
|
|
|
|
|
+ ...block,
|
|
|
|
|
+ type: "callout" as const,
|
|
|
|
|
+ nodeWrapper: {
|
|
|
|
|
+ className: "md-callout",
|
|
|
|
|
+ attrs: { "data-callout-type": calloutType },
|
|
|
|
|
+ },
|
|
|
|
|
+ metadata: { calloutType },
|
|
|
}
|
|
}
|
|
|
|
|
+ applyBlockDecorations(
|
|
|
|
|
+ continuationBlock,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ decorationSpecs,
|
|
|
|
|
+ isFocused,
|
|
|
|
|
+ markerMode,
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ applyBlockDecorations(
|
|
|
|
|
+ block,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ decorationSpecs,
|
|
|
|
|
+ isFocused,
|
|
|
|
|
+ markerMode,
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // If this paragraph has no callout-related blocks, the callout
|
|
|
|
|
- // group has ended — reset so subsequent blockquotes aren't
|
|
|
|
|
- // incorrectly merged into the previous callout.
|
|
|
|
|
- if (!hasCallout && !hasBlockquote) {
|
|
|
|
|
|
|
+ // Track whether a subsequent non-callout/blockquote resets the group
|
|
|
|
|
+ if (block.type !== "callout" && block.type !== "blockquote") {
|
|
|
calloutType = null
|
|
calloutType = null
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- for (const block of cache.blocks) {
|
|
|
|
|
- if (calloutType && block.type === "blockquote") {
|
|
|
|
|
- const continuationBlock = {
|
|
|
|
|
- ...block,
|
|
|
|
|
- type: "callout" as const,
|
|
|
|
|
- nodeWrapper: {
|
|
|
|
|
- className: "md-callout",
|
|
|
|
|
- attrs: { "data-callout-type": calloutType },
|
|
|
|
|
- },
|
|
|
|
|
- metadata: { calloutType },
|
|
|
|
|
- }
|
|
|
|
|
- if (continuationBlock.nodeWrapper) {
|
|
|
|
|
|
|
+ for (const token of cache.content) {
|
|
|
|
|
+ applyInlineDecorations(
|
|
|
|
|
+ token,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ decorationSpecs,
|
|
|
|
|
+ isFocused,
|
|
|
|
|
+ markerMode,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Properties — keyed by single-line paragraphs
|
|
|
|
|
+ if (cache.properties.length > 0) {
|
|
|
|
|
+ buildPropertyDecorations(
|
|
|
|
|
+ cache.properties,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ decorationSpecs,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.debug("build decorations", {
|
|
|
|
|
+ count: decorationSpecs.length,
|
|
|
|
|
+ paragraphs: paragraphs.length,
|
|
|
|
|
+ })
|
|
|
|
|
+ return DecorationSet.create(doc, decorationSpecs)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Apply block-level decorations (node wrapper + hidden/visible ranges).
|
|
|
|
|
+ */
|
|
|
|
|
+function applyBlockDecorations(
|
|
|
|
|
+ block: ParsedToken,
|
|
|
|
|
+ boundaries: number[],
|
|
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
|
|
+ decorationSpecs: Decoration[],
|
|
|
|
|
+ isFocused: boolean,
|
|
|
|
|
+ markerMode: MarkerVisibilityMode,
|
|
|
|
|
+) {
|
|
|
|
|
+ const ranges = block.decorationRanges
|
|
|
|
|
+ if (ranges.length === 0 && !block.nodeWrapper) return
|
|
|
|
|
+
|
|
|
|
|
+ const sorted = [...ranges].sort((a, b) => a.from - b.from)
|
|
|
|
|
+
|
|
|
|
|
+ // ── Table: hide source when blurred, render widget ──
|
|
|
|
|
+ if (block.type === "table") {
|
|
|
|
|
+ // ── Apply node wrapper (same for both focused and blurred) ──
|
|
|
|
|
+ if (block.nodeWrapper) {
|
|
|
|
|
+ const firstRange = sorted[0]
|
|
|
|
|
+ const lastRange = sorted[sorted.length - 1]
|
|
|
|
|
+ if (firstRange && lastRange) {
|
|
|
|
|
+ const resolvedFirst = resolveOffset(
|
|
|
|
|
+ firstRange.from,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ )
|
|
|
|
|
+ // lastRange.to is the exclusive end — may land on a \n separator
|
|
|
|
|
+ // boundary where resolveOffset returns null (no node wrapper).
|
|
|
|
|
+ const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
|
|
|
|
|
+ if (resolvedFirst && resolvedLast) {
|
|
|
|
|
+ const fromNode = paragraphs[resolvedFirst.paraIndex]!
|
|
|
|
|
+ const toNode = paragraphs[resolvedLast.paraIndex]!
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
- Decoration.node(pos, pos + node.nodeSize, {
|
|
|
|
|
- class: continuationBlock.nodeWrapper.className ?? undefined,
|
|
|
|
|
- ...continuationBlock.nodeWrapper.attrs,
|
|
|
|
|
- } as Record<string, string>),
|
|
|
|
|
|
|
+ Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
|
|
|
|
|
+ class: block.nodeWrapper.className ?? undefined,
|
|
|
|
|
+ ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
|
|
|
|
|
+ }),
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
- buildTokenDecorations(
|
|
|
|
|
- continuationBlock,
|
|
|
|
|
- blockStart,
|
|
|
|
|
- decorationSpecs,
|
|
|
|
|
- isFocused,
|
|
|
|
|
- markerMode,
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!isFocused) {
|
|
|
|
|
+ const firstRange = sorted[0]
|
|
|
|
|
+ const tokenStart = firstRange
|
|
|
|
|
+ ? resolveOffset(firstRange.from, boundaries, paragraphs)
|
|
|
|
|
+ : null
|
|
|
|
|
+ const source = block.wrapperAttrs?.["data-table-source"] ?? ""
|
|
|
|
|
+ for (const range of sorted) {
|
|
|
|
|
+ const specs = buildDecorationsForRange(
|
|
|
|
|
+ range,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
)
|
|
)
|
|
|
- } else {
|
|
|
|
|
- if (block.nodeWrapper) {
|
|
|
|
|
|
|
+ for (const spec of specs) {
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
- Decoration.node(pos, pos + node.nodeSize, {
|
|
|
|
|
- class: block.nodeWrapper.className ?? undefined,
|
|
|
|
|
- ...block.nodeWrapper.attrs,
|
|
|
|
|
- } as Record<string, string>),
|
|
|
|
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
|
|
+ class: "md-table-hidden",
|
|
|
|
|
+ }),
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
- buildTokenDecorations(
|
|
|
|
|
- block,
|
|
|
|
|
- blockStart,
|
|
|
|
|
- decorationSpecs,
|
|
|
|
|
- isFocused,
|
|
|
|
|
- markerMode,
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ if (source && tokenStart) {
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.widget(
|
|
|
|
|
+ tokenStart.pmPos,
|
|
|
|
|
+ (view) => {
|
|
|
|
|
+ const widgetSpan = document.createElement("span")
|
|
|
|
|
+ widgetSpan.className = "md-table-rendered"
|
|
|
|
|
+ // Rendered HTML from user source — cell content is escaped
|
|
|
|
|
+ // inside renderCellContent, but inline-math injects KaTeX
|
|
|
|
|
+ // output via innerHTML. This is local-editor-context safe.
|
|
|
|
|
+ widgetSpan.innerHTML = renderTableHtml(source)
|
|
|
|
|
+ widgetSpan.addEventListener("mousedown", (e) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ e.stopPropagation()
|
|
|
|
|
+ view.dispatch(
|
|
|
|
|
+ view.state.tr.setSelection(
|
|
|
|
|
+ TextSelection.near(
|
|
|
|
|
+ view.state.doc.resolve(tokenStart.pmPos),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ view.focus()
|
|
|
|
|
+ })
|
|
|
|
|
+ return widgetSpan
|
|
|
|
|
+ },
|
|
|
|
|
+ { side: -1 },
|
|
|
|
|
+ ),
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- for (const token of cache.content) {
|
|
|
|
|
- buildTokenDecorations(
|
|
|
|
|
- token,
|
|
|
|
|
- blockStart,
|
|
|
|
|
- decorationSpecs,
|
|
|
|
|
- isFocused,
|
|
|
|
|
- markerMode,
|
|
|
|
|
|
|
+ // ── Focused: show source with | as muted decorators ──
|
|
|
|
|
+ const source = block.wrapperAttrs?.["data-table-source"] ?? ""
|
|
|
|
|
+ const tableStart = sorted[0]!.from
|
|
|
|
|
+ let idx = 0
|
|
|
|
|
+ for (;;) {
|
|
|
|
|
+ idx = source.indexOf("|", idx)
|
|
|
|
|
+ if (idx === -1) break
|
|
|
|
|
+ const combinedFrom = tableStart + idx
|
|
|
|
|
+ const combinedTo = tableStart + idx + 1
|
|
|
|
|
+ const specs = buildDecorationsForRange(
|
|
|
|
|
+ { type: "hidden", from: combinedFrom, to: combinedTo },
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
)
|
|
)
|
|
|
|
|
+ for (const spec of specs) {
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
|
|
+ class: "md-decorator",
|
|
|
|
|
+ ...(block.wrapperAttrs ?? {}),
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ idx++
|
|
|
}
|
|
}
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- buildPropertyDecorations(
|
|
|
|
|
- cache.properties,
|
|
|
|
|
- blockStart,
|
|
|
|
|
- decorationSpecs,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ // ── Blockquote / callout continuation merge support ──
|
|
|
|
|
+ if (block.nodeWrapper) {
|
|
|
|
|
+ const firstRange = sorted[0]
|
|
|
|
|
+ const lastRange = sorted[sorted.length - 1]
|
|
|
|
|
+ if (firstRange && lastRange) {
|
|
|
|
|
+ const resolvedFirst = resolveOffset(
|
|
|
|
|
+ firstRange.from,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ )
|
|
|
|
|
+ // lastRange.to may point at a \n separator → resolveOffset returns null
|
|
|
|
|
+ // (node wrapper is silently dropped — fine for block text ranges).
|
|
|
|
|
+ const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
|
|
|
|
|
+ if (resolvedFirst && resolvedLast) {
|
|
|
|
|
+ const fromNode = paragraphs[resolvedFirst.paraIndex]!
|
|
|
|
|
+ const toNode = paragraphs[resolvedLast.paraIndex]!
|
|
|
|
|
+ const startPos = fromNode.pos
|
|
|
|
|
+ const endPos = toNode.pos + toNode.node.nodeSize
|
|
|
|
|
+ const wrapperClassName = block.nodeWrapper?.className
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.node(startPos, endPos, {
|
|
|
|
|
+ class: wrapperClassName ?? undefined,
|
|
|
|
|
+ ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- log.debug("build decorations", {
|
|
|
|
|
- count: decorationSpecs.length,
|
|
|
|
|
- paragraphs: paragraphs.length,
|
|
|
|
|
- })
|
|
|
|
|
- return DecorationSet.create(doc, decorationSpecs)
|
|
|
|
|
|
|
+ // ── Decorations for each range ──
|
|
|
|
|
+ for (const range of sorted) {
|
|
|
|
|
+ const specs = buildDecorationsForRange(
|
|
|
|
|
+ range,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ )
|
|
|
|
|
+ for (const spec of specs) {
|
|
|
|
|
+ const isHiddenDecorator = range.type === "hidden"
|
|
|
|
|
+
|
|
|
|
|
+ let rangeClass =
|
|
|
|
|
+ range.className ??
|
|
|
|
|
+ (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
|
|
|
|
|
+
|
|
|
|
|
+ if (isHiddenDecorator) {
|
|
|
|
|
+ const baseClass = range.className ?? "md-decorator"
|
|
|
|
|
+ if (markerMode === "always-visible") {
|
|
|
|
|
+ rangeClass = baseClass
|
|
|
|
|
+ } else if (markerMode === "live-preview") {
|
|
|
|
|
+ rangeClass = isFocused
|
|
|
|
|
+ ? baseClass
|
|
|
|
|
+ : `${baseClass} md-decorator-hidden`
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
|
|
+ nodeName: "span",
|
|
|
|
|
+ class: rangeClass,
|
|
|
|
|
+ ...(block.wrapperAttrs ?? {}),
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Build decorations for a single token.
|
|
|
|
|
- *
|
|
|
|
|
- * ProseMirror decorations are flat — overlapping ranges produce sibling
|
|
|
|
|
- * spans, not nested elements. Wrapper attrs are stamped onto every
|
|
|
|
|
- * segment so each span is self-contained.
|
|
|
|
|
- *
|
|
|
|
|
- * In live preview mode:
|
|
|
|
|
- * - type: "content" ranges always show
|
|
|
|
|
- * - type: "hidden" ranges (markers) fade based on cursor proximity when focused
|
|
|
|
|
|
|
+ * Apply inline token decorations (bold, italic, links, tags, etc.).
|
|
|
*/
|
|
*/
|
|
|
-function buildTokenDecorations(
|
|
|
|
|
|
|
+function applyInlineDecorations(
|
|
|
token: ParsedToken,
|
|
token: ParsedToken,
|
|
|
- offset: number,
|
|
|
|
|
|
|
+ boundaries: number[],
|
|
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
decorationSpecs: Decoration[],
|
|
decorationSpecs: Decoration[],
|
|
|
isFocused: boolean,
|
|
isFocused: boolean,
|
|
|
markerMode: MarkerVisibilityMode,
|
|
markerMode: MarkerVisibilityMode,
|
|
@@ -231,21 +457,29 @@ function buildTokenDecorations(
|
|
|
// ── Inline math: show KaTeX when blurred; show source when focused ──
|
|
// ── Inline math: show KaTeX when blurred; show source when focused ──
|
|
|
if (token.type === "inline-math") {
|
|
if (token.type === "inline-math") {
|
|
|
if (!isFocused) {
|
|
if (!isFocused) {
|
|
|
- const tokenStart = offset + sorted[0]!.from
|
|
|
|
|
- // Hide all ranges
|
|
|
|
|
|
|
+ const firstRange = sorted[0]
|
|
|
|
|
+ const tokenStart = firstRange
|
|
|
|
|
+ ? resolveOffset(firstRange.from, boundaries, paragraphs)
|
|
|
|
|
+ : null
|
|
|
const source = token.wrapperAttrs?.["data-math"] ?? ""
|
|
const source = token.wrapperAttrs?.["data-math"] ?? ""
|
|
|
for (const range of sorted) {
|
|
for (const range of sorted) {
|
|
|
- decorationSpecs.push(
|
|
|
|
|
- Decoration.inline(offset + range.from, offset + range.to, {
|
|
|
|
|
- class: "md-math-hidden",
|
|
|
|
|
- }),
|
|
|
|
|
|
|
+ const specs = buildDecorationsForRange(
|
|
|
|
|
+ range,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
)
|
|
)
|
|
|
|
|
+ for (const spec of specs) {
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
|
|
+ class: "md-math-hidden",
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- // Add clickable KaTeX widget
|
|
|
|
|
- if (source) {
|
|
|
|
|
|
|
+ if (source && tokenStart) {
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
Decoration.widget(
|
|
Decoration.widget(
|
|
|
- tokenStart,
|
|
|
|
|
|
|
+ tokenStart.pmPos,
|
|
|
(view) => {
|
|
(view) => {
|
|
|
const widgetSpan = document.createElement("span")
|
|
const widgetSpan = document.createElement("span")
|
|
|
widgetSpan.className = "md-math-rendered"
|
|
widgetSpan.className = "md-math-rendered"
|
|
@@ -255,7 +489,9 @@ function buildTokenDecorations(
|
|
|
e.stopPropagation()
|
|
e.stopPropagation()
|
|
|
view.dispatch(
|
|
view.dispatch(
|
|
|
view.state.tr.setSelection(
|
|
view.state.tr.setSelection(
|
|
|
- TextSelection.near(view.state.doc.resolve(tokenStart)),
|
|
|
|
|
|
|
+ TextSelection.near(
|
|
|
|
|
+ view.state.doc.resolve(tokenStart.pmPos),
|
|
|
|
|
+ ),
|
|
|
),
|
|
),
|
|
|
)
|
|
)
|
|
|
view.focus()
|
|
view.focus()
|
|
@@ -271,43 +507,88 @@ function buildTokenDecorations(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
for (const range of sorted) {
|
|
for (const range of sorted) {
|
|
|
- const rangeFrom = offset + range.from
|
|
|
|
|
- const rangeTo = offset + range.to
|
|
|
|
|
-
|
|
|
|
|
- const isHiddenDecorator = range.type === "hidden"
|
|
|
|
|
-
|
|
|
|
|
- let rangeClass =
|
|
|
|
|
- range.className ??
|
|
|
|
|
- (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
|
|
|
|
|
-
|
|
|
|
|
- // Apply visibility rules based on marker mode
|
|
|
|
|
- if (isHiddenDecorator) {
|
|
|
|
|
- const baseClass = range.className ?? "md-decorator"
|
|
|
|
|
- if (markerMode === "always-visible") {
|
|
|
|
|
- // Always show markers
|
|
|
|
|
- rangeClass = baseClass
|
|
|
|
|
- } else if (markerMode === "live-preview") {
|
|
|
|
|
- // Live preview: reveal markers when focused
|
|
|
|
|
- rangeClass = isFocused ? baseClass : `${baseClass} md-decorator-hidden`
|
|
|
|
|
|
|
+ const specs = buildDecorationsForRange(
|
|
|
|
|
+ range,
|
|
|
|
|
+ boundaries,
|
|
|
|
|
+ paragraphs,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ for (const spec of specs) {
|
|
|
|
|
+ const isHiddenDecorator = range.type === "hidden"
|
|
|
|
|
+
|
|
|
|
|
+ let rangeClass =
|
|
|
|
|
+ range.className ??
|
|
|
|
|
+ (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
|
|
|
|
|
+
|
|
|
|
|
+ if (isHiddenDecorator) {
|
|
|
|
|
+ const baseClass = range.className ?? "md-decorator"
|
|
|
|
|
+ if (markerMode === "always-visible") {
|
|
|
|
|
+ rangeClass = baseClass
|
|
|
|
|
+ } else if (markerMode === "live-preview") {
|
|
|
|
|
+ rangeClass = isFocused
|
|
|
|
|
+ ? baseClass
|
|
|
|
|
+ : `${baseClass} md-decorator-hidden`
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- const classes = rangeClass
|
|
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
|
|
+ nodeName: "span",
|
|
|
|
|
+ class: rangeClass,
|
|
|
|
|
+ ...(token.wrapperAttrs ?? {}),
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const attrs: Record<string, string> = {
|
|
|
|
|
- ...token.wrapperAttrs,
|
|
|
|
|
- class: classes,
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Map a single decoration range (combined-text offsets) to PM-relative specs,
|
|
|
|
|
+ * splitting across paragraph boundaries if needed.
|
|
|
|
|
+ */
|
|
|
|
|
+function buildDecorationsForRange(
|
|
|
|
|
+ range: DecorationRange,
|
|
|
|
|
+ boundaries: number[],
|
|
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
|
|
+): Array<{ from: number; to: number; className?: string }> {
|
|
|
|
|
+ const specs: Array<{ from: number; to: number }> = []
|
|
|
|
|
+ let pos = range.from
|
|
|
|
|
+
|
|
|
|
|
+ while (pos < range.to) {
|
|
|
|
|
+ // Find paragraph containing this offset
|
|
|
|
|
+ let paraIdx = -1
|
|
|
|
|
+ let paraStart = 0
|
|
|
|
|
+ let paraEnd = 0
|
|
|
|
|
+ for (let i = 0; i < paragraphs.length; i++) {
|
|
|
|
|
+ const s = boundaries[i]!
|
|
|
|
|
+ const e = boundaries[i + 1]! // includes the \n separator
|
|
|
|
|
+ if (pos >= s && pos < e - 1) {
|
|
|
|
|
+ paraIdx = i
|
|
|
|
|
+ paraStart = s
|
|
|
|
|
+ paraEnd = e - 1
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pos >= s && pos === e - 1 && pos < range.to) {
|
|
|
|
|
+ pos = e
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- decorationSpecs.push(
|
|
|
|
|
- Decoration.inline(rangeFrom, rangeTo, {
|
|
|
|
|
- nodeName: "span",
|
|
|
|
|
- ...attrs,
|
|
|
|
|
- }),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ if (paraIdx < 0) break
|
|
|
|
|
+
|
|
|
|
|
+ const segmentEnd = Math.min(range.to, paraEnd)
|
|
|
|
|
+ const localFrom = pos - paraStart
|
|
|
|
|
+ const localTo = segmentEnd - paraStart
|
|
|
|
|
+ const pmPos = paragraphs[paraIdx]!.pos + 1
|
|
|
|
|
+ specs.push({ from: pmPos + localFrom, to: pmPos + localTo })
|
|
|
|
|
+ pos = segmentEnd
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ return specs
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Build property decorations (key:: value).
|
|
|
|
|
+ */
|
|
|
function buildPropertyDecorations(
|
|
function buildPropertyDecorations(
|
|
|
properties: Array<{
|
|
properties: Array<{
|
|
|
keyRange: [number, number]
|
|
keyRange: [number, number]
|
|
@@ -315,34 +596,35 @@ function buildPropertyDecorations(
|
|
|
valueRange: [number, number]
|
|
valueRange: [number, number]
|
|
|
value?: string
|
|
value?: string
|
|
|
}>,
|
|
}>,
|
|
|
- childOffset: number,
|
|
|
|
|
|
|
+ boundaries: number[],
|
|
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
decorationSpecs: Decoration[],
|
|
decorationSpecs: Decoration[],
|
|
|
) {
|
|
) {
|
|
|
for (const prop of properties) {
|
|
for (const prop of properties) {
|
|
|
|
|
+ const resolved = resolveOffset(prop.keyRange[0], boundaries, paragraphs)
|
|
|
|
|
+ if (!resolved) continue
|
|
|
|
|
+
|
|
|
|
|
+ const paraIdx = resolved.paraIndex
|
|
|
|
|
+ const base = paragraphs[paraIdx]!.pos + 1 - boundaries[paraIdx]!
|
|
|
|
|
+
|
|
|
const [keyFrom, keyTo] = prop.keyRange
|
|
const [keyFrom, keyTo] = prop.keyRange
|
|
|
const [delimFrom, delimTo] = prop.delimiterRange
|
|
const [delimFrom, delimTo] = prop.delimiterRange
|
|
|
const [valueFrom, valueTo] = prop.valueRange
|
|
const [valueFrom, valueTo] = prop.valueRange
|
|
|
|
|
|
|
|
- const keyStart = keyFrom + childOffset
|
|
|
|
|
- const keyEnd = keyTo + childOffset
|
|
|
|
|
- const delimStart = delimFrom + childOffset
|
|
|
|
|
- const delimEnd = delimTo + childOffset
|
|
|
|
|
-
|
|
|
|
|
- // Property keys and delimiters always show (they're structural elements)
|
|
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
- Decoration.inline(keyStart, keyEnd, {
|
|
|
|
|
|
|
+ Decoration.inline(base + keyFrom, base + keyTo, {
|
|
|
class: "md-property-key",
|
|
class: "md-property-key",
|
|
|
}),
|
|
}),
|
|
|
)
|
|
)
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
- Decoration.inline(delimStart, delimEnd, {
|
|
|
|
|
|
|
+ Decoration.inline(base + delimFrom, base + delimTo, {
|
|
|
class: "md-property-delimiter",
|
|
class: "md-property-delimiter",
|
|
|
}),
|
|
}),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
if (prop.value) {
|
|
if (prop.value) {
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
- Decoration.inline(valueFrom + childOffset, valueTo + childOffset, {
|
|
|
|
|
|
|
+ Decoration.inline(base + valueFrom, base + valueTo, {
|
|
|
class: "md-property-value",
|
|
class: "md-property-value",
|
|
|
}),
|
|
}),
|
|
|
)
|
|
)
|
|
@@ -350,6 +632,160 @@ function buildPropertyDecorations(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Render a GFM table string to an HTML table.
|
|
|
|
|
+ */
|
|
|
|
|
+function renderTableHtml(source: string): string {
|
|
|
|
|
+ const lines = source.split("\n").filter((l) => l.trim().length > 0)
|
|
|
|
|
+ if (lines.length < 2) return ""
|
|
|
|
|
+
|
|
|
|
|
+ // line[1] is expected to be the GFM separator row (|---|)
|
|
|
|
|
+ // If it doesn't look like one, skip it to avoid rendering dashes as cells
|
|
|
|
|
+ if (lines.length >= 2 && !/^[\s:|:-]+$/.test(lines[1]!)) {
|
|
|
|
|
+ lines.splice(1, 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const rows: string[] = []
|
|
|
|
|
+
|
|
|
|
|
+ const headerCells = parseRow(lines[0]!)
|
|
|
|
|
+ rows.push(
|
|
|
|
|
+ `<thead><tr>${headerCells.map((c) => `<th>${renderCellContent(c.trim())}</th>`).join("")}</tr></thead>`,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if (lines.length > 2) {
|
|
|
|
|
+ const body: string[] = []
|
|
|
|
|
+ for (let i = 2; i < lines.length; i++) {
|
|
|
|
|
+ const cells = parseRow(lines[i]!)
|
|
|
|
|
+ body.push(
|
|
|
|
|
+ `<tr>${cells.map((c) => `<td>${renderCellContent(c.trim())}</td>`).join("")}</tr>`,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.push(`<tbody>${body.join("")}</tbody>`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return `<table>${rows.join("")}</table>`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseRow(line: string): string[] {
|
|
|
|
|
+ const trimmed = line.trim()
|
|
|
|
|
+ const content = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed
|
|
|
|
|
+ const endContent = content.endsWith("|") ? content.slice(0, -1) : content
|
|
|
|
|
+ // Split on | but not inside escaped pipes
|
|
|
|
|
+ return endContent.split(/(?<!\\)\|/).map((c) => c.trim())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function escapeHtml(s: string): string {
|
|
|
|
|
+ return s
|
|
|
|
|
+ .replace(/&/g, "&")
|
|
|
|
|
+ .replace(/</g, "<")
|
|
|
|
|
+ .replace(/>/g, ">")
|
|
|
|
|
+ .replace(/"/g, """)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface InlineTagInfo {
|
|
|
|
|
+ open: string
|
|
|
|
|
+ close: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Map an inline token to its HTML open/close tags.
|
|
|
|
|
+ * For links, extracts the URL from the decoration ranges.
|
|
|
|
|
+ */
|
|
|
|
|
+function getInlineTagInfo(
|
|
|
|
|
+ token: { type: string; decorationRanges: DecorationRange[] },
|
|
|
|
|
+ text: string,
|
|
|
|
|
+): InlineTagInfo | null {
|
|
|
|
|
+ switch (token.type) {
|
|
|
|
|
+ case "strong":
|
|
|
|
|
+ return { open: "<strong>", close: "</strong>" }
|
|
|
|
|
+ case "em":
|
|
|
|
|
+ return { open: "<em>", close: "</em>" }
|
|
|
|
|
+ case "code":
|
|
|
|
|
+ return { open: "<code>", close: "</code>" }
|
|
|
|
|
+ case "strike":
|
|
|
|
|
+ return { open: "<s>", close: "</s>" }
|
|
|
|
|
+ case "highlight":
|
|
|
|
|
+ return { open: "<mark>", close: "</mark>" }
|
|
|
|
|
+ case "link": {
|
|
|
|
|
+ const urlRange = token.decorationRanges[2]
|
|
|
|
|
+ if (urlRange?.type === "hidden") {
|
|
|
|
|
+ const url = text.slice(urlRange.from + 2, urlRange.to - 1)
|
|
|
|
|
+ return { open: `<a href="${escapeHtml(url)}">`, close: "</a>" }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+ case "page-ref":
|
|
|
|
|
+ return { open: '<span class="md-page-ref">', close: "</span>" }
|
|
|
|
|
+ case "block-ref":
|
|
|
|
|
+ return { open: '<span class="md-block-ref">', close: "</span>" }
|
|
|
|
|
+ case "tag":
|
|
|
|
|
+ return { open: '<span class="md-tag">', close: "</span>" }
|
|
|
|
|
+ default:
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Render inline markdown to HTML for table cells.
|
|
|
|
|
+ * Uses the existing Lezer-based parser to extract tokens,
|
|
|
|
|
+ * then builds HTML by skipping hidden delimiter positions
|
|
|
|
|
+ * and wrapping content ranges in the appropriate tags.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Each cell is parsed independently (no cache) — cells are
|
|
|
|
|
+ * short enough that this is negligible for the target 16ms budget.
|
|
|
|
|
+ */
|
|
|
|
|
+function renderCellContent(text: string): string {
|
|
|
|
|
+ if (!text) return ""
|
|
|
|
|
+ const result = parseMarkdown(text)
|
|
|
|
|
+ if (result.content.length === 0) return escapeHtml(text)
|
|
|
|
|
+
|
|
|
|
|
+ const hidden = new Set<number>()
|
|
|
|
|
+ const opens: Record<number, string> = {}
|
|
|
|
|
+ const closes: Record<number, string> = {}
|
|
|
|
|
+
|
|
|
|
|
+ for (const token of result.content) {
|
|
|
|
|
+ if (token.type === "inline-math") {
|
|
|
|
|
+ const contentRange = token.decorationRanges.find(
|
|
|
|
|
+ (r) => r.type === "content",
|
|
|
|
|
+ )
|
|
|
|
|
+ if (contentRange) {
|
|
|
|
|
+ for (const range of token.decorationRanges) {
|
|
|
|
|
+ for (let i = range.from; i < range.to; i++) hidden.add(i)
|
|
|
|
|
+ }
|
|
|
|
|
+ const mathSource = text.slice(contentRange.from, contentRange.to)
|
|
|
|
|
+ const temp = document.createElement("span")
|
|
|
|
|
+ renderKatexSync(mathSource, temp, false)
|
|
|
|
|
+ opens[contentRange.from] = (opens[contentRange.from] ?? "") + temp.innerHTML
|
|
|
|
|
+ }
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const tagInfo = getInlineTagInfo(token, text)
|
|
|
|
|
+ if (!tagInfo) continue
|
|
|
|
|
+
|
|
|
|
|
+ for (const range of token.decorationRanges) {
|
|
|
|
|
+ if (range.type === "hidden") {
|
|
|
|
|
+ for (let i = range.from; i < range.to; i++) hidden.add(i)
|
|
|
|
|
+ } else if (range.type === "content") {
|
|
|
|
|
+ opens[range.from] = (opens[range.from] ?? "") + tagInfo.open
|
|
|
|
|
+ closes[range.to] = (closes[range.to] ?? "") + tagInfo.close
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let html = ""
|
|
|
|
|
+ for (let pos = 0; pos < text.length; pos++) {
|
|
|
|
|
+ if (closes[pos]) html += closes[pos]
|
|
|
|
|
+ if (opens[pos]) html += opens[pos]
|
|
|
|
|
+ if (!hidden.has(pos)) html += escapeHtml(text[pos]!)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (closes[text.length]) html += closes[text.length]
|
|
|
|
|
+
|
|
|
|
|
+ return html
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Plugin ──────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
interface DecorationState {
|
|
interface DecorationState {
|
|
|
engine: MarkdownRuleEngine
|
|
engine: MarkdownRuleEngine
|
|
|
contentCaches: Map<string, BlockTokenCache>
|
|
contentCaches: Map<string, BlockTokenCache>
|
|
@@ -363,13 +799,12 @@ const markdownDecorationsKey = new PluginKey<DecorationState>(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Invalidate caches for textblocks whose content changed.
|
|
|
|
|
- * Caches are keyed by content text, so identical content reuses
|
|
|
|
|
- * cached tokens even if document positions have shifted.
|
|
|
|
|
|
|
+ * Invalidate caches when document content changes.
|
|
|
|
|
+ * Cache is keyed by the combined text of all paragraphs.
|
|
|
*/
|
|
*/
|
|
|
function invalidateChangedCaches(
|
|
function invalidateChangedCaches(
|
|
|
newDoc: ProsemirrorNode,
|
|
newDoc: ProsemirrorNode,
|
|
|
- oldDoc: ProsemirrorNode,
|
|
|
|
|
|
|
+ _oldDoc: ProsemirrorNode,
|
|
|
oldCaches: Map<string, BlockTokenCache>,
|
|
oldCaches: Map<string, BlockTokenCache>,
|
|
|
engine: MarkdownRuleEngine,
|
|
engine: MarkdownRuleEngine,
|
|
|
): Map<string, BlockTokenCache> {
|
|
): Map<string, BlockTokenCache> {
|
|
@@ -377,39 +812,28 @@ function invalidateChangedCaches(
|
|
|
let hitCount = 0
|
|
let hitCount = 0
|
|
|
let missCount = 0
|
|
let missCount = 0
|
|
|
|
|
|
|
|
- // Collect old caches in document order for position-based incremental parsing
|
|
|
|
|
- const oldCacheOrder: BlockTokenCache[] = []
|
|
|
|
|
- oldDoc.descendants((node: ProsemirrorNode) => {
|
|
|
|
|
- if (!node.isTextblock) return
|
|
|
|
|
- if (node.type.spec.code) return
|
|
|
|
|
- const cache = oldCaches.get(node.textContent)
|
|
|
|
|
- if (cache) oldCacheOrder.push(cache)
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const paragraphs = collectParagraphs(newDoc)
|
|
|
|
|
+ const combined = buildCombinedText(paragraphs)
|
|
|
|
|
+ const combinedText = combined.text
|
|
|
|
|
+ const boundaries = combined.boundaries
|
|
|
|
|
|
|
|
- let blockIndex = 0
|
|
|
|
|
- newDoc.descendants((node: ProsemirrorNode) => {
|
|
|
|
|
- if (!node.isTextblock) return
|
|
|
|
|
- if (node.type.spec.code) return
|
|
|
|
|
- const blockText = node.textContent
|
|
|
|
|
|
|
+ if (!combinedText) {
|
|
|
|
|
+ // Empty block — no paragraphs
|
|
|
|
|
+ return newCaches
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const oldCache = oldCaches.get(blockText)
|
|
|
|
|
- if (oldCache) {
|
|
|
|
|
- hitCount++
|
|
|
|
|
- newCaches.set(blockText, oldCache)
|
|
|
|
|
- } else {
|
|
|
|
|
- missCount++
|
|
|
|
|
- // Use old tree from same-position block for incremental Lezer parsing
|
|
|
|
|
- const oldTree = oldCacheOrder[blockIndex]?.tree
|
|
|
|
|
- log.info("CACHE MISS", {
|
|
|
|
|
- blockText: JSON.stringify(blockText),
|
|
|
|
|
- blockIndex,
|
|
|
|
|
- hasOldTree: !!oldTree,
|
|
|
|
|
- oldBlockText: oldCacheOrder[blockIndex] ? JSON.stringify(oldCacheOrder[blockIndex]!.text) : "N/A",
|
|
|
|
|
- })
|
|
|
|
|
- newCaches.set(blockText, parseBlockTokens(blockText, engine, oldTree))
|
|
|
|
|
- }
|
|
|
|
|
- blockIndex++
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const oldCache = oldCaches.get(combinedText)
|
|
|
|
|
+ if (oldCache) {
|
|
|
|
|
+ hitCount++
|
|
|
|
|
+ newCaches.set(combinedText, oldCache)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ missCount++
|
|
|
|
|
+ // Force full re-parse (no incremental) — incremental Lezer reuses old tree
|
|
|
|
|
+ // fragments, which prevents GFM table detection when a Paragraph was
|
|
|
|
|
+ // classified before the delimiter line existed.
|
|
|
|
|
+ const parsed = parseBlockTokens(combinedText, boundaries, engine, undefined)
|
|
|
|
|
+ newCaches.set(combinedText, parsed)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
log.debug("cache invalidate", {
|
|
log.debug("cache invalidate", {
|
|
|
hitCount,
|
|
hitCount,
|
|
@@ -422,8 +846,6 @@ function invalidateChangedCaches(
|
|
|
export function createMarkdownDecorationsPlugin(
|
|
export function createMarkdownDecorationsPlugin(
|
|
|
initialMarkerMode: MarkerVisibilityMode = defaultMarkerMode,
|
|
initialMarkerMode: MarkerVisibilityMode = defaultMarkerMode,
|
|
|
) {
|
|
) {
|
|
|
- // Closure-level cache for the last-built DecorationSet.
|
|
|
|
|
- // Avoids rebuilding on every cursor-move transaction.
|
|
|
|
|
let cachedSet: DecorationSet | null = null
|
|
let cachedSet: DecorationSet | null = null
|
|
|
let cachedVersion = -1
|
|
let cachedVersion = -1
|
|
|
let cachedFocus = false
|
|
let cachedFocus = false
|
|
@@ -436,11 +858,16 @@ export function createMarkdownDecorationsPlugin(
|
|
|
const engine = new MarkdownRuleEngine()
|
|
const engine = new MarkdownRuleEngine()
|
|
|
const contentCaches = new Map<string, BlockTokenCache>()
|
|
const contentCaches = new Map<string, BlockTokenCache>()
|
|
|
|
|
|
|
|
- state.doc.descendants((node: ProsemirrorNode) => {
|
|
|
|
|
- if (!node.isTextblock) return
|
|
|
|
|
- const blockText = node.textContent
|
|
|
|
|
- contentCaches.set(blockText, parseBlockTokens(blockText, engine))
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const paragraphs = collectParagraphs(state.doc)
|
|
|
|
|
+ const combined = buildCombinedText(paragraphs)
|
|
|
|
|
+ const combinedText = combined.text
|
|
|
|
|
+ const boundaries = combined.boundaries
|
|
|
|
|
+ if (combinedText) {
|
|
|
|
|
+ contentCaches.set(
|
|
|
|
|
+ combinedText,
|
|
|
|
|
+ parseBlockTokens(combinedText, boundaries, engine),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
engine,
|
|
engine,
|