mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 09:01:37 +10:00
Merge branch 'main' into feat/bases
This commit is contained in:
+17
-17
@@ -21,20 +21,20 @@
|
||||
"@docmost/base-formula": "workspace:*",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/dates": "8.3.18",
|
||||
"@mantine/form": "8.3.18",
|
||||
"@mantine/hooks": "8.3.18",
|
||||
"@mantine/modals": "8.3.18",
|
||||
"@mantine/notifications": "8.3.18",
|
||||
"@mantine/spotlight": "8.3.18",
|
||||
"@mantine/core": "9.3.2",
|
||||
"@mantine/dates": "9.3.2",
|
||||
"@mantine/form": "9.3.2",
|
||||
"@mantine/hooks": "9.3.2",
|
||||
"@mantine/modals": "9.3.2",
|
||||
"@mantine/notifications": "9.3.2",
|
||||
"@mantine/spotlight": "9.3.2",
|
||||
"@slidoapp/emoji-mart": "5.8.7",
|
||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.14.2",
|
||||
"@tanstack/react-virtual": "3.14.3",
|
||||
"alfaaz": "1.1.0",
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"jotai": "2.18.1",
|
||||
"jotai": "2.20.1",
|
||||
"jotai-optics": "0.4.0",
|
||||
"js-cookie": "3.0.7",
|
||||
"jwt-decode": "4.0.0",
|
||||
@@ -52,16 +52,16 @@
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mitt": "3.0.1",
|
||||
"nanoid": "^3.3.8",
|
||||
"posthog-js": "1.372.2",
|
||||
"react": "18.3.1",
|
||||
"nanoid": "3.3.8",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "19.2.7",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-drawio": "1.0.7",
|
||||
"react-error-boundary": "6.1.1",
|
||||
"react-helmet-async": "3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-router-dom": "7.13.1",
|
||||
"react-router-dom": "7.18.0",
|
||||
"semver": "7.7.4",
|
||||
"socket.io-client": "4.8.3",
|
||||
"zod": "4.3.6"
|
||||
@@ -76,8 +76,8 @@
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
@@ -92,7 +92,7 @@
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,8 @@
|
||||
"Insert mermaid diagram": "Insert mermaid diagram",
|
||||
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
||||
"Insert current date": "Insert current date",
|
||||
"Time": "Time",
|
||||
"Insert current time": "Insert current time",
|
||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||
"Multiple": "Multiple",
|
||||
"Turn into": "Turn into",
|
||||
@@ -1108,6 +1110,12 @@
|
||||
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||
"Allow personal spaces": "Allow personal spaces",
|
||||
"Members can create their own personal space.": "Members can create their own personal space.",
|
||||
"Toggle allow personal spaces": "Toggle allow personal spaces",
|
||||
"Create personal space": "Create personal space",
|
||||
"Personal space": "Personal space",
|
||||
"{{name}}'s space": "{{name}}'s space",
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
exportPage,
|
||||
exportPageToDocx,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
@@ -32,17 +40,25 @@ export default function ExportModal({
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDocx = format === ExportFormat.Docx;
|
||||
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
||||
const blockedByLicense = isDocx && !docxEntitled;
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
if (format === ExportFormat.Docx) {
|
||||
await exportPageToDocx({ pageId: id });
|
||||
} else {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
@@ -88,10 +104,15 @@ export default function ExportModal({
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
<ExportFormatSelection
|
||||
format={format}
|
||||
onChange={handleChange}
|
||||
includeDocx={type === "page"}
|
||||
docxEntitled={docxEntitled}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{type === "page" && (
|
||||
{type === "page" && !isDocx && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
@@ -143,7 +164,16 @@ export default function ExportModal({
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
loading={isExporting}
|
||||
disabled={blockedByLicense}
|
||||
data-disabled={blockedByLicense || undefined}
|
||||
>
|
||||
{t("Export")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
@@ -154,23 +184,49 @@ export default function ExportModal({
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
includeDocx?: boolean;
|
||||
docxEntitled?: boolean;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
function ExportFormatSelection({
|
||||
format,
|
||||
onChange,
|
||||
includeDocx,
|
||||
docxEntitled,
|
||||
}: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = [
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
...(includeDocx
|
||||
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
data={data}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||
comboboxProps={{ width: 200 }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
renderOption={({ option }) =>
|
||||
option.value === "docx" && !docxEntitled ? (
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Badge size="xs" mt={4}>
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm">{option.label}</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,16 @@ import {
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
|
||||
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -36,11 +43,20 @@ export default function TopMenu() {
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
|
||||
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
|
||||
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
|
||||
const [
|
||||
createOpened,
|
||||
{ open: openCreate, close: closeCreate },
|
||||
] = useDisclosure(false);
|
||||
|
||||
if (!user || !workspace) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
@@ -115,6 +131,26 @@ export default function TopMenu() {
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
{personalSpace ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={getSpaceUrl(personalSpace.slug)}
|
||||
leftSection={<IconUser size={16} />}
|
||||
>
|
||||
{t("Personal space")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
hasPersonalSpaces &&
|
||||
settingEnabled && (
|
||||
<Menu.Item
|
||||
onClick={openCreate}
|
||||
leftSection={<IconUser size={16} />}
|
||||
>
|
||||
{t("Create personal space")}
|
||||
</Menu.Item>
|
||||
)
|
||||
)}
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
@@ -160,5 +196,8 @@ export default function TopMenu() {
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,14 +54,17 @@ export const CustomAvatar = React.forwardRef<
|
||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const isInitials = !color || color === "initials";
|
||||
const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||
const hue = pickedColor.split(".")[0];
|
||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||
|
||||
const resolvedColor = variant === "filled" ? pickedColor : hue;
|
||||
|
||||
const placeholderStyles =
|
||||
isInitials && variant !== "filled"
|
||||
? {
|
||||
placeholder: {
|
||||
color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`,
|
||||
color: `var(--mantine-color-${hue}-9)`,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function ChatInput({
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!editor || isStreaming) return;
|
||||
if (!editor || editor.isDestroyed || isStreaming) return;
|
||||
const json = editor.getJSON();
|
||||
const text = editorJsonToText(json).trim();
|
||||
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
||||
@@ -264,7 +264,7 @@ export default function ChatInput({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && autofocus) {
|
||||
if (editor && !editor.isDestroyed && autofocus) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, TextInput } from "@mantine/core";
|
||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconArrowUp } from "@tabler/icons-react";
|
||||
@@ -14,7 +21,7 @@ import { ResultPreview } from "./result-preview.tsx";
|
||||
import classes from "./ai-menu.module.css";
|
||||
import { marked } from "marked";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface EditorAiMenuProps {
|
||||
@@ -49,7 +56,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
});
|
||||
}, [prompt, output, activeCommandSet]);
|
||||
const updateMenuPlacement = useCallback(() => {
|
||||
if (!editor || !showAiMenu) return;
|
||||
if (!isEditorReady(editor) || !showAiMenu) return;
|
||||
|
||||
const { view } = editor;
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -95,7 +102,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
);
|
||||
const handleGenerate = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
if (!editor || isLoading) return;
|
||||
if (!isEditorReady(editor) || isLoading) return;
|
||||
|
||||
let command: CommandItem | null = item || null;
|
||||
|
||||
@@ -158,6 +165,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
return setActiveCommandSet("main");
|
||||
}
|
||||
if (item.id === "result-replace") {
|
||||
if (!isEditorReady(editor)) return setShowAiMenu(false);
|
||||
const chain = editor.chain().focus();
|
||||
|
||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
||||
@@ -183,6 +191,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-insert-below") {
|
||||
if (!isEditorReady(editor)) return setShowAiMenu(false);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
@@ -246,7 +255,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const handleClose = () => setShowAiMenu(false);
|
||||
const observer = new ResizeObserver(() => {
|
||||
|
||||
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
|
||||
{expandable && (
|
||||
<Table.Tr className={classes.detailRow}>
|
||||
<Table.Td colSpan={4} p={0}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Collapse expanded={isExpanded}>
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function BillingPlans() {
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
onChange={(value) => setSelectedTierValue(value)}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
interface ResolveCommentProps {
|
||||
editor: Editor;
|
||||
@@ -31,7 +32,7 @@ function ResolveComment({
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,5 +19,7 @@ export const Feature = {
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
TEMPLATES: 'templates',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
} as const;
|
||||
|
||||
@@ -20,6 +20,7 @@ const enterpriseFeatures = [
|
||||
"Bases",
|
||||
"Kanban",
|
||||
"Templates",
|
||||
"Personal Spaces"
|
||||
];
|
||||
|
||||
export default function OssDetails() {
|
||||
|
||||
@@ -193,7 +193,7 @@ export function MfaSetupModal({
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={manualEntryOpen}>
|
||||
<Collapse expanded={manualEntryOpen}>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={20} />}
|
||||
color="gray"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Modal, TextInput, Button, Group, Divider } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useCreatePersonalSpaceMutation } from "@/ee/personal-space/queries/personal-space-query";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(2).max(100),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function CreatePersonalSpaceModal({ opened, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const currentUser = useAtomValue(currentUserAtom);
|
||||
const createMutation = useCreatePersonalSpaceMutation();
|
||||
|
||||
const firstName = (currentUser?.user?.name ?? "").trim().split(/\s+/)[0] || "";
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: firstName ? t("{{name}}'s space", { name: firstName }) : "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const createdSpace = await createMutation.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
onClose();
|
||||
navigate(getSpaceUrl(createdSpace.slug));
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Create personal space")}
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Divider size="xs" mb="md" />
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput
|
||||
withAsterisk
|
||||
data-autofocus
|
||||
label={t("Space name")}
|
||||
variant="filled"
|
||||
errorProps={{ role: "alert" }}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button type="submit" loading={createMutation.isPending}>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function PersonalSpacesSetting() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Allow personal spaces")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Members can create their own personal space.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<PersonalSpacesToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalSpacesToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.spaces?.allowPersonal === true,
|
||||
);
|
||||
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({
|
||||
allowPersonalSpaces: value,
|
||||
});
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} disabled={hasPersonalSpaces} refProp="rootRef">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasPersonalSpaces}
|
||||
aria-label={t("Toggle allow personal spaces")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import {
|
||||
createPersonalSpace,
|
||||
getPersonalSpace,
|
||||
} from "@/ee/personal-space/services/personal-space-service";
|
||||
|
||||
export function usePersonalSpaceQuery(
|
||||
enabled: boolean,
|
||||
): UseQueryResult<ISpace | null, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["personal-space"],
|
||||
queryFn: () => getPersonalSpace(),
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePersonalSpaceMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ISpace, Error, { name?: string }>({
|
||||
mutationFn: (data) => createPersonalSpace(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["personal-space"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["spaces"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
|
||||
export async function getPersonalSpace(): Promise<ISpace | null> {
|
||||
const req = await api.post<ISpace | null>("/personal-space/info", {});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createPersonalSpace(data: {
|
||||
name?: string;
|
||||
}): Promise<ISpace> {
|
||||
const req = await api.post<ISpace>("/personal-space/create", data);
|
||||
return req.data;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export default function TemplateEditor() {
|
||||
|
||||
// Load template data into editor
|
||||
useEffect(() => {
|
||||
if (existingTemplate && editor) {
|
||||
if (existingTemplate && editor && !editor.isDestroyed) {
|
||||
loadedRef.current = false;
|
||||
setTitle(existingTemplate.title || "");
|
||||
setIcon(existingTemplate.icon || null);
|
||||
@@ -383,7 +383,8 @@ export default function TemplateEditor() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor?.commands.focus("start");
|
||||
if (editor && !editor.isDestroyed)
|
||||
editor.commands.focus("start");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -48,11 +49,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
setReadOnlyCommentData(null);
|
||||
} else {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
if (!isEditorReady(editor)) return "";
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to);
|
||||
};
|
||||
@@ -74,24 +78,28 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
|
||||
const createdComment =
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
editor
|
||||
.chain()
|
||||
.setComment(createdComment.id)
|
||||
.unsetCommentDecoration()
|
||||
.run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor
|
||||
.chain()
|
||||
.setComment(createdComment.id)
|
||||
.unsetCommentDecoration()
|
||||
.run();
|
||||
editor.commands.setTextSelection({
|
||||
from: editor.view.state.selection.from,
|
||||
to: editor.view.state.selection.from,
|
||||
});
|
||||
}
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.view.dispatch(editor.state.tr.scrollIntoView());
|
||||
}
|
||||
}, 400);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -112,22 +112,24 @@ const CommentEditor = forwardRef(
|
||||
// websocket on another browser). Skip for editable editors to avoid
|
||||
// resetting the cursor position on every keystroke.
|
||||
useEffect(() => {
|
||||
if (!editable && commentEditor && defaultContent) {
|
||||
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}
|
||||
}, [defaultContent, editable, commentEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
commentEditor?.commands.focus("end");
|
||||
if (autofocus && commentEditor && !commentEditor.isDestroyed) {
|
||||
commentEditor.commands.focus("end");
|
||||
}
|
||||
}, 10);
|
||||
}, [commentEditor, autofocus]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearContent: () => {
|
||||
commentEditor.commands.clearContent();
|
||||
if (commentEditor && !commentEditor.isDestroyed) {
|
||||
commentEditor.commands.clearContent();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
@@ -75,7 +76,9 @@ function CommentListItem({
|
||||
async function handleDeleteComment() {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetComment(comment.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
@@ -93,7 +96,7 @@ function CommentListItem({
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
type ReadonlyBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
@@ -29,6 +30,10 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
if (isInteractingRef.current) return;
|
||||
if (!isEditorReady(editor)) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pmSelection = editor.state.selection;
|
||||
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
|
||||
@@ -97,7 +102,7 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
}, [showReadOnlyCommentPopup]);
|
||||
|
||||
const handleCommentClick = () => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const view = editor.view;
|
||||
const ystate = ySyncPluginKey.getState(view.state);
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const nodesWithMenus = [
|
||||
"callout",
|
||||
|
||||
@@ -111,8 +111,9 @@ async function reuploadPastedAttachments(
|
||||
const match = ATTACHMENT_URL_RE.exec(src);
|
||||
if (!match) return;
|
||||
|
||||
const cleanSrc = src.split("?")[0];
|
||||
const fileName =
|
||||
node.attrs.name || src.split("/").pop() || "file";
|
||||
node.attrs.name || cleanSrc.split("/").pop() || "file";
|
||||
|
||||
pastedNodes.push({
|
||||
pos,
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
<Menu.Item
|
||||
leftSection={<IconRotate2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertTransclusionSource().run()
|
||||
editor.chain().focus().toggleTransclusionSource().run()
|
||||
}
|
||||
>
|
||||
{t("Synced block")}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
|
||||
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
|
||||
import { normalizeUrl } from "@/lib/utils";
|
||||
|
||||
const parseInternalLink = (
|
||||
@@ -313,7 +313,9 @@ export default function LinkView(props: MarkViewProps) {
|
||||
);
|
||||
|
||||
const handleRemoveLink = useCallback(() => {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
}
|
||||
setPopoverState("closed");
|
||||
}, [editor]);
|
||||
|
||||
@@ -345,7 +347,7 @@ export default function LinkView(props: MarkViewProps) {
|
||||
NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
const textNode = walker.nextNode();
|
||||
if (textNode) {
|
||||
if (textNode && isEditorReady(editor)) {
|
||||
const view = editor.view as any;
|
||||
view.domObserver.stop();
|
||||
textNode.nodeValue = val;
|
||||
|
||||
+10
-2
@@ -17,6 +17,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -64,13 +65,13 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
replaceButtonToggle();
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
//TODO: check type error
|
||||
@@ -90,27 +91,32 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replaceAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
@@ -118,6 +124,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
const handleOpenEvent = (e) => {
|
||||
setPageFindState({ isOpen: true });
|
||||
if (!isEditorReady(editor)) return;
|
||||
const selectedText = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
@@ -149,6 +156,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
}, [pageFindState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
||||
editor.commands.resetIndex();
|
||||
goToSelection();
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IconMenu4,
|
||||
IconPageBreak,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
IconColumns3,
|
||||
@@ -494,6 +495,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Time",
|
||||
description: "Insert current time",
|
||||
searchTerms: ["time", "now", "clock"],
|
||||
icon: IconClock,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
const currentTime = new Date().toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(currentTime)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
description: "Insert inline status badge.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -50,6 +50,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
const headerPaddingRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleScrollToHeading = (position: number) => {
|
||||
if (!props.editor || props.editor.isDestroyed) return;
|
||||
const { view } = props.editor;
|
||||
|
||||
const headerOffset = parseInt(
|
||||
@@ -73,16 +74,21 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||
if (!props.editor || props.editor.isDestroyed) return;
|
||||
|
||||
const result = recalculateLinks(props.editor.$nodes("heading"));
|
||||
|
||||
setLinks(result.links);
|
||||
setHeadingDOMNodes(result.nodes);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// "create" repopulates once the editor view mounts after this component
|
||||
props.editor?.on("create", handleUpdate);
|
||||
props.editor?.on("update", handleUpdate);
|
||||
|
||||
return () => {
|
||||
props.editor?.off("create", handleUpdate);
|
||||
props.editor?.off("update", handleUpdate);
|
||||
};
|
||||
}, [props.editor]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Menu, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { isCellSelection, isEditorReady } from "@docmost/editor-ext";
|
||||
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
@@ -27,7 +27,9 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
tablePos,
|
||||
}: CellChevronProps) {
|
||||
const { t } = useTranslation();
|
||||
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
|
||||
const cellDom = isEditorReady(editor)
|
||||
? (editor.view.nodeDOM(cellPos) as HTMLElement | null)
|
||||
: null;
|
||||
|
||||
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||
placement: "top-end",
|
||||
@@ -61,6 +63,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
});
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const current = editor.state.selection;
|
||||
|
||||
// Preserve an existing multi-cell CellSelection that already covers
|
||||
@@ -86,6 +89,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
}, [editor, cellPos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
|
||||
|
||||
interface Args {
|
||||
@@ -19,6 +20,7 @@ export function useColumnRowMenuLifecycle({
|
||||
tablePos,
|
||||
}: Args) {
|
||||
const onOpen = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const selection = buildRowOrColumnSelection(
|
||||
editor.state,
|
||||
tableNode,
|
||||
@@ -33,6 +35,7 @@ export function useColumnRowMenuLifecycle({
|
||||
}, [editor, orientation, index, tableNode, tablePos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
type Scope =
|
||||
| { kind: "col"; index: number }
|
||||
@@ -15,6 +16,7 @@ export function useTableClear(
|
||||
scope: Scope,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const tr = editor.state.tr;
|
||||
const tableStart = tablePos + 1;
|
||||
const map = TableMap.get(tableNode);
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
import { isEditorReady, moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
|
||||
export type MoveDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useTableMoveRowColumn(
|
||||
const canMove = target >= 0 && target <= maxIndex;
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!canMove) return;
|
||||
if (!canMove || !isEditorReady(editor)) return;
|
||||
const tr = editor.state.tr;
|
||||
const moved =
|
||||
orientation === "col"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
convertArrayOfRowsToTableNode,
|
||||
convertTableNodeToArrayOfRows,
|
||||
isEditorReady,
|
||||
transpose,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -63,7 +64,7 @@ export function useTableSort({
|
||||
}, [tableNode, orientation, index]);
|
||||
|
||||
const handleSort = useCallback(() => {
|
||||
if (!canSort) return;
|
||||
if (!canSort || !isEditorReady(editor)) return;
|
||||
|
||||
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||
const axes = orientation === "col" ? rows : transpose(rows);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
|
||||
@@ -160,7 +160,11 @@ export function TitleEditor({
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
if (
|
||||
titleEditor &&
|
||||
!titleEditor.isDestroyed &&
|
||||
title !== titleEditor.getText()
|
||||
) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
@@ -34,7 +34,7 @@ export function HistoryEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !content) return;
|
||||
if (!editor || editor.isDestroyed || !content) return;
|
||||
|
||||
let decorationSet = DecorationSet.empty;
|
||||
let addedCount = 0;
|
||||
|
||||
@@ -132,6 +132,25 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
export async function exportPageToDocx(data: { pageId: string }): Promise<void> {
|
||||
const req = await api.post("/docx-export", data, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const fileName = req?.headers["content-disposition"]
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
let decodedFileName = fileName;
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName);
|
||||
} catch (err) {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
export async function importPage(file: File, spaceId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("spaceId", spaceId);
|
||||
|
||||
@@ -99,4 +99,5 @@ export interface IExportPageParams {
|
||||
export enum ExportFormat {
|
||||
HTML = "html",
|
||||
Markdown = "markdown",
|
||||
Docx = "docx",
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ShareShell({
|
||||
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLElement | null>(null);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -17,8 +17,8 @@ const formSchema = z.object({
|
||||
.min(2)
|
||||
.max(100)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Space slug must be alphanumeric. No special characters",
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
||||
"Space slug must start with a letter or number and may contain hyphens and underscores",
|
||||
),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ const formSchema = z.object({
|
||||
.min(2)
|
||||
.max(100)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Space slug must be alphanumeric. No special characters",
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
||||
"Space slug must start with a letter or number and may contain hyphens and underscores",
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ISpace {
|
||||
description: string;
|
||||
logo?: string;
|
||||
slug: string;
|
||||
isPersonal?: boolean;
|
||||
hostname: string;
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
|
||||
defaultValue={UserRole.MEMBER}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
onChange={setRole}
|
||||
onChange={(value) => setRole(value)}
|
||||
/>
|
||||
|
||||
<MultiGroupSelect
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface IWorkspace {
|
||||
trashRetentionDays?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
allowPersonalSpaces?: boolean;
|
||||
isScimEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ export interface IWorkspaceSettings {
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
api?: IWorkspaceApiSettings;
|
||||
templates?: IWorkspaceTemplateSettings;
|
||||
spaces?: IWorkspaceSpaceSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceApiSettings {
|
||||
@@ -57,6 +59,10 @@ export interface IWorkspaceTemplateSettings {
|
||||
allowMemberTemplates?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSpaceSettings {
|
||||
allowPersonal?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
@@ -13,6 +13,7 @@ api.interceptors.response.use(
|
||||
const exemptEndpoints = [
|
||||
"/api/pages/export",
|
||||
"/api/spaces/export",
|
||||
"/api/docx-export",
|
||||
"/api/bases/export-csv",
|
||||
];
|
||||
if (response.request.responseURL) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Helmet } from "react-helmet-async";
|
||||
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
||||
import { Divider } from "@mantine/core";
|
||||
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
|
||||
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,6 +23,9 @@ export default function WorkspaceSettings() {
|
||||
<Divider my="md" />
|
||||
<AllowMemberTemplates />
|
||||
|
||||
<Divider my="md" />
|
||||
<PersonalSpacesSetting />
|
||||
|
||||
{isCloud() && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
|
||||
@@ -25,3 +25,11 @@
|
||||
.mantine-Input-input[data-variant="default"] {
|
||||
border-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
|
||||
/* better contrast for disabled inputs */
|
||||
.mantine-Input-input:disabled,
|
||||
.mantine-Input-input[data-disabled] {
|
||||
opacity: 0.7;
|
||||
color: var(--mantine-color-dimmed);
|
||||
-webkit-text-fill-color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Badge,
|
||||
createTheme,
|
||||
CSSVariablesResolver,
|
||||
MantineColorsTuple,
|
||||
Tabs,
|
||||
v8CssVariablesResolver,
|
||||
} from "@mantine/core";
|
||||
|
||||
const blue: MantineColorsTuple = [
|
||||
@@ -37,6 +39,15 @@ export const theme = createTheme({
|
||||
red,
|
||||
},
|
||||
components: {
|
||||
// Size badges to their content; fit-content collapses inside table cells.
|
||||
Badge: Badge.extend({
|
||||
styles: (_theme, props) => ({
|
||||
root:
|
||||
props.fullWidth || props.circle
|
||||
? {}
|
||||
: { width: "max-content", maxWidth: "100%" },
|
||||
}),
|
||||
}),
|
||||
Tabs: Tabs.extend({
|
||||
vars: (theme, props) => ({
|
||||
root: {
|
||||
@@ -68,9 +79,11 @@ export const theme = createTheme({
|
||||
|
||||
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
||||
variables: {
|
||||
...v8CssVariablesResolver(theme).variables,
|
||||
"--input-error-size": theme.fontSizes.sm,
|
||||
},
|
||||
light: {
|
||||
...v8CssVariablesResolver(theme).light,
|
||||
"--mantine-color-dimmed": "#4b5563",
|
||||
"--mantine-color-dark-light-color": "#4e5359",
|
||||
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
|
||||
@@ -106,6 +119,7 @@ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
||||
"--mantine-color-orange-light-color": "#a63508",
|
||||
},
|
||||
dark: {
|
||||
...v8CssVariablesResolver(theme).dark,
|
||||
"--mantine-color-dark-light-color": "var(--mantine-color-gray-4)",
|
||||
"--mantine-color-dark-light-hover": "var(--mantine-color-default-hover)",
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
+71
-70
@@ -30,14 +30,14 @@
|
||||
"test:e2e": "jest --config test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.52",
|
||||
"@ai-sdk/openai": "^3.0.47",
|
||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
||||
"@ai-sdk/google": "3.0.52",
|
||||
"@ai-sdk/openai": "3.0.47",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@aws-sdk/client-s3": "3.1050.0",
|
||||
"@aws-sdk/lib-storage": "3.1050.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||
"@azure/storage-blob": "12.31.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@clickhouse/client": "1.18.2",
|
||||
"@docmost/base-formula": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@@ -48,82 +48,82 @@
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@nest-lab/throttler-storage-redis": "^1.2.0",
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs-labs/nestjs-ioredis": "11.0.4",
|
||||
"@nestjs/bullmq": "11.0.4",
|
||||
"@nestjs/cache-manager": "3.1.3",
|
||||
"@nestjs/common": "11.1.27",
|
||||
"@nestjs/config": "4.0.4",
|
||||
"@nestjs/core": "11.1.27",
|
||||
"@nestjs/event-emitter": "3.1.0",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^6.0.134",
|
||||
"ai-sdk-ollama": "^3.8.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bowser": "^2.14.1",
|
||||
"bullmq": "^5.76.10",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie": "^1.1.1",
|
||||
"csv-stringify": "^6",
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"@nestjs/passport": "11.0.5",
|
||||
"@nestjs/platform-fastify": "11.1.27",
|
||||
"@nestjs/platform-socket.io": "11.1.27",
|
||||
"@nestjs/schedule": "6.1.3",
|
||||
"@nestjs/terminus": "11.1.1",
|
||||
"@nestjs/throttler": "6.5.0",
|
||||
"@nestjs/websockets": "11.1.27",
|
||||
"@node-saml/passport-saml": "5.1.0",
|
||||
"@socket.io/redis-adapter": "8.3.0",
|
||||
"ai": "6.0.134",
|
||||
"ai-sdk-ollama": "3.8.1",
|
||||
"bcrypt": "6.0.0",
|
||||
"bowser": "2.14.1",
|
||||
"bullmq": "5.79.0",
|
||||
"cache-manager": "7.2.8",
|
||||
"cheerio": "1.2.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
"cookie": "1.1.1",
|
||||
"csv-stringify": "6.8.0",
|
||||
"fast-bm25": "0.0.5",
|
||||
"fastify-ip": "^2.0.0",
|
||||
"fs-extra": "^11.3.4",
|
||||
"fastify-ip": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
"happy-dom": "20.8.9",
|
||||
"ioredis": "^5.10.1",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"kysely": "^0.28.17",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"ldapts": "^8.1.7",
|
||||
"lib0": "^0.2.117",
|
||||
"mammoth": "^1.12.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"ioredis": "5.10.1",
|
||||
"js-tiktoken": "1.0.21",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"kysely": "0.28.17",
|
||||
"kysely-migration-cli": "0.4.2",
|
||||
"kysely-postgres-js": "3.0.0",
|
||||
"ldapts": "8.1.7",
|
||||
"lib0": "0.2.117",
|
||||
"mammoth": "1.12.0",
|
||||
"mime-types": "3.0.2",
|
||||
"msgpackr": "^1.11.9",
|
||||
"nanoid": "5.1.7",
|
||||
"nestjs-cls": "^6.2.0",
|
||||
"nestjs-kysely": "^3.1.2",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"nodemailer": "^8.0.5",
|
||||
"openid-client": "^6.8.2",
|
||||
"otpauth": "^9.5.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"nestjs-cls": "6.2.0",
|
||||
"nestjs-kysely": "3.1.2",
|
||||
"nestjs-pino": "4.6.1",
|
||||
"nodemailer": "9.0.1",
|
||||
"openid-client": "6.8.2",
|
||||
"otpauth": "9.5.0",
|
||||
"p-limit": "7.3.0",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"pg-tsquery": "8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.8",
|
||||
"postmark": "^4.0.7",
|
||||
"react": "^18.3.1",
|
||||
"react-email": "6.0.8",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"pino-http": "11.0.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postgres": "3.4.8",
|
||||
"postmark": "4.0.7",
|
||||
"react": "19.2.7",
|
||||
"react-email": "6.6.3",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"scimmy": "1.3.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io": "4.8.3",
|
||||
"stripe": "^17.7.0",
|
||||
"tlds": "^1.261.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^3.0.5",
|
||||
"undici": "7.24.0",
|
||||
"ws": "^8.20.1",
|
||||
"yauzl": "^3.2.1",
|
||||
"zod": "^4.3.6"
|
||||
"tlds": "1.261.0",
|
||||
"tmp-promise": "3.0.3",
|
||||
"tseep": "1.3.1",
|
||||
"typesense": "3.0.5",
|
||||
"undici": "7.28.0",
|
||||
"ws": "8.21.0",
|
||||
"yauzl": "3.2.1",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
@@ -195,6 +195,7 @@
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@docmost/base-formula/server$": "<rootDir>/../../../packages/base-formula/src/index.server.ts",
|
||||
"^@docmost/base-formula/client$": "<rootDir>/../../../packages/base-formula/src/index.client.ts"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export const Feature = {
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
IsAlphanumeric,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
@@ -20,6 +20,9 @@ export class CreateSpaceDto {
|
||||
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@IsAlphanumeric()
|
||||
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
||||
message:
|
||||
'Space slug must start with a letter or number and may contain hyphens and underscores',
|
||||
})
|
||||
slug: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export class SpaceService {
|
||||
workspaceId: string,
|
||||
createSpaceDto: CreateSpaceDto,
|
||||
trx?: KyselyTransaction,
|
||||
options?: { isPersonal?: boolean },
|
||||
): Promise<Space> {
|
||||
let space = null;
|
||||
|
||||
@@ -59,6 +60,7 @@ export class SpaceService {
|
||||
workspaceId,
|
||||
createSpaceDto,
|
||||
trx,
|
||||
options,
|
||||
);
|
||||
|
||||
await this.spaceMemberService.addUserToSpace(
|
||||
@@ -81,6 +83,7 @@ export class SpaceService {
|
||||
after: {
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
...(space.isPersonal ? { isPersonal: true } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -93,6 +96,7 @@ export class SpaceService {
|
||||
workspaceId: string,
|
||||
createSpaceDto: CreateSpaceDto,
|
||||
trx?: KyselyTransaction,
|
||||
options?: { isPersonal?: boolean },
|
||||
): Promise<Space> {
|
||||
const slugExists = await this.spaceRepo.slugExists(
|
||||
createSpaceDto.slug,
|
||||
@@ -112,6 +116,7 @@ export class SpaceService {
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
slug: createSpaceDto.slug,
|
||||
isPersonal: options?.isPersonal ?? false,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowMemberTemplates: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowPersonalSpaces: boolean;
|
||||
}
|
||||
|
||||
@@ -333,7 +333,8 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -361,6 +362,18 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||
if (
|
||||
!this.licenseCheckService.hasFeature(
|
||||
ws.licenseKey,
|
||||
Feature.PERSONAL_SPACES,
|
||||
ws.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
@@ -500,6 +513,20 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
|
||||
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
|
||||
before.allowPersonalSpaces = prev;
|
||||
after.allowPersonalSpaces = updateWorkspaceDto.allowPersonalSpaces;
|
||||
}
|
||||
await this.workspaceRepo.updateSpaceSettings(
|
||||
workspaceId,
|
||||
'allowPersonal',
|
||||
updateWorkspaceDto.allowPersonalSpaces,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
@@ -507,6 +534,7 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
delete updateWorkspaceDto.allowMemberTemplates;
|
||||
delete updateWorkspaceDto.aiChat;
|
||||
delete updateWorkspaceDto.allowPersonalSpaces;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
updateWorkspaceDto,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('spaces')
|
||||
.addColumn('is_personal', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE UNIQUE INDEX spaces_personal_creator_unique
|
||||
ON spaces (creator_id)
|
||||
WHERE is_personal = true AND deleted_at IS NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.dropIndex('spaces_personal_creator_unique')
|
||||
.ifExists()
|
||||
.execute();
|
||||
await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
|
||||
}
|
||||
@@ -57,6 +57,22 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findPersonalSpace(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Space | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('creatorId', '=', userId)
|
||||
.where('isPersonal', '=', true)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async slugExists(
|
||||
slug: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateSpaceSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('spaces', COALESCE(settings->'spaces', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
@@ -368,6 +368,7 @@ export interface Spaces {
|
||||
deletedAt: Timestamp | null;
|
||||
description: string | null;
|
||||
id: Generated<string>;
|
||||
isPersonal: Generated<boolean>;
|
||||
logo: string | null;
|
||||
name: string | null;
|
||||
settings: Json | null;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 607ed80921...167198a70e
+57
-56
@@ -19,70 +19,71 @@
|
||||
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@casl/ability": "6.8.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@floating-ui/dom": "1.7.3",
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@hocuspocus/server": "3.4.4",
|
||||
"@hocuspocus/transformer": "3.4.4",
|
||||
"@joplin/turndown": "^4.0.82",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.64",
|
||||
"@joplin/turndown": "4.0.82",
|
||||
"@joplin/turndown-plugin-gfm": "1.0.64",
|
||||
"@sindresorhus/slugify": "3.0.0",
|
||||
"@tiptap/core": "3.20.4",
|
||||
"@tiptap/extension-audio": "3.20.4",
|
||||
"@tiptap/extension-code-block": "3.20.4",
|
||||
"@tiptap/extension-collaboration": "3.20.4",
|
||||
"@tiptap/extension-collaboration-caret": "3.20.4",
|
||||
"@tiptap/extension-color": "3.20.4",
|
||||
"@tiptap/extension-document": "3.20.4",
|
||||
"@tiptap/extension-heading": "3.20.4",
|
||||
"@tiptap/extension-highlight": "3.20.4",
|
||||
"@tiptap/extension-history": "3.20.4",
|
||||
"@tiptap/extension-image": "3.20.4",
|
||||
"@tiptap/extension-link": "3.20.4",
|
||||
"@tiptap/extension-list": "3.20.4",
|
||||
"@tiptap/extension-placeholder": "3.20.4",
|
||||
"@tiptap/extension-subscript": "3.20.4",
|
||||
"@tiptap/extension-superscript": "3.20.4",
|
||||
"@tiptap/extension-table": "3.20.4",
|
||||
"@tiptap/extension-text": "3.20.4",
|
||||
"@tiptap/extension-text-align": "3.20.4",
|
||||
"@tiptap/extension-text-style": "3.20.4",
|
||||
"@tiptap/extension-typography": "3.20.4",
|
||||
"@tiptap/extension-unique-id": "3.20.4",
|
||||
"@tiptap/extension-youtube": "3.20.4",
|
||||
"@tiptap/html": "3.20.4",
|
||||
"@tiptap/pm": "3.20.4",
|
||||
"@tiptap/react": "3.20.4",
|
||||
"@tiptap/starter-kit": "3.20.4",
|
||||
"@tiptap/suggestion": "3.20.4",
|
||||
"@tiptap/y-tiptap": "3.0.2",
|
||||
"bytes": "^3.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"@tiptap/core": "3.27.1",
|
||||
"@tiptap/extension-audio": "3.27.1",
|
||||
"@tiptap/extension-code-block": "3.27.1",
|
||||
"@tiptap/extension-collaboration": "3.27.1",
|
||||
"@tiptap/extension-collaboration-caret": "3.27.1",
|
||||
"@tiptap/extension-color": "3.27.1",
|
||||
"@tiptap/extension-document": "3.27.1",
|
||||
"@tiptap/extension-heading": "3.27.1",
|
||||
"@tiptap/extension-highlight": "3.27.1",
|
||||
"@tiptap/extension-history": "3.27.1",
|
||||
"@tiptap/extension-image": "3.27.1",
|
||||
"@tiptap/extension-link": "3.27.1",
|
||||
"@tiptap/extension-list": "3.27.1",
|
||||
"@tiptap/extension-placeholder": "3.27.1",
|
||||
"@tiptap/extension-subscript": "3.27.1",
|
||||
"@tiptap/extension-superscript": "3.27.1",
|
||||
"@tiptap/extension-table": "3.27.1",
|
||||
"@tiptap/extension-text": "3.27.1",
|
||||
"@tiptap/extension-text-align": "3.27.1",
|
||||
"@tiptap/extension-text-style": "3.27.1",
|
||||
"@tiptap/extension-typography": "3.27.1",
|
||||
"@tiptap/extension-unique-id": "3.27.1",
|
||||
"@tiptap/extension-youtube": "3.27.1",
|
||||
"@tiptap/html": "3.27.1",
|
||||
"@tiptap/pm": "3.27.1",
|
||||
"@tiptap/react": "3.27.1",
|
||||
"@tiptap/starter-kit": "3.27.1",
|
||||
"@tiptap/suggestion": "3.27.1",
|
||||
"@tiptap/y-tiptap": "3.0.5",
|
||||
"bytes": "3.1.2",
|
||||
"cross-env": "10.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "3.4.1",
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"image-dimensions": "^2.5.0",
|
||||
"jszip": "^3.10.1",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"docx": "9.7.1",
|
||||
"dompurify": "3.4.11",
|
||||
"fractional-indexing-jittered": "1.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"image-dimensions": "2.5.0",
|
||||
"jszip": "3.10.1",
|
||||
"linkifyjs": "4.3.2",
|
||||
"marked": "17.0.5",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode": "1.5.4",
|
||||
"rfc6902": "5.2.0",
|
||||
"uuid": "^14.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"uuid": "14.0.0",
|
||||
"y-indexeddb": "9.0.12",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"yjs": "^13.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "22.6.1",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"concurrently": "^9.2.1",
|
||||
"@types/bytes": "3.1.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/turndown": "5.0.6",
|
||||
"concurrently": "9.2.3",
|
||||
"nx": "22.6.1",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
@@ -101,10 +102,10 @@
|
||||
"prosemirror-changeset": "2.4.0",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"glob": "13.0.6",
|
||||
"ws": "8.20.1",
|
||||
"dompurify": "3.4.1",
|
||||
"tmp": "0.2.6",
|
||||
"hono": "4.12.18",
|
||||
"ws": "8.21.0",
|
||||
"dompurify": "3.4.11",
|
||||
"tmp": "0.2.7",
|
||||
"hono": "4.12.25",
|
||||
"mermaid": "11.15.0",
|
||||
"nanoid@^3": "3.3.8",
|
||||
"socket.io-parser": "4.2.6",
|
||||
@@ -112,7 +113,7 @@
|
||||
"lodash-es": "4.18.1",
|
||||
"lodash": "4.18.1",
|
||||
"@hono/node-server": "1.19.13",
|
||||
"undici": "7.24.0",
|
||||
"undici": "7.28.0",
|
||||
"ajv@^6": "6.14.0",
|
||||
"ajv@^8": "8.18.0",
|
||||
"underscore": "1.13.8",
|
||||
@@ -133,8 +134,8 @@
|
||||
"axios": "1.16.0",
|
||||
"langsmith": "0.7.0",
|
||||
"follow-redirects": "1.16.0",
|
||||
"protobufjs": "7.5.8",
|
||||
"ip-address": "10.1.1"
|
||||
"protobufjs": "7.5.8",
|
||||
"ip-address": "10.1.1"
|
||||
},
|
||||
"neverBuiltDependencies": []
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@docmost/editor-ext",
|
||||
"homepage": "https://docmost.com",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch"
|
||||
|
||||
@@ -33,4 +33,8 @@ export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export {
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from "./lib/prosemirror-docx";
|
||||
export * from "./lib/base-embed";
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# `prosemirror-docx`
|
||||
|
||||
[](https://www.npmjs.com/package/prosemirror-docx)
|
||||
[](https://github.com/curvenote/prosemirror-docx)
|
||||
[
|
||||
[](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
|
||||

|
||||
|
||||
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
|
||||
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but can’t read from `*.docx`), and has most of the basic nodes covered (see below).
|
||||
|
||||
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs'; // Or some other way to write a file
|
||||
|
||||
// Set up your prosemirror state/document as you normally do
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
getImageBuffer(src: string) {
|
||||
return anImageBuffer;
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced usage
|
||||
|
||||
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
|
||||
|
||||
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
|
||||
|
||||
```ts
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
|
||||
return {
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
fonts: [], // embed fonts,
|
||||
styles: {
|
||||
paragraphStyles,
|
||||
default: {
|
||||
heading1: paragraphStyles[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
|
||||
|
||||
## Extended usage
|
||||
|
||||
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
|
||||
|
||||
```ts
|
||||
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
|
||||
|
||||
const nodeSerializer = {
|
||||
...defaultNodes,
|
||||
my_paragraph(state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
};
|
||||
|
||||
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
|
||||
```
|
||||
|
||||
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
|
||||
|
||||
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
|
||||
|
||||
```ts
|
||||
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
export const docxSerializer = new DocxSerializerAsync(
|
||||
{
|
||||
...defaultAsyncNodes,
|
||||
async image(state, node) {
|
||||
const { src } = node.attrs;
|
||||
await state.image(src, 70, 'center', undefined, 'png');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
defaultMarks,
|
||||
);
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
async getImageBuffer(src: string) {
|
||||
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
|
||||
return new Uint8Array(arrayBuffer);
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
## Supported Nodes
|
||||
|
||||
- text
|
||||
- paragraph
|
||||
- heading (levels)
|
||||
- TODO: Support numbering of headings
|
||||
- blockquote
|
||||
- code_block
|
||||
- TODO: No styles supported
|
||||
- horizontal_rule
|
||||
- hard_break
|
||||
- ordered_list
|
||||
- unordered_list
|
||||
- list_item
|
||||
- image
|
||||
- math
|
||||
- equations (numbered & unnumbered)
|
||||
- tables
|
||||
|
||||
Planned:
|
||||
|
||||
- Internal References (e.g. see Table 1)
|
||||
|
||||
## Supported Marks
|
||||
|
||||
- em
|
||||
- strong
|
||||
- link
|
||||
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
|
||||
- code
|
||||
- subscript
|
||||
- superscript
|
||||
- strikethrough
|
||||
- underline
|
||||
- smallcaps
|
||||
- allcaps
|
||||
|
||||
## Resources
|
||||
|
||||
- [Prosemirror Docs](https://prosemirror.net/docs/)
|
||||
- [docx](https://docx.js.org/)
|
||||
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
|
||||
@@ -0,0 +1,24 @@
|
||||
// MIT - https://github.com/curvenote/prosemirror-docx/
|
||||
export type { SectionConfig, SerializationState } from './types';
|
||||
export type {
|
||||
MarkSerializer,
|
||||
NodeSerializer,
|
||||
NodeSerializerAsync,
|
||||
Options,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
|
||||
export {
|
||||
DocxSerializerStateAsync,
|
||||
DocxSerializerAsync,
|
||||
DocxSerializerState,
|
||||
DocxSerializer,
|
||||
MAX_IMAGE_WIDTH,
|
||||
} from './serializer';
|
||||
export {
|
||||
defaultAsyncNodes,
|
||||
defaultMarks,
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from './schema';
|
||||
export { writeDocx, createDocFromState, buildDoc } from './utils';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
|
||||
import { INumbering } from './types';
|
||||
|
||||
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
|
||||
return {
|
||||
alignment: AlignmentType.START,
|
||||
style: {
|
||||
paragraph: {
|
||||
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const numbered = Array(3)
|
||||
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
|
||||
.flat()
|
||||
.map((format, level) => ({
|
||||
level,
|
||||
format,
|
||||
text: `%${level + 1}.`,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const bullets = Array(3)
|
||||
.fill(['●', '○', '■'])
|
||||
.flat()
|
||||
.map((text, level) => ({
|
||||
level,
|
||||
format: LevelFormat.BULLET,
|
||||
text,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const styles = {
|
||||
numbered,
|
||||
bullets,
|
||||
};
|
||||
|
||||
export type NumberingStyles = keyof typeof styles;
|
||||
|
||||
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
|
||||
return {
|
||||
reference,
|
||||
levels: styles[style],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { HeadingLevel, ShadingType } from 'docx';
|
||||
import { Node } from 'prosemirror-model';
|
||||
import {
|
||||
DocxSerializerAsync,
|
||||
MarkSerializer,
|
||||
NodeSerializerAsync,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
import { writeDocx } from './utils';
|
||||
|
||||
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
|
||||
|
||||
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
|
||||
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
|
||||
// (named colors, hsl, etc.) so the caller omits the color rather than letting
|
||||
// docx throw "Invalid hex value".
|
||||
function toDocxColor(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const value = input.trim().toLowerCase();
|
||||
const hex = value.startsWith('#') ? value.slice(1) : value;
|
||||
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
|
||||
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||
return hex
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('');
|
||||
}
|
||||
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (rgb) {
|
||||
const channel = (n: string) =>
|
||||
Math.max(0, Math.min(255, parseInt(n, 10)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Images and diagrams embed via the image resolver; the URL (with its file
|
||||
// extension) is passed through so docx can infer the image type.
|
||||
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
|
||||
const src = node.attrs?.src || node.attrs?.attachmentId;
|
||||
if (src) {
|
||||
try {
|
||||
await state.image(src, 100);
|
||||
} catch {
|
||||
// Unrenderable/missing image: skip rather than fail the whole export.
|
||||
}
|
||||
}
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
// Non-embeddable media render as a labelled line.
|
||||
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label =
|
||||
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label = node.attrs?.src || node.attrs?.url || 'embed';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
text(state, node) {
|
||||
state.text(node.text ?? '');
|
||||
},
|
||||
async paragraph(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
async heading(state, node) {
|
||||
await state.renderInline(node);
|
||||
const heading = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
][(node.attrs.level ?? 1) - 1];
|
||||
state.closeBlock(node, { heading });
|
||||
},
|
||||
async blockquote(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async codeBlock(state, node) {
|
||||
await state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
horizontalRule(state, node) {
|
||||
state.closeBlock(node, { thematicBreak: true });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
hardBreak(state) {
|
||||
state.addRunOptions({ break: 1 });
|
||||
},
|
||||
async bulletList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async orderedList(state, node) {
|
||||
await state.renderList(node, 'numbered');
|
||||
},
|
||||
async listItem(state, node) {
|
||||
await state.renderListItem(node);
|
||||
},
|
||||
async taskList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async taskItem(state, node) {
|
||||
if (state.currentNumbering) {
|
||||
state.addParagraphOptions({ numbering: state.currentNumbering });
|
||||
}
|
||||
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async table(state, node) {
|
||||
await state.table(node);
|
||||
},
|
||||
// Docmost stores LaTeX in attrs.text.
|
||||
mathInline(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: true });
|
||||
},
|
||||
mathBlock(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
image: renderImage,
|
||||
drawio: renderImage,
|
||||
excalidraw: renderImage,
|
||||
video: renderFileLine,
|
||||
audio: renderFileLine,
|
||||
pdf: renderFileLine,
|
||||
attachment: renderFileLine,
|
||||
embed: renderEmbedLine,
|
||||
youtube: renderEmbedLine,
|
||||
async callout(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async details(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async detailsSummary(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
|
||||
},
|
||||
async detailsContent(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async columns(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async column(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async transclusionSource(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
mention(state, node) {
|
||||
state.text(`@${node.attrs?.label ?? ''}`);
|
||||
},
|
||||
status(state, node) {
|
||||
state.text(`[${node.attrs?.text ?? ''}]`);
|
||||
},
|
||||
pageBreak(state, node) {
|
||||
state.closeBlock(node, { pageBreakBefore: true });
|
||||
},
|
||||
// No usable static export representation: skip without failing.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
bold() {
|
||||
return { bold: true };
|
||||
},
|
||||
italic() {
|
||||
return { italics: true };
|
||||
},
|
||||
strike() {
|
||||
return { strike: true };
|
||||
},
|
||||
underline() {
|
||||
return { underline: {} };
|
||||
},
|
||||
code() {
|
||||
return {
|
||||
font: { name: 'Monospace' },
|
||||
color: '000000',
|
||||
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
|
||||
};
|
||||
},
|
||||
superscript() {
|
||||
return { superScript: true };
|
||||
},
|
||||
subscript() {
|
||||
return { subScript: true };
|
||||
},
|
||||
link() {
|
||||
// Handled specifically in the serializer; Word treats links as nodes.
|
||||
return {};
|
||||
},
|
||||
highlight(_state, _node, mark) {
|
||||
const fill = toDocxColor(mark.attrs?.color);
|
||||
return fill
|
||||
? { shading: { type: ShadingType.CLEAR, fill } }
|
||||
: { highlight: 'yellow' };
|
||||
},
|
||||
// @tiptap/extension-color stores the color on the textStyle mark.
|
||||
textStyle(_state, _node, mark) {
|
||||
const color = toDocxColor(mark.attrs?.color);
|
||||
return color ? { color } : {};
|
||||
},
|
||||
// Comments are editor-only; drop the annotation in the export.
|
||||
comment() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
export async function pageNodeToDocxBuffer(
|
||||
doc: Node,
|
||||
getImageBuffer: DocxImageResolver,
|
||||
): Promise<Buffer> {
|
||||
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
|
||||
const wordDoc = await serializer.serializeAsync(
|
||||
doc,
|
||||
{ getImageBuffer },
|
||||
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
|
||||
// has no heading color, so override the default heading run colors to the
|
||||
// normal text color. Sizes/italics mirror docx's own defaults so only the
|
||||
// color changes.
|
||||
() =>
|
||||
({
|
||||
styles: {
|
||||
default: {
|
||||
heading1: { run: { color: '000000', size: 32 } },
|
||||
heading2: { run: { color: '000000', size: 26 } },
|
||||
heading3: { run: { color: '000000', size: 24 } },
|
||||
heading4: { run: { color: '000000', italics: true } },
|
||||
heading5: { run: { color: '000000' } },
|
||||
heading6: { run: { color: '000000' } },
|
||||
},
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
return writeDocx(wordDoc);
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
import { Node, Mark } from 'prosemirror-model';
|
||||
import {
|
||||
IParagraphOptions,
|
||||
IRunOptions,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
ExternalHyperlink,
|
||||
ParagraphChild,
|
||||
MathRun,
|
||||
Math,
|
||||
TabStopType,
|
||||
TabStopPosition,
|
||||
SequentialIdentifier,
|
||||
Bookmark,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
ITableCellOptions,
|
||||
InternalHyperlink,
|
||||
SimpleField,
|
||||
FootnoteReferenceRun,
|
||||
IImageOptions,
|
||||
Document,
|
||||
ITableOptions,
|
||||
ITableRowOptions,
|
||||
IPropertiesOptions,
|
||||
} from 'docx';
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { createNumbering, NumberingStyles } from './numbering';
|
||||
import { buildDoc, createShortId } from './utils';
|
||||
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
|
||||
|
||||
// This is duplicated from @curvenote/schema
|
||||
export type AlignOptions = 'left' | 'center' | 'right';
|
||||
|
||||
export type NodeSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
|
||||
>;
|
||||
|
||||
export type NodeSerializerAsync = Record<
|
||||
string,
|
||||
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
|
||||
>;
|
||||
|
||||
export type MarkSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
|
||||
>;
|
||||
|
||||
export type Options = {
|
||||
getImageBuffer: (src: string) => Uint8Array;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type OptionsAsync = {
|
||||
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type IMathOpts = {
|
||||
inline?: boolean;
|
||||
id?: string | null;
|
||||
numbered?: boolean;
|
||||
};
|
||||
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
|
||||
|
||||
export const MAX_IMAGE_WIDTH = 600;
|
||||
|
||||
function createReferenceBookmark(
|
||||
id: string,
|
||||
kind: 'Equation' | 'Figure' | 'Table',
|
||||
before?: string,
|
||||
after?: string,
|
||||
) {
|
||||
const textBefore = before ? [new TextRun(before)] : [];
|
||||
const textAfter = after ? [new TextRun(after)] : [];
|
||||
return new Bookmark({
|
||||
id,
|
||||
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
|
||||
});
|
||||
}
|
||||
|
||||
export class DocxSerializerState {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
options: Options;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as Options);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
parent.forEach((node, _, i) => {
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
this.render(node, parent, i);
|
||||
});
|
||||
}
|
||||
|
||||
render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
this.nodes[node.type.name](this, node, parent, index);
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
parent.forEach(progress);
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = this.options.getImageBuffer(src);
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
node.content.forEach((row) => {
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
row.content.forEach((cell) => {
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
});
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
row.content.forEach((cell) => {
|
||||
this.children = [];
|
||||
this.renderContent(cell);
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
});
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this seperates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializer {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
serialize(
|
||||
content: Node,
|
||||
options: Options,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
): Document {
|
||||
const state = new DocxSerializerState(this.nodes, this.marks, options);
|
||||
state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerStateAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
options: OptionsAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as OptionsAsync);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
async renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
const node = parent.child(i);
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.render(node, parent, i);
|
||||
}
|
||||
}
|
||||
|
||||
async render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
async renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = async (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
await this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
// Process nodes sequentially to maintain order
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await progress(parent.child(i), 0, i);
|
||||
}
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
async renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
await this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
async renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
await this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
async image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
async table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
|
||||
const row = node.content.child(rowIndex);
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
|
||||
// Check if all cells in the row are headers
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
}
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
|
||||
// Iterate through cells and ensure order
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
this.children = [];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.renderContent(cell); // Ensure order
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
}
|
||||
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this separates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
async footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
await this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
async serializeAsync(
|
||||
content: Node,
|
||||
options: OptionsAsync,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
) {
|
||||
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
|
||||
await state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [k in keyof T]: T[k];
|
||||
};
|
||||
|
||||
export type IFootnotes = Mutable<
|
||||
Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly children: readonly Paragraph[];
|
||||
}
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type INumbering = INumberingOptions['config'][0];
|
||||
|
||||
export interface SectionConfig {
|
||||
properties?: ISectionOptions['properties'];
|
||||
headers?: ISectionOptions['headers'];
|
||||
footers?: ISectionOptions['footers'];
|
||||
}
|
||||
|
||||
export interface SerializationState {
|
||||
numbering: INumberingOptions['config'];
|
||||
sections?: Array<{
|
||||
config: SectionConfig;
|
||||
children: ISectionOptions['children'];
|
||||
}>;
|
||||
children?: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Document,
|
||||
INumberingOptions,
|
||||
IPropertiesOptions,
|
||||
ISectionOptions,
|
||||
Packer,
|
||||
SectionType,
|
||||
} from 'docx';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
import { IFootnotes, SerializationState } from './types';
|
||||
|
||||
export function createShortId() {
|
||||
return Math.random().toString(36).slice(2, 11);
|
||||
}
|
||||
|
||||
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
|
||||
let sections = state?.sections?.length
|
||||
? state.sections.map((section) => ({
|
||||
properties: section.config.properties || {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
headers: section.config.headers,
|
||||
footers: section.config.footers,
|
||||
children: section.children,
|
||||
}))
|
||||
: undefined;
|
||||
if (!sections) {
|
||||
sections = [
|
||||
{
|
||||
headers: undefined,
|
||||
footers: undefined,
|
||||
properties: {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
children: state?.children || [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
footnotes: state.footnotes,
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
sections,
|
||||
...(opts || {}),
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use `buildDoc` instead
|
||||
* Creates a docx document from the given state.
|
||||
* */
|
||||
export function createDocFromState(state: {
|
||||
numbering: INumberingOptions['config'];
|
||||
children: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}) {
|
||||
return buildDoc({
|
||||
numbering: state.numbering,
|
||||
sections: [
|
||||
{
|
||||
config: {},
|
||||
children: state.children,
|
||||
},
|
||||
],
|
||||
footnotes: state.footnotes,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeDocx(
|
||||
doc: Document,
|
||||
/**
|
||||
* @deprecated use `.then()` or `await` instead
|
||||
*/
|
||||
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
|
||||
) {
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
await write?.(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function getLatexFromNode(node: ProsemirrorNode): string {
|
||||
let math = '';
|
||||
node.forEach((child) => {
|
||||
if (child.isText) math += child.text;
|
||||
// TODO: improve this as we may have other things in the future
|
||||
});
|
||||
return math;
|
||||
}
|
||||
Generated
+1975
-2173
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user