From 035e21e2112f75fa2eed4dd82fa0ca9109b4544a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 20:33:21 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`feat/ty?= =?UTF-8?q?pst`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @Vito0912. * https://github.com/docmost/docmost/pull/1652#issuecomment-3353711947 The following files were modified: * `apps/client/src/features/editor/components/typst/typst-block.tsx` * `apps/client/src/features/editor/components/typst/typst-menu.tsx` * `apps/client/src/features/editor/page-editor.tsx` * `apps/server/src/collaboration/collaboration.util.ts` * `packages/editor-ext/src/lib/markdown/utils/marked.utils.ts` --- .../editor/components/typst/typst-block.tsx | 430 ++++++++++++++++++ .../editor/components/typst/typst-menu.tsx | 371 +++++++++++++++ .../src/features/editor/page-editor.tsx | 15 + .../src/collaboration/collaboration.util.ts | 11 +- .../src/lib/markdown/utils/marked.utils.ts | 17 +- 5 files changed, 842 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/editor/components/typst/typst-block.tsx create mode 100644 apps/client/src/features/editor/components/typst/typst-menu.tsx 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 ( + + +