mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 09:11:15 +10:00
📝 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:
committed by
GitHub
parent
3135030376
commit
035e21e211
430
apps/client/src/features/editor/components/typst/typst-block.tsx
Normal file
430
apps/client/src/features/editor/components/typst/typst-block.tsx
Normal 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;
|
||||
}
|
||||
371
apps/client/src/features/editor/components/typst/typst-menu.tsx
Normal file
371
apps/client/src/features/editor/components/typst/typst-menu.tsx
Normal 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;
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user