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}

} +
+
+ ) +}