diff --git a/apps/client/src/features/editor/components/typst/typst-block.tsx b/apps/client/src/features/editor/components/typst/typst-block.tsx new file mode 100644 index 00000000..07c998f7 --- /dev/null +++ b/apps/client/src/features/editor/components/typst/typst-block.tsx @@ -0,0 +1,430 @@ +import { + MutableRefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { + Stack, + Textarea, + Box, + Text, + useComputedColorScheme, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useTranslation } from "react-i18next"; +import classes from "./typst.module.css"; +import { renderTypstToSvg } from "@/features/editor/utils"; + +const normalizeCssHeight = ( + value: string | number | null | undefined +): string | null => { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return null; + } + return `${value}px`; + } + + const trimmed = value.trim(); + + if (!trimmed.length) { + return null; + } + + if (trimmed.toLowerCase() === "auto") { + return null; + } + + if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + const numeric = Number.parseFloat(trimmed); + if (!Number.isNaN(numeric) && Number.isFinite(numeric)) { + return `${numeric}px`; + } + } + + return trimmed; +}; + +/** + * Render a TipTap NodeView for displaying and editing Typst content with display, inline, and split modes. + * + * Provides: + * - A display mode that shows rendered Typst SVG output with configurable scale and height. + * - An inline single-textarea editor mode with live error feedback. + * - A split editor/preview mode with a draggable resizer and debounced preview-to-document synchronization. + * + * The component respects editor read-only state, applies optional dark-mode color inversion to rendered SVGs, and surfaces translation-ready UI strings for empty or error states. + * + * @returns The React element tree for the Typst block node view, or `null` if no supported mode is active. + */ +export default function TypstBlockView(props: NodeViewProps) { + const { t } = useTranslation(); + const { node, updateAttributes, editor } = props; + const resultRef = useRef(null); + const previewRef = useRef(null); + const textAreaRef = useRef(null); + const nodeViewRef = useRef(null); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + const [splitRatio, setSplitRatio] = useState(50); // Percentage for left panel + const [debouncedPreview] = useDebouncedValue(preview, 600); + const resultJob = useRef(0); + const previewJob = useRef(0); + const computedColorScheme = useComputedColorScheme(); + const isDarkMode = computedColorScheme === "dark"; + + const editMode = node.attrs.editMode || "display"; + const scale = node.attrs.scale || 100; + const rawHeight = node.attrs.height as string | number | null | undefined; + const heightCss = normalizeCssHeight(rawHeight); + + const renderOutput = useCallback( + async ( + value: string, + container: HTMLDivElement | null, + jobRef: MutableRefObject, + applyScale: number = 100, + maxHeight?: string | number | null, + shouldInvert: boolean = false + ) => { + if (!container) { + return; + } + const job = jobRef.current + 1; + jobRef.current = job; + if (!value.trim()) { + container.innerHTML = ""; + if (jobRef.current === job) { + setError(null); + } + return; + } + try { + const svg = await renderTypstToSvg(value); + if (jobRef.current !== job) { + return; + } + container.innerHTML = svg; + + const normalizedMaxHeight = normalizeCssHeight(maxHeight); + const svgElements = Array.from( + container.querySelectorAll("svg") + ); + svgElements.forEach((svgElement) => { + svgElement.style.removeProperty("transform"); + svgElement.style.removeProperty("transform-origin"); + { + const widthAttr = + svgElement.getAttribute("width") ?? svgElement.style.width; + let numericWidth = Number.NaN; + if (typeof widthAttr === "string" && widthAttr.trim().length) { + numericWidth = parseFloat(widthAttr); + } + if (!Number.isFinite(numericWidth)) { + const rect = svgElement.getBoundingClientRect(); + if (rect && Number.isFinite(rect.width)) { + numericWidth = rect.width; + } + } + if (Number.isFinite(numericWidth)) { + const scaled = numericWidth * (applyScale / 100); + svgElement.style.setProperty("width", `${scaled}px`, "important"); + } else { + svgElement.style.removeProperty("width"); + } + } + svgElement.style.height = "auto"; + svgElement.style.maxWidth = "none"; + svgElement.style.display = "block"; + + if (shouldInvert) { + svgElement.style.filter = "invert(1) hue-rotate(180deg)"; + } else { + svgElement.style.removeProperty("filter"); + } + }); + + container.style.width = "100%"; + container.style.overflowX = "auto"; + container.style.removeProperty("overflow"); + + if (normalizedMaxHeight) { + container.style.maxHeight = normalizedMaxHeight; + container.style.height = normalizedMaxHeight; + container.style.overflowY = "auto"; + } else { + container.style.removeProperty("max-height"); + container.style.removeProperty("height"); + container.style.removeProperty("overflow-y"); + } + + setError(null); + } catch (err) { + if (jobRef.current !== job) { + return; + } + container.innerHTML = ""; + setError(t("Typst rendering error")); + } + }, + [t] + ); + + useEffect(() => { + renderOutput( + node.attrs.text ?? "", + resultRef.current, + resultJob, + scale, + heightCss, + isDarkMode + ); + }, [node.attrs.text, renderOutput, scale, heightCss, isDarkMode]); + + useEffect(() => { + if (editMode === "split") { + renderOutput( + debouncedPreview ?? "", + previewRef.current, + previewJob, + scale, + heightCss, + isDarkMode + ); + } + }, [debouncedPreview, editMode, renderOutput, scale, heightCss, isDarkMode]); + + useEffect(() => { + if (debouncedPreview === null) { + return; + } + queueMicrotask(() => { + updateAttributes({ text: debouncedPreview }); + }); + }, [debouncedPreview, updateAttributes]); + + useEffect(() => { + if (props.selected && editMode === "display") { + setPreview(node.attrs.text ?? ""); + } + }, [props.selected, node.attrs.text, editMode]); + + useEffect(() => { + if (editMode !== "display" && preview === null) { + setPreview(node.attrs.text ?? ""); + } else if (editMode === "display") { + setPreview(null); + setTimeout(() => { + renderOutput( + node.attrs.text ?? "", + resultRef.current, + resultJob, + scale, + heightCss, + isDarkMode + ); + }, 0); + } + }, [ + editMode, + node.attrs.text, + preview, + renderOutput, + scale, + heightCss, + isDarkMode, + ]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + updateAttributes({ editMode: "display" }); + return; + } + + if (!textAreaRef.current) { + return; + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (editMode !== "split") return; + + const startX = e.clientX; + const startRatio = splitRatio; + const container = nodeViewRef.current; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const containerWidth = containerRect.width - 32; // pading + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - startX; + const deltaPercent = (deltaX / containerWidth) * 100; + const newRatio = Math.min(Math.max(startRatio + deltaPercent, 20), 80); + setSplitRatio(newRatio); + }; + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + const isReadonly = !editor.isEditable; + + if (editMode === "display" || isReadonly) { + return ( + +
+
+
+ {!(node.attrs.text ?? "").trim().length && ( +
{t("Empty equation")}
+ )} + {error &&
{t("Error in equation")}
} +
+ ); + } + + if (editMode === "inline") { + return ( + + +