📝 Add docstrings to feat/typst

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`
This commit is contained in:
coderabbitai[bot]
2025-09-30 20:33:21 +00:00
committed by GitHub
parent 3135030376
commit 035e21e211
5 changed files with 842 additions and 2 deletions

View File

@ -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<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const nodeViewRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(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<number>,
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<SVGElement>("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<HTMLTextAreaElement>) => {
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 (
<NodeViewWrapper
ref={nodeViewRef}
data-typst="true"
className={[
classes.typstBlock,
classes.displayMode,
props.selected ? classes.selected : "",
error ? classes.error : "",
!(node.attrs.text ?? "").trim().length ? classes.empty : "",
]
.filter(Boolean)
.join(" ")}
>
<div
className={classes.displayContainer}
style={heightCss ? { height: heightCss } : undefined}
>
<div
ref={resultRef}
className={classes.displayContent}
style={
heightCss ? { maxHeight: heightCss, height: "100%" } : undefined
}
></div>
</div>
{!(node.attrs.text ?? "").trim().length && (
<div>{t("Empty equation")}</div>
)}
{error && <div>{t("Error in equation")}</div>}
</NodeViewWrapper>
);
}
if (editMode === "inline") {
return (
<NodeViewWrapper
ref={nodeViewRef}
data-typst="true"
className={[classes.typstBlock, classes.inlineEditor]
.filter(Boolean)
.join(" ")}
>
<Stack gap="sm">
<Textarea
minRows={6}
maxRows={20}
autosize
resize="vertical"
ref={textAreaRef}
value={preview ?? ""}
placeholder={"#set heading(level: 1)[Title]"}
className={classes.inlineTextarea}
onKeyDown={handleKeyDown}
onChange={(event) => setPreview(event.target.value)}
autoFocus
/>
{error && (
<Text size="sm" c="red">
{error}
</Text>
)}
</Stack>
</NodeViewWrapper>
);
}
if (editMode === "split") {
return (
<NodeViewWrapper
ref={nodeViewRef}
data-typst="true"
className={[classes.typstBlock, classes.splitView]
.filter(Boolean)
.join(" ")}
>
<Stack gap="sm">
<div className={classes.splitContainer}>
<div
className={classes.splitEditor}
style={{ width: `${splitRatio}%` }}
>
<Text size="xs" fw={500} mb="xs" c="dimmed">
{t("Editor")}
</Text>
<Textarea
minRows={8}
maxRows={24}
autosize
resize="vertical"
ref={textAreaRef}
value={preview ?? ""}
placeholder={"#set heading(level: 1)[Title]"}
className={classes.splitTextarea}
onKeyDown={handleKeyDown}
onChange={(event) => setPreview(event.target.value)}
autoFocus
spellCheck={false}
styles={{ input: { caretColor: "blue" } }}
/>
</div>
<div
className={`${classes.splitResizer}`}
style={{ marginLeft: "4px", marginRight: "4px" }}
onMouseDown={handleMouseDown}
/>
<div
className={classes.splitPreview}
style={{ width: `${100 - splitRatio}%` }}
>
<Text size="xs" fw={500} mb="xs" c="dimmed">
{t("Preview")}
</Text>
<Box
className={classes.previewContainer}
style={heightCss ? { height: heightCss } : undefined}
>
<div
ref={previewRef}
style={
heightCss
? { maxHeight: heightCss, height: "100%" }
: undefined
}
></div>
{!preview?.trim() && (
<Text c="dimmed" ta="center" py="xl">
{t("Empty equation")}
</Text>
)}
</Box>
</div>
</div>
{error && (
<Text size="sm" c="red">
{error}
</Text>
)}
</Stack>
</NodeViewWrapper>
);
}
return null;
}

View File

@ -0,0 +1,371 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { NodeSelection } from "@tiptap/pm/state";
import { ActionIcon, NumberInput, TextInput, Tooltip } from "@mantine/core";
import {
IconColumns,
IconEdit,
IconEye,
IconTrashX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
const normalizeHeightValue = (
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;
};
const formatHeightForInput = (
value: string | number | null | undefined
): string => {
const normalized = normalizeHeightValue(value);
return normalized ?? "";
};
/**
* Render a contextual bubble menu for Typst blocks providing mode, height, and scale controls.
*
* The menu appears when a Typst block is selected (or the selection is inside one) and lets users
* toggle edit modes (display/inline/split), edit the block height with normalization and commit
* semantics, and adjust the rendering scale.
*
* @param editor - TipTap editor instance used to read selection/attributes and apply updates to the typstBlock node
* @returns A React element that renders the Typst block bubble menu bound to the provided editor
*/
export function TypstMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [heightValue, setHeightValue] = useState<string>("");
const [lastSyncedHeight, setLastSyncedHeight] = useState<string>("");
const [isHeightDirty, setIsHeightDirty] = useState(false);
const wasMenuActiveRef = useRef(false);
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isEditable) {
return false;
}
const { selection } = state;
if (
selection instanceof NodeSelection &&
selection.node.type.name === "typstBlock"
) {
return true;
}
const predicate = (node: PMNode) => node.type.name === "typstBlock";
const parent = findParentNode(predicate)(selection);
return Boolean(parent);
},
[editor]
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const { selection, tr } = ctx.editor.state;
const predicate = (node: PMNode) => node.type.name === "typstBlock";
const target =
selection instanceof NodeSelection &&
selection.node.type.name === "typstBlock"
? { node: selection.node }
: findParentNode(predicate)(selection);
if (!target) {
return null;
}
const attrs = target?.node?.attrs ?? {};
const currentMode = (attrs.editMode as string) || "display";
const currentScale = (attrs.scale as number) || 100;
const currentHeight = attrs.height ?? null;
return {
isDisplay: currentMode === "display",
isInline: currentMode === "inline",
isSplit: currentMode === "split",
currentScale,
currentHeight,
updateId: tr.time,
};
},
});
const isMenuActive = Boolean(editorState);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
if (
selection instanceof NodeSelection &&
selection.node.type.name === "typstBlock"
) {
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
if (dom) {
return dom.getBoundingClientRect();
}
}
const predicate = (node: PMNode) => node.type.name === "typstBlock";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent.pos) as HTMLElement | null;
if (dom) {
return dom.getBoundingClientRect();
}
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const setDisplayMode = useCallback(() => {
editor.commands.updateAttributes("typstBlock", { editMode: "display" });
editor.commands.focus();
}, [editor]);
const setInlineMode = useCallback(() => {
editor.commands.updateAttributes("typstBlock", { editMode: "inline" });
editor.commands.focus();
}, [editor]);
const setSplitMode = useCallback(() => {
editor.commands.updateAttributes("typstBlock", { editMode: "split" });
editor.commands.focus();
}, [editor]);
const setScale = useCallback(
(scale: number) => {
editor.commands.updateAttributes("typstBlock", { scale });
},
[editor]
);
useEffect(() => {
const formatted = formatHeightForInput(editorState?.currentHeight ?? null);
setLastSyncedHeight(formatted);
if (!isHeightDirty) {
setHeightValue(formatted);
}
}, [editorState?.currentHeight, editorState?.updateId, isHeightDirty]);
const commitHeight = useCallback(
(options?: { skipFocus?: boolean }) => {
const nextHeight = normalizeHeightValue(heightValue);
const nextFormatted = nextHeight ?? "";
if (nextFormatted === lastSyncedHeight) {
if (heightValue !== nextFormatted) {
setHeightValue(nextFormatted);
}
setIsHeightDirty(false);
return;
}
editor.commands.updateAttributes("typstBlock", { height: nextHeight });
if (!options?.skipFocus) {
editor.commands.focus();
}
setLastSyncedHeight(nextFormatted);
setHeightValue(nextFormatted);
setIsHeightDirty(false);
},
[editor, heightValue, lastSyncedHeight]
);
useEffect(() => {
if (wasMenuActiveRef.current && !isMenuActive) {
commitHeight({ skipFocus: true });
}
wasMenuActiveRef.current = isMenuActive;
}, [commitHeight, isMenuActive]);
const handleHeightFocus = useCallback(() => {
setIsHeightDirty(true);
}, []);
const handleHeightChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setHeightValue(event.currentTarget.value);
setIsHeightDirty(true);
},
[]
);
const handleHeightBlur = useCallback(() => {
commitHeight({ skipFocus: true });
}, [commitHeight]);
const handleHeightKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
commitHeight({ skipFocus: true });
}
},
[commitHeight]
);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
.chain()
.focus()
.setNodeSelection(selection.from)
.deleteSelection()
.run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="typst-menu"
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 4],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label={t("Display Mode")}>
<ActionIcon
variant={editorState?.isDisplay ? "light" : "default"}
size="lg"
aria-label={t("Display Mode")}
onClick={setDisplayMode}
>
<IconEye size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit Mode")}>
<ActionIcon
variant={editorState?.isInline ? "light" : "default"}
size="lg"
aria-label={t("Edit Mode")}
onClick={setInlineMode}
>
<IconEdit size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Split View")}>
<ActionIcon
variant={editorState?.isSplit ? "light" : "default"}
size="lg"
aria-label={t("Split View")}
onClick={setSplitMode}
>
<IconColumns size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Height")}>
<TextInput
value={heightValue}
onFocus={handleHeightFocus}
onChange={handleHeightChange}
onBlur={handleHeightBlur}
onKeyDown={handleHeightKeyDown}
size="sm"
w={120}
placeholder={t("Auto")}
spellCheck={false}
autoComplete="off"
styles={{
input: {
textAlign: "center",
height: "36px",
fontSize: "12px",
},
}}
/>
</Tooltip>
<Tooltip position="top" label={t("Scale")}>
<NumberInput
value={editorState?.currentScale || 100}
onChange={(value) =>
setScale(typeof value === "number" ? Math.round(value) : 100)
}
min={50}
max={300}
step={10}
suffix="%"
size="sm"
w={90}
styles={{
input: {
textAlign: "center",
height: "36px",
fontSize: "12px",
},
}}
/>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
);
}
export default TypstMenu;

View File

@ -44,6 +44,7 @@ import {
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import TypstMenu from "./components/typst/typst-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
@ -63,6 +64,19 @@ interface PageEditorProps {
content: any;
}
/**
* Renders a collaborative rich-text editor for a specific page with optional editability and initial content.
*
* This component initializes Yjs providers (local IndexedDB persistence and a remote Hocuspocus provider),
* manages connection/token lifecycle, integrates collaboration-aware editor extensions and UI menus,
* updates local page cache during edits, and coordinates comment/aside behavior and idle/visibility-based
* connection handling.
*
* @param pageId - The ID of the page to edit and the document name used for collaboration (e.g., "page.{pageId}")
* @param editable - Whether the editor should permit user edits (subject to user preferences and permissions)
* @param content - Initial editor document content to render when the editor is shown statically before collaboration connects
* @returns A React element that renders the collaborative page editor and its contextual menus/dialogs
*/
export default function PageEditor({
pageId,
editable,
@ -412,6 +426,7 @@ export default function PageEditor({
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<TypstMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}

View File

@ -33,12 +33,14 @@ import {
Embed,
Mention,
Subpages,
TypstBlock,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { generateHTML } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
@ -80,8 +82,15 @@ export const tiptapExtensions = [
Embed,
Mention,
Subpages,
TypstBlock,
] as any;
/**
* Convert a Tiptap JSON document to HTML.
*
* @param tiptapJson - Tiptap-compatible JSON document (editor state/content)
* @returns An HTML string representing the provided document
*/
export function jsonToHtml(tiptapJson: any) {
return generateHTML(tiptapJson, tiptapExtensions);
}

View File

@ -2,6 +2,7 @@ import { marked } from "marked";
import { calloutExtension } from "./callout.marked";
import { mathBlockExtension } from "./math-block.marked";
import { mathInlineExtension } from "./math-inline.marked";
import { typstBlockExtension } from "./typst-block.marked";
marked.use({
renderer: {
@ -29,9 +30,23 @@ marked.use({
});
marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
extensions: [
calloutExtension,
mathBlockExtension,
mathInlineExtension,
typstBlockExtension,
],
});
/**
* Converts Markdown text to HTML.
*
* The function removes an optional leading YAML front matter block, trims leading whitespace,
* parses the remaining Markdown with line-breaks enabled, and returns the resulting HTML.
*
* @param markdownInput - Markdown source text; may include a leading YAML front matter section delimited by `---`.
* @returns The generated HTML string produced from the parsed Markdown.
*/
export function markdownToHtml(
markdownInput: string,
): string | Promise<string> {