|
|
@@ -40,6 +40,7 @@ interface ParsedToken {
|
|
|
decorationRanges: DecorationRange[]
|
|
|
wrapperAttrs?: Record<string, string>
|
|
|
nodeWrapper?: { className: string; attrs?: Record<string, string> }
|
|
|
+ metadata?: Record<string, unknown>
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -232,29 +233,76 @@ function buildDecorationsFromCache(
|
|
|
const cache = contentCaches.get(combinedText)
|
|
|
if (!cache) return DecorationSet.empty
|
|
|
|
|
|
+ applyCalloutGroupDecorations(
|
|
|
+ cache.blocks,
|
|
|
+ boundaries,
|
|
|
+ paragraphs,
|
|
|
+ decorationSpecs,
|
|
|
+ isFocused,
|
|
|
+ markerMode,
|
|
|
+ )
|
|
|
+
|
|
|
+ for (const token of cache.content) {
|
|
|
+ applyInlineDecorations(
|
|
|
+ token,
|
|
|
+ boundaries,
|
|
|
+ paragraphs,
|
|
|
+ decorationSpecs,
|
|
|
+ isFocused,
|
|
|
+ markerMode,
|
|
|
+ resolvedRefs,
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // 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 callout group decorations, merging blockquote continuations into
|
|
|
+ * their preceding callout block so they share the same node wrapper class.
|
|
|
+ */
|
|
|
+function applyCalloutGroupDecorations(
|
|
|
+ blocks: ParsedToken[],
|
|
|
+ boundaries: number[],
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
+ decorationSpecs: Decoration[],
|
|
|
+ isFocused: boolean,
|
|
|
+ markerMode: MarkerVisibilityMode,
|
|
|
+) {
|
|
|
let calloutType: string | null = null
|
|
|
|
|
|
- for (const block of cache.blocks) {
|
|
|
- // Track callout type from callout blocks
|
|
|
+ for (const block of 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,
|
|
|
+ {
|
|
|
+ ...block,
|
|
|
+ type: "callout" as const,
|
|
|
+ nodeWrapper: {
|
|
|
+ className: "md-callout",
|
|
|
+ attrs: { "data-callout-type": calloutType },
|
|
|
+ },
|
|
|
+ metadata: { calloutType },
|
|
|
+ },
|
|
|
boundaries,
|
|
|
paragraphs,
|
|
|
decorationSpecs,
|
|
|
@@ -272,39 +320,116 @@ function buildDecorationsFromCache(
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- // Track whether a subsequent non-callout/blockquote resets the group
|
|
|
if (block.type !== "callout" && block.type !== "blockquote") {
|
|
|
calloutType = null
|
|
|
}
|
|
|
}
|
|
|
+}
|
|
|
|
|
|
- for (const token of cache.content) {
|
|
|
- applyInlineDecorations(
|
|
|
- token,
|
|
|
- boundaries,
|
|
|
- paragraphs,
|
|
|
- decorationSpecs,
|
|
|
- isFocused,
|
|
|
- markerMode,
|
|
|
- resolvedRefs,
|
|
|
- )
|
|
|
+/**
|
|
|
+ * Apply table decorations — blurred state hides the raw | pipes and renders
|
|
|
+ * an HTML table widget; focused state shows the source with | as muted
|
|
|
+ * decorators. Node wrapper is applied in both states.
|
|
|
+ */
|
|
|
+function applyTableDecorations(
|
|
|
+ block: ParsedToken,
|
|
|
+ sorted: DecorationRange[],
|
|
|
+ boundaries: number[],
|
|
|
+ paragraphs: ParagraphInfo[],
|
|
|
+ decorationSpecs: Decoration[],
|
|
|
+ isFocused: boolean,
|
|
|
+) {
|
|
|
+ if (block.nodeWrapper) {
|
|
|
+ const firstRange = sorted[0]
|
|
|
+ const lastRange = sorted[sorted.length - 1]
|
|
|
+ if (firstRange && lastRange) {
|
|
|
+ const resolvedFirst = resolveOffset(
|
|
|
+ firstRange.from,
|
|
|
+ boundaries,
|
|
|
+ paragraphs,
|
|
|
+ )
|
|
|
+ const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
|
|
|
+ if (resolvedFirst && resolvedLast) {
|
|
|
+ const fromNode = paragraphs[resolvedFirst.paraIndex]!
|
|
|
+ const toNode = paragraphs[resolvedLast.paraIndex]!
|
|
|
+ decorationSpecs.push(
|
|
|
+ Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
|
|
|
+ class: block.nodeWrapper.className ?? undefined,
|
|
|
+ ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // Properties — keyed by single-line paragraphs
|
|
|
- if (cache.properties.length > 0) {
|
|
|
- buildPropertyDecorations(
|
|
|
- cache.properties,
|
|
|
+ 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)
|
|
|
+ for (const spec of specs) {
|
|
|
+ decorationSpecs.push(
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
+ class: "md-table-hidden",
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (source && tokenStart) {
|
|
|
+ decorationSpecs.push(
|
|
|
+ Decoration.widget(
|
|
|
+ tokenStart.pmPos,
|
|
|
+ (view) => {
|
|
|
+ const widgetSpan = document.createElement("span")
|
|
|
+ widgetSpan.className = "md-table-rendered"
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ 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,
|
|
|
- decorationSpecs,
|
|
|
)
|
|
|
+ for (const spec of specs) {
|
|
|
+ decorationSpecs.push(
|
|
|
+ Decoration.inline(spec.from, spec.to, {
|
|
|
+ class: "md-decorator",
|
|
|
+ ...(block.wrapperAttrs ?? {}),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ idx++
|
|
|
}
|
|
|
-
|
|
|
- log.debug("build decorations", {
|
|
|
- count: decorationSpecs.length,
|
|
|
- paragraphs: paragraphs.length,
|
|
|
- })
|
|
|
- return DecorationSet.create(doc, decorationSpecs)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -323,106 +448,15 @@ function applyBlockDecorations(
|
|
|
|
|
|
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(
|
|
|
- Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
|
|
|
- class: block.nodeWrapper.className ?? undefined,
|
|
|
- ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
|
|
|
- }),
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- 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)
|
|
|
- for (const spec of specs) {
|
|
|
- decorationSpecs.push(
|
|
|
- Decoration.inline(spec.from, spec.to, {
|
|
|
- class: "md-table-hidden",
|
|
|
- }),
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
- 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
|
|
|
- }
|
|
|
-
|
|
|
- // ── 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++
|
|
|
- }
|
|
|
+ applyTableDecorations(
|
|
|
+ block,
|
|
|
+ sorted,
|
|
|
+ boundaries,
|
|
|
+ paragraphs,
|
|
|
+ decorationSpecs,
|
|
|
+ isFocused,
|
|
|
+ )
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -869,18 +903,29 @@ function getInlineTagInfo(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const cellParseCache = new Map<string, ParsedDecorations>()
|
|
|
+const CELL_CACHE_MAX = 50
|
|
|
+
|
|
|
/**
|
|
|
* 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.
|
|
|
+ * Cell parse results are cached by text content to avoid redundant
|
|
|
+ * Lezer parses on every decoration rebuild.
|
|
|
*/
|
|
|
function renderCellContent(text: string): string {
|
|
|
if (!text) return ""
|
|
|
- const result = parseMarkdown(text)
|
|
|
+ let result = cellParseCache.get(text)
|
|
|
+ if (!result) {
|
|
|
+ result = parseMarkdown(text)
|
|
|
+ if (cellParseCache.size >= CELL_CACHE_MAX) {
|
|
|
+ const firstKey = cellParseCache.keys().next().value
|
|
|
+ if (firstKey !== undefined) cellParseCache.delete(firstKey)
|
|
|
+ }
|
|
|
+ cellParseCache.set(text, result)
|
|
|
+ }
|
|
|
if (result.content.length === 0) return escapeHtml(text)
|
|
|
|
|
|
const hidden = new Set<number>()
|