diff --git a/app/components/editor/nodeviews/CodeBlockView.tsx b/app/components/editor/nodeviews/CodeBlockView.tsx
index 4ace63c..c909d97 100644
--- a/app/components/editor/nodeviews/CodeBlockView.tsx
+++ b/app/components/editor/nodeviews/CodeBlockView.tsx
@@ -1,7 +1,9 @@
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
import { lowlight } from '~/lib/tiptap/extensions'
import AbcPreview from './AbcPreview'
+import EmbedPreview from './EmbedPreview'
import FountainPreview from './FountainPreview'
+import FunctionPlotPreview from './FunctionPlotPreview'
import MermaidPreview from './MermaidPreview'
const LANGUAGES: string[] = (lowlight.listLanguages() as string[]).slice().sort()
@@ -33,9 +35,15 @@ export default function CodeBlockView({ node, updateAttributes, editor }: NodeVi
as="code" className="font-mono text-sm" />
- {language === 'mermaid' && }
- {language === 'abc' && }
- {language === 'fountain' && }
+ {/* contentEditable=false keeps the caret and ProseMirror mutation
+ handling out of the preview DOM (embeds inject iframes there) */}
+
+ {language === 'mermaid' &&
}
+ {language === 'abc' &&
}
+ {language === 'fountain' &&
}
+ {language === 'functionplot' &&
}
+ {language === 'embed' &&
}
+
)
}
diff --git a/app/components/editor/nodeviews/EmbedPreview.tsx b/app/components/editor/nodeviews/EmbedPreview.tsx
new file mode 100644
index 0000000..446796a
--- /dev/null
+++ b/app/components/editor/nodeviews/EmbedPreview.tsx
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react'
+import IframelyEmbed from '~/components/IframelyEmbed'
+
+// Live preview for ```embed code blocks inside the visual editor. Renders the
+// same IframelyEmbed component the published page hydrates, with the same
+// default dimensions (rehype-iframely: 444×250). Debounced so a half-typed
+// URL doesn't fire embed requests on every keystroke.
+export default function EmbedPreview({ source }: { source: string }) {
+ const [url, setUrl] = useState(null)
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ const firstLine = source
+ .split('\n')
+ .map((line) => line.trim())
+ .find(Boolean)
+ setUrl(firstLine && /^https?:\/\/\S+$/.test(firstLine) ? firstLine : null)
+ }, 800)
+ return () => clearTimeout(timer)
+ }, [source])
+
+ if (!url) {
+ return (
+
+ Paste a URL on the first line to preview the embed.
+
+ )
+ }
+
+ // Block layout (not flex): Iframely's responsive embeds size themselves
+ // with a padding-bottom percentage of the container width, so the container
+ // needs a real width — a flex item shrink-wraps to zero and the embed
+ // collapses with it.
+ return (
+
+ )
+}
diff --git a/app/components/editor/nodeviews/FunctionPlotPreview.tsx b/app/components/editor/nodeviews/FunctionPlotPreview.tsx
new file mode 100644
index 0000000..2a1d2dc
--- /dev/null
+++ b/app/components/editor/nodeviews/FunctionPlotPreview.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useRef, useState } from 'react'
+import { logger } from '~/lib/logger'
+
+// Live preview for ```functionplot code blocks inside the visual editor.
+// Mirrors the published page's hydration (PreviewRenderer): optional
+// key: value frontmatter between --- markers, then one function per line,
+// each validated with mathjs; invalid lines are skipped silently so nothing
+// flashes while the author is mid-keystroke.
+
+interface PlotConfig {
+ width: number
+ height: number
+ xDomain: [number, number]
+ yDomain: [number, number]
+ grid: boolean
+ code: string
+}
+
+// Defaults match the published renderer's rehype-functionplot options.
+function parsePlotSource(source: string): PlotConfig {
+ const config: PlotConfig = {
+ width: 800,
+ height: 400,
+ xDomain: [-10, 10],
+ yDomain: [-10, 10],
+ grid: true,
+ code: source,
+ }
+
+ const frontmatterMatch = source.match(/^---[\r\n]+(.*?)[\r\n]+---[\r\n]+([\s\S]*)$/s)
+ if (!frontmatterMatch) return config
+
+ const [, frontmatter, code] = frontmatterMatch
+ let valid = false
+ for (const line of frontmatter.split(/\r?\n/)) {
+ const separatorIndex = line.indexOf(':')
+ if (separatorIndex === -1) continue
+ const key = line.slice(0, separatorIndex).trim()
+ const value = line.slice(separatorIndex + 1).trim()
+ if (!key || !value) continue
+ const num = parseFloat(value)
+ switch (key) {
+ case 'xMin':
+ if (!isNaN(num)) (config.xDomain[0] = num), (valid = true)
+ break
+ case 'xMax':
+ if (!isNaN(num)) (config.xDomain[1] = num), (valid = true)
+ break
+ case 'yMin':
+ if (!isNaN(num)) (config.yDomain[0] = num), (valid = true)
+ break
+ case 'yMax':
+ if (!isNaN(num)) (config.yDomain[1] = num), (valid = true)
+ break
+ case 'width':
+ if (!isNaN(num) && num > 0) (config.width = Math.round(num)), (valid = true)
+ break
+ case 'height':
+ if (!isNaN(num) && num > 0) (config.height = Math.round(num)), (valid = true)
+ break
+ case 'grid':
+ if (value === 'true' || value === 'false') (config.grid = value === 'true'), (valid = true)
+ break
+ }
+ }
+ if (valid) config.code = code.trim()
+ return config
+}
+
+export default function FunctionPlotPreview({ source }: { source: string }) {
+ const containerRef = useRef(null)
+ const [message, setMessage] = useState(null)
+
+ useEffect(() => {
+ const trimmed = source.trim()
+ if (!trimmed) return
+
+ let cancelled = false
+ const timer = setTimeout(async () => {
+ try {
+ const [{ parse }, plotModule] = await Promise.all([import('mathjs'), import('function-plot')])
+ // function-plot is CJS-only; depending on the bundler's interop the
+ // callable lands at the namespace, .default, or .default.default
+ // (Vite's dynamic-import CJS wrapping adds a layer that static
+ // imports — like PreviewRenderer's — don't see).
+ const candidates = [plotModule, (plotModule as any).default, (plotModule as any).default?.default]
+ const functionPlot = candidates.find((c) => typeof c === 'function') as
+ | ((options: Record) => void)
+ | undefined
+ if (!functionPlot) throw new Error('function-plot module shape not recognized')
+ if (cancelled || !containerRef.current) return
+
+ const config = parsePlotSource(trimmed)
+ const data = config.code
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .flatMap((fn) => {
+ try {
+ return parse(fn) ? [{ fn }] : []
+ } catch {
+ // Mid-typing expressions like "sin(" are expected; skip quietly.
+ return []
+ }
+ })
+
+ if (data.length === 0) {
+ setMessage('Type mathematical functions to plot, one per line (e.g. x^2, sin(x)).')
+ return
+ }
+
+ containerRef.current.innerHTML = ''
+ functionPlot({
+ target: containerRef.current,
+ data,
+ width: config.width,
+ height: config.height,
+ xAxis: { domain: config.xDomain },
+ yAxis: { domain: config.yDomain },
+ grid: config.grid,
+ })
+ setMessage(null)
+ } catch (error) {
+ logger.error({ error }, 'Function plot preview render error')
+ if (!cancelled) setMessage('Unable to render plot. Check the function syntax.')
+ }
+ }, 500)
+
+ return () => {
+ cancelled = true
+ clearTimeout(timer)
+ }
+ }, [source])
+
+ return (
+
+ {message &&
{message}
}
+
+
+ )
+}