|
@@ -22,11 +22,12 @@ import { renderKatexSync } from "@/lib/katex"
|
|
|
import { createLogger } from "@/lib/logger"
|
|
import { createLogger } from "@/lib/logger"
|
|
|
import {
|
|
import {
|
|
|
type DecorationRange,
|
|
type DecorationRange,
|
|
|
- type ParsedDecorations,
|
|
|
|
|
MarkdownRuleEngine,
|
|
MarkdownRuleEngine,
|
|
|
|
|
+ type ParsedDecorations,
|
|
|
parseMarkdown,
|
|
parseMarkdown,
|
|
|
} from "@/lib/markdown-parser"
|
|
} from "@/lib/markdown-parser"
|
|
|
import type { BlockDecoration } from "@/lib/markdown-rules"
|
|
import type { BlockDecoration } from "@/lib/markdown-rules"
|
|
|
|
|
+import { safeImageSrc } from "@/lib/safe-image-src"
|
|
|
|
|
|
|
|
const log = createLogger("Decorations")
|
|
const log = createLogger("Decorations")
|
|
|
|
|
|
|
@@ -168,7 +169,8 @@ function buildCombinedText(paragraphs: ParagraphInfo[]): {
|
|
|
let text = ""
|
|
let text = ""
|
|
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
for (let i = 0; i < paragraphs.length; i++) {
|
|
|
text += paragraphs[i]!.text
|
|
text += paragraphs[i]!.text
|
|
|
- const sepLen = i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
|
|
|
|
|
|
|
+ const sepLen =
|
|
|
|
|
+ i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
|
|
|
boundaries.push(text.length + sepLen)
|
|
boundaries.push(text.length + sepLen)
|
|
|
if (i < paragraphs.length - 1) {
|
|
if (i < paragraphs.length - 1) {
|
|
|
text += separatorBetween(paragraphs, i)
|
|
text += separatorBetween(paragraphs, i)
|
|
@@ -356,11 +358,7 @@ function applyBlockDecorations(
|
|
|
: null
|
|
: null
|
|
|
const source = block.wrapperAttrs?.["data-table-source"] ?? ""
|
|
const source = block.wrapperAttrs?.["data-table-source"] ?? ""
|
|
|
for (const range of sorted) {
|
|
for (const range of sorted) {
|
|
|
- const specs = buildDecorationsForRange(
|
|
|
|
|
- range,
|
|
|
|
|
- boundaries,
|
|
|
|
|
- paragraphs,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const specs = buildDecorationsForRange(range, boundaries, paragraphs)
|
|
|
for (const spec of specs) {
|
|
for (const spec of specs) {
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
Decoration.inline(spec.from, spec.to, {
|
|
Decoration.inline(spec.from, spec.to, {
|
|
@@ -459,11 +457,7 @@ function applyBlockDecorations(
|
|
|
|
|
|
|
|
// ── Decorations for each range ──
|
|
// ── Decorations for each range ──
|
|
|
for (const range of sorted) {
|
|
for (const range of sorted) {
|
|
|
- const specs = buildDecorationsForRange(
|
|
|
|
|
- range,
|
|
|
|
|
- boundaries,
|
|
|
|
|
- paragraphs,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const specs = buildDecorationsForRange(range, boundaries, paragraphs)
|
|
|
for (const spec of specs) {
|
|
for (const spec of specs) {
|
|
|
const isHiddenDecorator = range.type === "hidden"
|
|
const isHiddenDecorator = range.type === "hidden"
|
|
|
|
|
|
|
@@ -519,11 +513,7 @@ function applyInlineDecorations(
|
|
|
: null
|
|
: null
|
|
|
const source = token.wrapperAttrs?.["data-math"] ?? ""
|
|
const source = token.wrapperAttrs?.["data-math"] ?? ""
|
|
|
for (const range of sorted) {
|
|
for (const range of sorted) {
|
|
|
- const specs = buildDecorationsForRange(
|
|
|
|
|
- range,
|
|
|
|
|
- boundaries,
|
|
|
|
|
- paragraphs,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const specs = buildDecorationsForRange(range, boundaries, paragraphs)
|
|
|
for (const spec of specs) {
|
|
for (const spec of specs) {
|
|
|
decorationSpecs.push(
|
|
decorationSpecs.push(
|
|
|
Decoration.inline(spec.from, spec.to, {
|
|
Decoration.inline(spec.from, spec.to, {
|
|
@@ -570,12 +560,59 @@ function applyInlineDecorations(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ── Image: show <img> when blurred; show source when focused ──
|
|
|
|
|
+ if (token.type === "image") {
|
|
|
|
|
+ if (!isFocused) {
|
|
|
|
|
+ const firstRange = sorted[0]
|
|
|
|
|
+ const tokenStart = firstRange
|
|
|
|
|
+ ? resolveOffset(firstRange.from, boundaries, paragraphs)
|
|
|
|
|
+ : null
|
|
|
|
|
+ const src = safeImageSrc(token.wrapperAttrs?.["data-src"] ?? "")
|
|
|
|
|
+ const alt = token.wrapperAttrs?.["data-alt"] ?? ""
|
|
|
|
|
+ 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-image-hidden",
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (src && tokenStart) {
|
|
|
|
|
+ decorationSpecs.push(
|
|
|
|
|
+ Decoration.widget(
|
|
|
|
|
+ tokenStart.pmPos,
|
|
|
|
|
+ (view) => {
|
|
|
|
|
+ const wrapper = document.createElement("span")
|
|
|
|
|
+ wrapper.className = "md-image-wrapper"
|
|
|
|
|
+ const img = document.createElement("img")
|
|
|
|
|
+ img.src = src
|
|
|
|
|
+ img.alt = alt
|
|
|
|
|
+ img.className = "md-image-rendered"
|
|
|
|
|
+ img.loading = "lazy"
|
|
|
|
|
+ wrapper.appendChild(img)
|
|
|
|
|
+ wrapper.addEventListener("mousedown", (e) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ e.stopPropagation()
|
|
|
|
|
+ const $pos = view.state.doc.resolve(tokenStart.pmPos)
|
|
|
|
|
+ view.dispatch(
|
|
|
|
|
+ view.state.tr.setSelection(TextSelection.near($pos)),
|
|
|
|
|
+ )
|
|
|
|
|
+ view.focus()
|
|
|
|
|
+ })
|
|
|
|
|
+ return wrapper
|
|
|
|
|
+ },
|
|
|
|
|
+ { side: -1 },
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
for (const range of sorted) {
|
|
for (const range of sorted) {
|
|
|
- const specs = buildDecorationsForRange(
|
|
|
|
|
- range,
|
|
|
|
|
- boundaries,
|
|
|
|
|
- paragraphs,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const specs = buildDecorationsForRange(range, boundaries, paragraphs)
|
|
|
|
|
|
|
|
for (const spec of specs) {
|
|
for (const spec of specs) {
|
|
|
const isHiddenDecorator = range.type === "hidden"
|
|
const isHiddenDecorator = range.type === "hidden"
|
|
@@ -804,6 +841,19 @@ function getInlineTagInfo(
|
|
|
return { open: '<span class="md-block-ref">', close: "</span>" }
|
|
return { open: '<span class="md-block-ref">', close: "</span>" }
|
|
|
case "tag":
|
|
case "tag":
|
|
|
return { open: '<span class="md-tag">', close: "</span>" }
|
|
return { open: '<span class="md-tag">', close: "</span>" }
|
|
|
|
|
+ case "image": {
|
|
|
|
|
+ const urlRange = token.decorationRanges[2]
|
|
|
|
|
+ if (urlRange?.type === "hidden") {
|
|
|
|
|
+ const url = text.slice(urlRange.from + 2, urlRange.to - 1)
|
|
|
|
|
+ const safe = safeImageSrc(url)
|
|
|
|
|
+ if (safe)
|
|
|
|
|
+ return {
|
|
|
|
|
+ open: `<img src="${escapeHtml(safe)}" class="md-image-rendered" loading="lazy">`,
|
|
|
|
|
+ close: "",
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
default:
|
|
default:
|
|
|
return null
|
|
return null
|
|
|
}
|
|
}
|
|
@@ -841,9 +891,11 @@ function renderCellContent(text: string): string {
|
|
|
// If KaTeX fails in a table cell, fall back to escaped source.
|
|
// If KaTeX fails in a table cell, fall back to escaped source.
|
|
|
try {
|
|
try {
|
|
|
renderKatexSync(mathSource, temp, false)
|
|
renderKatexSync(mathSource, temp, false)
|
|
|
- opens[contentRange.from] = (opens[contentRange.from] ?? "") + temp.innerHTML
|
|
|
|
|
|
|
+ opens[contentRange.from] =
|
|
|
|
|
+ (opens[contentRange.from] ?? "") + temp.innerHTML
|
|
|
} catch {
|
|
} catch {
|
|
|
- opens[contentRange.from] = (opens[contentRange.from] ?? "") + `<span class="md-math-error">${escapeHtml(mathSource)}</span>`
|
|
|
|
|
|
|
+ opens[contentRange.from] =
|
|
|
|
|
+ `${opens[contentRange.from] ?? ""}<span class="md-math-error">${escapeHtml(mathSource)}</span>`
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
continue
|
|
continue
|
|
@@ -1038,10 +1090,7 @@ export function createMarkdownDecorationsPlugin(
|
|
|
const pluginState = markdownDecorationsKey.getState(state)
|
|
const pluginState = markdownDecorationsKey.getState(state)
|
|
|
if (!pluginState) return DecorationSet.empty
|
|
if (!pluginState) return DecorationSet.empty
|
|
|
|
|
|
|
|
- if (
|
|
|
|
|
- cachedSet &&
|
|
|
|
|
- cachedVersion === pluginState.decorationVersion
|
|
|
|
|
- ) {
|
|
|
|
|
|
|
+ if (cachedSet && cachedVersion === pluginState.decorationVersion) {
|
|
|
return cachedSet
|
|
return cachedSet
|
|
|
}
|
|
}
|
|
|
|
|
|