This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
/**
* PDF Export Color Constants
*
* These colors are mapped from the editor CSS variables and tailwind-config tokens
* to ensure PDF exports match the editor's appearance.
*
* Source mappings:
* - Editor colors: packages/editor/src/styles/variables.css
* - Tailwind tokens: packages/tailwind-config/variables.css
*/
// Editor text colors (from variables.css :root)
export const EDITOR_TEXT_COLORS = {
gray: "#5c5e63",
peach: "#ff5b59",
pink: "#f65385",
orange: "#fd9038",
green: "#0fc27b",
"light-blue": "#17bee9",
"dark-blue": "#266df0",
purple: "#9162f9",
} as const;
// Editor background colors - Light theme (from variables.css [data-theme*="light"])
export const EDITOR_BACKGROUND_COLORS_LIGHT = {
gray: "#d6d6d8",
peach: "#ffd5d7",
pink: "#fdd4e3",
orange: "#ffe3cd",
green: "#c3f0de",
"light-blue": "#c5eff9",
"dark-blue": "#c9dafb",
purple: "#e3d8fd",
} as const;
// Editor background colors - Dark theme (from variables.css [data-theme*="dark"])
export const EDITOR_BACKGROUND_COLORS_DARK = {
gray: "#404144",
peach: "#593032",
pink: "#562e3d",
orange: "#583e2a",
green: "#1d4a3b",
"light-blue": "#1f495c",
"dark-blue": "#223558",
purple: "#3d325a",
} as const;
// Use light theme colors by default for PDF exports
export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT;
// Color key type
export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS;
/**
* Maps a color key to its text color hex value
*/
export const getTextColorHex = (colorKey: string): string | null => {
if (colorKey in EDITOR_TEXT_COLORS) {
return EDITOR_TEXT_COLORS[colorKey as EditorColorKey];
}
return null;
};
/**
* Maps a color key to its background color hex value
*/
export const getBackgroundColorHex = (colorKey: string): string | null => {
if (colorKey in EDITOR_BACKGROUND_COLORS) {
return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey];
}
return null;
};
/**
* Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)")
*/
export const isCssVariable = (value: string): boolean => {
return value.startsWith("var(");
};
/**
* Extracts the color key from a CSS variable reference
* e.g., "var(--editor-colors-gray-text)" -> "gray"
* e.g., "var(--editor-colors-light-blue-background)" -> "light-blue"
*/
export const extractColorKeyFromCssVariable = (cssVar: string): string | null => {
// Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background)
const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/);
if (match) {
return match[1];
}
return null;
};
/**
* Resolves a color value to a hex color for PDF rendering
* Handles both direct hex values and CSS variable references
*/
export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => {
if (!value) return null;
// If it's already a hex color, return it
if (value.startsWith("#")) {
return value;
}
// If it's a CSS variable, extract the key and get the hex value
if (isCssVariable(value)) {
const colorKey = extractColorKeyFromCssVariable(value);
if (colorKey) {
return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey);
}
}
// If it's just a color key (e.g., "gray", "peach"), get the hex value
if (type === "text") {
return getTextColorHex(value);
}
return getBackgroundColorHex(value);
};
// Semantic colors from tailwind-config (light theme)
// These are derived from the CSS variables in packages/tailwind-config/variables.css
// Neutral colors (light theme)
export const NEUTRAL_COLORS = {
white: "#ffffff",
100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa
200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5
300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0
400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb
500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5
600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9
700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc
800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c
900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a
1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363
1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d
1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f
black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f
} as const;
// Brand colors (light theme accent)
export const BRAND_COLORS = {
default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue
100: "#f5f8ff",
200: "#e8f0ff",
300: "#d1e1ff",
400: "#b3d0ff",
500: "#8ab8ff",
600: "#5c9aff",
700: "#3f76ff",
900: "#2952b3",
1000: "#1e3d80",
1100: "#142b5c",
1200: "#0d1f40",
} as const;
// Semantic text colors
export const TEXT_COLORS = {
primary: NEUTRAL_COLORS[1200], // --txt-primary
secondary: NEUTRAL_COLORS[1100], // --txt-secondary
tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary
placeholder: NEUTRAL_COLORS[900], // --txt-placeholder
disabled: NEUTRAL_COLORS[800], // --txt-disabled
accentPrimary: BRAND_COLORS.default, // --txt-accent-primary
linkPrimary: BRAND_COLORS.default, // --txt-link-primary
} as const;
// Semantic background colors
export const BACKGROUND_COLORS = {
canvas: NEUTRAL_COLORS[300], // --bg-canvas
surface1: NEUTRAL_COLORS.white, // --bg-surface-1
surface2: NEUTRAL_COLORS[100], // --bg-surface-2
layer1: NEUTRAL_COLORS[200], // --bg-layer-1
layer2: NEUTRAL_COLORS.white, // --bg-layer-2
layer3: NEUTRAL_COLORS[300], // --bg-layer-3
accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100)
} as const;
// Semantic border colors
export const BORDER_COLORS = {
subtle: NEUTRAL_COLORS[400], // --border-subtle
subtle1: NEUTRAL_COLORS[500], // --border-subtle-1
strong: NEUTRAL_COLORS[600], // --border-strong
strong1: NEUTRAL_COLORS[700], // --border-strong-1
accentStrong: BRAND_COLORS.default, // --border-accent-strong
} as const;
// Code/inline code colors
export const CODE_COLORS = {
background: NEUTRAL_COLORS[200], // Similar to bg-layer-1
text: "#dc2626", // Red for inline code text (matches editor)
blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks
} as const;
// Link colors
export const LINK_COLORS = {
primary: BRAND_COLORS.default,
hover: BRAND_COLORS[900],
} as const;
// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary)
export const MENTION_COLORS = {
background: "#e0e9ff", // accent-primary with ~20% opacity on white
text: BRAND_COLORS.default,
} as const;
// Success/Green colors
export const SUCCESS_COLORS = {
primary: "#10b981",
subtle: "#d1fae5",
} as const;
// Warning/Amber colors
export const WARNING_COLORS = {
primary: "#f59e0b",
subtle: "#fef3c7",
} as const;
// Danger/Red colors
export const DANGER_COLORS = {
primary: "#ef4444",
subtle: "#fee2e2",
} as const;
+232
View File
@@ -0,0 +1,232 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Circle, Path, Rect, Svg } from "@react-pdf/renderer";
type IconProps = {
size?: number;
color?: string;
};
// Lightbulb icon for callouts (default)
export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M9 21h6M12 3a6 6 0 0 0-6 6c0 2.22 1.21 4.16 3 5.19V17a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2.81c1.79-1.03 3-2.97 3-5.19a6 6 0 0 0-6-6z"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Document/file icon for page embeds
export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path d="M14 2v6h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Link icon for page links and external links
export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Paperclip icon for attachments (default)
export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Image icon for image attachments
export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={3} y={3} width={18} height={18} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
<Circle cx={8.5} cy={8.5} r={1.5} fill={color} />
<Path d="M21 15l-5-5L5 21" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Video icon for video attachments
export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={2} y={4} width={15} height={16} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M17 10l5-3v10l-5-3z" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Music/audio icon
export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path d="M9 18V5l12-2v13" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
<Circle cx={6} cy={18} r={3} fill="none" stroke={color} strokeWidth={2} />
<Circle cx={18} cy={16} r={3} fill="none" stroke={color} strokeWidth={2} />
</Svg>
);
// File-text icon for PDFs and documents
export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
<Path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Table/spreadsheet icon
export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M3 9h18M3 15h18M9 3v18M15 3v18" fill="none" stroke={color} strokeWidth={2} />
</Svg>
);
// Presentation icon
export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={2} y={3} width={20} height={14} rx={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M8 21l4-4 4 4M12 17v-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Archive/zip icon
export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M21 8v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
<Path
d="M23 3H1v5h22V3zM10 12h4"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Globe icon for external embeds (rich cards)
export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} fill="none" stroke={color} strokeWidth={2} />
<Path
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
fill="none"
stroke={color}
strokeWidth={2}
/>
</Svg>
);
// Clipboard icon for whiteboards
export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
<Rect x={8} y={2} width={8} height={4} rx={1} fill="none" stroke={color} strokeWidth={2} />
</Svg>
);
// Ruler/diagram icon for diagrams
export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M14 3v4a1 1 0 0 0 1 1h4"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"
fill="none"
stroke={color}
strokeWidth={2}
/>
<Path d="M9 9h1M9 13h6M9 17h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Work item / task icon
export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M9 12l2 2 4-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
// Checkmark icon for checked task items
export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path d="M20 6L9 17l-5-5" fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
// Helper to get file icon component based on file type
export const getFileIcon = (fileType: string, size = 16, color = "#374151") => {
if (fileType.startsWith("image/")) return <ImageIcon size={size} color={color} />;
if (fileType.startsWith("video/")) return <VideoIcon size={size} color={color} />;
if (fileType.startsWith("audio/")) return <MusicIcon size={size} color={color} />;
if (fileType.includes("pdf")) return <FileTextIcon size={size} color="#dc2626" />;
if (fileType.includes("spreadsheet") || fileType.includes("excel")) return <TableIcon size={size} color="#16a34a" />;
if (fileType.includes("document") || fileType.includes("word")) return <FileTextIcon size={size} color="#2563eb" />;
if (fileType.includes("presentation") || fileType.includes("powerpoint"))
return <PresentationIcon size={size} color="#ea580c" />;
if (fileType.includes("zip") || fileType.includes("archive")) return <ArchiveIcon size={size} color={color} />;
return <PaperclipIcon size={size} color={color} />;
};
+24
View File
@@ -0,0 +1,24 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter";
export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers";
export { markRenderers, applyMarks } from "./mark-renderers";
export { pdfStyles } from "./styles";
export type {
KeyGenerator,
MarkRendererRegistry,
NodeRendererRegistry,
PDFExportMetadata,
PDFExportOptions,
PDFMarkRenderer,
PDFNodeRenderer,
PDFRenderContext,
PDFUserMention,
TipTapDocument,
TipTapMark,
TipTapNode,
} from "./types";
@@ -0,0 +1,144 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Style } from "@react-pdf/types";
import {
BACKGROUND_COLORS,
CODE_COLORS,
EDITOR_BACKGROUND_COLORS,
EDITOR_TEXT_COLORS,
LINK_COLORS,
resolveColorForPdf,
} from "./colors";
import type { MarkRendererRegistry, TipTapMark } from "./types";
export const markRenderers: MarkRendererRegistry = {
bold: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontWeight: "bold",
}),
italic: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontStyle: "italic",
}),
underline: (_mark: TipTapMark, style: Style): Style => ({
...style,
textDecoration: "underline",
}),
strike: (_mark: TipTapMark, style: Style): Style => ({
...style,
textDecoration: "line-through",
}),
code: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontFamily: "Courier",
fontSize: 10,
backgroundColor: BACKGROUND_COLORS.layer1,
color: CODE_COLORS.text,
}),
link: (_mark: TipTapMark, style: Style): Style => ({
...style,
color: LINK_COLORS.primary,
textDecoration: "underline",
}),
textStyle: (mark: TipTapMark, style: Style): Style => {
const attrs = mark.attrs || {};
const newStyle: Style = { ...style };
if (attrs.color && typeof attrs.color === "string") {
newStyle.color = attrs.color;
}
if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") {
newStyle.backgroundColor = attrs.backgroundColor;
}
return newStyle;
},
highlight: (mark: TipTapMark, style: Style): Style => {
const attrs = mark.attrs || {};
return {
...style,
backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple,
};
},
subscript: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontSize: 8,
}),
superscript: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontSize: 8,
}),
/**
* Custom color mark handler
* Handles the customColor extension which stores colors as data-text-color and data-background-color attributes
* The colors can be either:
* 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST)
* 2. Direct hex values for custom colors
* 3. CSS variable references like "var(--editor-colors-gray-text)"
*/
customColor: (mark: TipTapMark, style: Style): Style => {
const attrs = mark.attrs || {};
const newStyle: Style = { ...style };
// Handle text color (stored in 'color' attribute)
const textColor = attrs.color as string | undefined;
if (textColor) {
const resolvedColor = resolveColorForPdf(textColor, "text");
if (resolvedColor) {
newStyle.color = resolvedColor;
} else if (textColor.startsWith("#") || textColor.startsWith("rgb")) {
// Direct color value
newStyle.color = textColor;
} else if (textColor in EDITOR_TEXT_COLORS) {
// Color key lookup
newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS];
}
}
// Handle background color (stored in 'backgroundColor' attribute)
const backgroundColor = attrs.backgroundColor as string | undefined;
if (backgroundColor) {
const resolvedColor = resolveColorForPdf(backgroundColor, "background");
if (resolvedColor) {
newStyle.backgroundColor = resolvedColor;
} else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) {
// Direct color value
newStyle.backgroundColor = backgroundColor;
} else if (backgroundColor in EDITOR_BACKGROUND_COLORS) {
// Color key lookup
newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS];
}
}
return newStyle;
},
};
export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => {
if (!marks || marks.length === 0) {
return baseStyle;
}
return marks.reduce((style, mark) => {
const renderer = markRenderers[mark.type];
if (renderer) {
return renderer(mark, style);
}
return style;
}, baseStyle);
};
@@ -0,0 +1,444 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Image, Link, Text, View } from "@react-pdf/renderer";
import type { Style } from "@react-pdf/types";
import type { ReactElement } from "react";
import { CORE_EXTENSIONS } from "@plane/editor";
import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors";
import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons";
import { applyMarks } from "./mark-renderers";
import { pdfStyles } from "./styles";
import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types";
const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => {
const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined;
const iconName = node.attrs?.["data-icon-name"] as string | undefined;
const iconColor = (node.attrs?.["data-icon-color"] as string) || color;
if (logoInUse === "emoji") {
const emojiUnicode = node.attrs?.["data-emoji-unicode"] as string | undefined;
if (emojiUnicode) {
return <Text style={{ fontSize: 14 }}>{emojiUnicode}</Text>;
}
}
if (iconName) {
switch (iconName) {
case "FileText":
case "File":
return <DocumentIcon size={16} color={iconColor} />;
case "Link":
return <LinkIcon size={16} color={iconColor} />;
case "Globe":
return <GlobeIcon size={16} color={iconColor} />;
case "Clipboard":
return <ClipboardIcon size={16} color={iconColor} />;
case "CheckSquare":
case "Check":
return <CheckIcon size={16} color={iconColor} />;
case "Lightbulb":
default:
return <LightbulbIcon size={16} color={iconColor} />;
}
}
return <LightbulbIcon size={16} color={color} />;
};
export const createKeyGenerator = (): KeyGenerator => {
let counter = 0;
return () => `node-${counter++}`;
};
const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => {
const style = applyMarks(node.marks, {});
const hasLink = node.marks?.find((m) => m.type === "link");
if (hasLink) {
const href = (hasLink.attrs?.href as string) || "#";
return (
<Link key={getKey()} src={href} style={{ ...pdfStyles.link, ...style }}>
{node.text || ""}
</Link>
);
}
return (
<Text key={getKey()} style={style}>
{node.text || ""}
</Text>
);
};
const getTextAlignStyle = (textAlign: string | null | undefined): Style => {
if (!textAlign) return {};
return {
textAlign: textAlign as "left" | "right" | "center" | "justify",
};
};
const getFlexAlignStyle = (textAlign: string | null | undefined): Style => {
if (!textAlign) return {};
if (textAlign === "right") return { alignItems: "flex-end" };
if (textAlign === "center") return { alignItems: "center" };
return {};
};
export const nodeRenderers: NodeRendererRegistry = {
doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()}>{children}</View>
),
text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement =>
renderTextWithMarks(node, ctx.getKey),
paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const textAlign = node.attrs?.textAlign as string | null;
const background = node.attrs?.backgroundColor as string | undefined;
const alignStyle = getTextAlignStyle(textAlign);
const flexStyle = getFlexAlignStyle(textAlign);
const resolvedBgColor =
background && background !== "default" ? resolveColorForPdf(background, "background") : null;
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.paragraphWrapper, flexStyle, bgStyle]}>
<Text style={[pdfStyles.paragraph, alignStyle, bgStyle]}>{children}</Text>
</View>
);
},
heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const level = (node.attrs?.level as number) || 1;
const styleKey = `heading${level}` as keyof typeof pdfStyles;
const style = pdfStyles[styleKey] || pdfStyles.heading1;
const textAlign = node.attrs?.textAlign as string | null;
const alignStyle = getTextAlignStyle(textAlign);
const flexStyle = getFlexAlignStyle(textAlign);
return (
<View key={ctx.getKey()} style={flexStyle}>
<Text style={[style, alignStyle]}>{children}</Text>
</View>
);
},
blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.blockquote} wrap={false}>
{children}
</View>
),
codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const codeContent = node.content?.map((c) => c.text || "").join("") || "";
return (
<View key={ctx.getKey()} style={pdfStyles.codeBlock} wrap={false}>
<Text>{codeContent}</Text>
</View>
);
},
bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.bulletList, indentStyle]}>
{children}
</View>
);
},
orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.orderedList, indentStyle]}>
{children}
</View>
);
},
listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const isOrdered = node.attrs?._parentType === "orderedList";
const index = (node.attrs?._listItemIndex as number) || 0;
const bullet = isOrdered ? `${index}.` : "•";
const textAlign = node.attrs?._textAlign as string | null;
const flexStyle = getFlexAlignStyle(textAlign);
return (
<View key={ctx.getKey()} style={[pdfStyles.listItem, flexStyle]} wrap={false}>
<View style={pdfStyles.listItemBullet}>
<Text>{bullet}</Text>
</View>
<View style={pdfStyles.listItemContent}>{children}</View>
</View>
);
},
taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.taskList}>
{children}
</View>
),
taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const checked = node.attrs?.checked === true;
return (
<View key={ctx.getKey()} style={pdfStyles.taskItem} wrap={false}>
<View style={checked ? [pdfStyles.taskCheckbox, pdfStyles.taskCheckboxChecked] : pdfStyles.taskCheckbox}>
{checked && <CheckIcon size={8} color="#ffffff" />}
</View>
<View style={pdfStyles.listItemContent}>{children}</View>
</View>
);
},
table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.table}>
{children}
</View>
),
tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const isHeader = node.attrs?._isHeader === true;
return (
<View key={ctx.getKey()} style={isHeader ? pdfStyles.tableHeaderRow : pdfStyles.tableRow} wrap={false}>
{children}
</View>
);
},
tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const colwidth = node.attrs?.colwidth as number[] | undefined;
const background = node.attrs?.background as string | undefined;
const width = colwidth?.[0];
const widthStyle = width ? { width, flex: undefined } : {};
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.tableHeaderCell, widthStyle, bgStyle]}>
{children}
</View>
);
},
tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const colwidth = node.attrs?.colwidth as number[] | undefined;
const background = node.attrs?.background as string | undefined;
const width = colwidth?.[0];
const widthStyle = width ? { width, flex: undefined } : {};
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.tableCell, widthStyle, bgStyle]}>
{children}
</View>
);
},
horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.horizontalRule} />
),
hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<Text key={ctx.getKey()}>{"\n"}</Text>
),
image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
if (ctx.metadata?.noAssets) {
return <View key={ctx.getKey()} />;
}
const src = (node.attrs?.src as string) || "";
const width = node.attrs?.width as number | undefined;
const alignment = (node.attrs?.alignment as string) || "left";
if (!src) {
return <View key={ctx.getKey()} />;
}
const alignmentStyle =
alignment === "center"
? { alignItems: "center" as const }
: alignment === "right"
? { alignItems: "flex-end" as const }
: { alignItems: "flex-start" as const };
return (
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
<Image
src={src}
style={[pdfStyles.image, width ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }]}
/>
</View>
);
},
imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
if (ctx.metadata?.noAssets) {
return <View key={ctx.getKey()} />;
}
const assetId = (node.attrs?.src as string) || "";
const rawWidth = node.attrs?.width;
const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined);
const alignment = (node.attrs?.alignment as string) || "left";
if (!assetId) {
return <View key={ctx.getKey()} />;
}
let resolvedSrc = assetId;
if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) {
resolvedSrc = ctx.metadata.resolvedImageUrls[assetId];
}
const alignmentStyle =
alignment === "center"
? { alignItems: "center" as const }
: alignment === "right"
? { alignItems: "flex-end" as const }
: { alignItems: "flex-start" as const };
if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) {
return (
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
<Text style={pdfStyles.imagePlaceholderText}>[Image: {assetId.slice(0, 8)}...]</Text>
</View>
);
}
const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 };
return (
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
<Image src={resolvedSrc} style={[pdfStyles.image, imageStyle]} />
</View>
);
},
calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const backgroundKey = (node.attrs?.["data-background"] as string) || "gray";
const backgroundColor =
EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3;
return (
<View key={ctx.getKey()} style={[pdfStyles.callout, { backgroundColor }]}>
<View style={pdfStyles.calloutIconContainer}>{getCalloutIcon(node, TEXT_COLORS.primary)}</View>
<View style={[pdfStyles.calloutContent, { color: TEXT_COLORS.primary }]}>{children}</View>
</View>
);
},
mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const id = (node.attrs?.id as string) || "";
const entityIdentifier = (node.attrs?.entity_identifier as string) || "";
const entityName = (node.attrs?.entity_name as string) || "";
let displayText = entityName || id || entityIdentifier;
if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) {
const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id);
if (userMention) {
displayText = userMention.display_name;
}
}
return (
<Text key={ctx.getKey()} style={pdfStyles.mention}>
@{displayText}
</Text>
);
},
};
type InternalRenderContext = {
parentType?: string;
nestingLevel: number;
listItemIndex: number;
textAlign?: string | null;
pdfContext: PDFRenderContext;
};
const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => {
const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context;
const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST;
let childTextAlign = textAlign;
if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) {
childTextAlign = node.attrs.textAlign as string;
}
const nodeWithContext = {
...node,
attrs: {
...node.attrs,
_parentType: parentType,
_nestingLevel: nestingLevel,
_listItemIndex: listItemIndex,
_textAlign: childTextAlign,
_isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER),
},
};
let childNestingLevel = nestingLevel;
if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) {
childNestingLevel = nestingLevel + 1;
}
let currentListItemIndex = 0;
const children: ReactElement[] =
node.content?.map((child) => {
const childContext: InternalRenderContext = {
parentType: node.type,
nestingLevel: childNestingLevel,
listItemIndex: 0,
textAlign: childTextAlign,
pdfContext,
};
if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) {
currentListItemIndex++;
childContext.listItemIndex = currentListItemIndex;
}
return renderNodeWithContext(child, childContext);
}) || [];
const renderer = nodeRenderers[node.type];
if (renderer) {
return renderer(nodeWithContext, children, pdfContext);
}
if (children.length > 0) {
return <View key={pdfContext.getKey()}>{children}</View>;
}
return <View key={pdfContext.getKey()} />;
};
export const renderNode = (
node: TipTapNode,
parentType?: string,
_index?: number,
metadata?: PDFExportMetadata,
getKey?: KeyGenerator
): ReactElement => {
const keyGen = getKey ?? createKeyGenerator();
return renderNodeWithContext(node, {
parentType,
nestingLevel: 0,
listItemIndex: 0,
pdfContext: { getKey: keyGen, metadata },
});
};
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { createRequire } from "module";
import path from "path";
import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer";
import { createKeyGenerator, renderNode } from "./node-renderers";
import { pdfStyles } from "./styles";
import type { PDFExportOptions, TipTapDocument } from "./types";
// Use createRequire for ESM compatibility to resolve font file paths
const require = createRequire(import.meta.url);
// Resolve local font file paths from @fontsource/inter package
const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json"));
Font.register({
family: "Inter",
fonts: [
{
src: path.join(interFontDir, "files/inter-latin-400-normal.woff"),
fontWeight: 400,
},
{
src: path.join(interFontDir, "files/inter-latin-400-italic.woff"),
fontWeight: 400,
fontStyle: "italic",
},
{
src: path.join(interFontDir, "files/inter-latin-600-normal.woff"),
fontWeight: 600,
},
{
src: path.join(interFontDir, "files/inter-latin-600-italic.woff"),
fontWeight: 600,
fontStyle: "italic",
},
{
src: path.join(interFontDir, "files/inter-latin-700-normal.woff"),
fontWeight: 700,
},
{
src: path.join(interFontDir, "files/inter-latin-700-italic.woff"),
fontWeight: 700,
fontStyle: "italic",
},
],
});
export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => {
const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options;
// Merge noAssets into metadata for use in node renderers
const mergedMetadata = { ...metadata, noAssets };
const content = doc.content || [];
const getKey = createKeyGenerator();
const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey));
return (
<Document title={title} author={author} subject={subject}>
<Page size={pageSize} orientation={pageOrientation} style={pdfStyles.page}>
{title && <Text style={pdfStyles.title}>{title}</Text>}
{renderedContent}
</Page>
</Document>
);
};
export const renderPlaneDocToPdfBuffer = async (
doc: TipTapDocument,
options: PDFExportOptions = {}
): Promise<Buffer> => {
const pdfDocument = createPdfDocument(doc, options);
const pdfInstance = pdf(pdfDocument);
const blob = await pdfInstance.toBlob();
const arrayBuffer = await blob.arrayBuffer();
return Buffer.from(arrayBuffer);
};
export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise<Blob> => {
const pdfDocument = createPdfDocument(doc, options);
const pdfInstance = pdf(pdfDocument);
return await pdfInstance.toBlob();
};
+250
View File
@@ -0,0 +1,250 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { StyleSheet } from "@react-pdf/renderer";
import {
BACKGROUND_COLORS,
BORDER_COLORS,
BRAND_COLORS,
CODE_COLORS,
LINK_COLORS,
MENTION_COLORS,
TEXT_COLORS,
} from "./colors";
export const pdfStyles = StyleSheet.create({
page: {
padding: 40,
fontFamily: "Inter",
fontSize: 11,
lineHeight: 1.6,
color: TEXT_COLORS.primary,
},
title: {
fontSize: 24,
fontWeight: 600,
marginBottom: 20,
color: TEXT_COLORS.primary,
},
heading1: {
fontSize: 20,
fontWeight: 600,
marginTop: 16,
marginBottom: 8,
color: TEXT_COLORS.primary,
},
heading2: {
fontSize: 16,
fontWeight: 600,
marginTop: 14,
marginBottom: 6,
color: TEXT_COLORS.primary,
},
heading3: {
fontSize: 14,
fontWeight: 600,
marginTop: 12,
marginBottom: 4,
color: TEXT_COLORS.primary,
},
heading4: {
fontSize: 12,
fontWeight: 600,
marginTop: 10,
marginBottom: 4,
color: TEXT_COLORS.secondary,
},
heading5: {
fontSize: 11,
fontWeight: 600,
marginTop: 8,
marginBottom: 4,
color: TEXT_COLORS.secondary,
},
heading6: {
fontSize: 10,
fontWeight: 600,
marginTop: 6,
marginBottom: 4,
color: TEXT_COLORS.tertiary,
},
paragraph: {
marginBottom: 0,
},
paragraphWrapper: {
marginBottom: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong
paddingLeft: 12,
marginLeft: 0,
marginVertical: 8,
fontStyle: "normal", // Matches editor: font-style: normal
fontWeight: 400, // Matches editor: font-weight: 400
color: TEXT_COLORS.primary,
breakInside: "avoid",
},
codeBlock: {
backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent
padding: 12,
borderRadius: 4,
fontFamily: "Courier",
fontSize: 10,
marginVertical: 8,
color: TEXT_COLORS.primary,
breakInside: "avoid",
},
codeInline: {
backgroundColor: BACKGROUND_COLORS.layer1,
padding: 2,
paddingHorizontal: 4,
borderRadius: 2,
fontFamily: "Courier",
fontSize: 10,
color: CODE_COLORS.text, // Red for inline code
},
bulletList: {
marginVertical: 8,
paddingLeft: 0,
},
orderedList: {
marginVertical: 8,
paddingLeft: 0,
},
listItem: {
display: "flex",
flexDirection: "row",
gap: 6,
marginBottom: 4,
paddingRight: 10,
breakInside: "avoid",
},
listItemBullet: {},
listItemContent: {
flex: 1,
},
taskList: {
marginVertical: 8,
},
taskItem: {
display: "flex",
flexDirection: "row",
gap: 6,
marginBottom: 4,
alignItems: "flex-start",
paddingRight: 10,
breakInside: "avoid",
},
taskCheckbox: {
width: 12,
height: 12,
borderWidth: 1,
borderColor: BORDER_COLORS.strong, // Matches editor: border-strong
borderRadius: 2,
marginTop: 2,
alignItems: "center",
justifyContent: "center",
},
taskCheckboxChecked: {
backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary
borderColor: BRAND_COLORS.default, // --border-color-accent-strong
},
table: {
marginVertical: 8,
borderWidth: 1,
borderColor: BORDER_COLORS.subtle1, // border-subtle-1
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: BORDER_COLORS.subtle1,
breakInside: "avoid",
},
tableHeaderRow: {
backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: BORDER_COLORS.subtle1,
},
tableCell: {
padding: 8,
borderRightWidth: 1,
borderRightColor: BORDER_COLORS.subtle1,
flex: 1,
},
tableHeaderCell: {
padding: 8,
borderRightWidth: 1,
borderRightColor: BORDER_COLORS.subtle1,
flex: 1,
fontWeight: "bold",
},
horizontalRule: {
borderBottomWidth: 1,
borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1
marginVertical: 16,
},
image: {
maxWidth: "100%",
marginVertical: 8,
},
imagePlaceholder: {
backgroundColor: BACKGROUND_COLORS.layer1,
padding: 16,
borderRadius: 4,
marginVertical: 8,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: BORDER_COLORS.subtle,
borderStyle: "dashed",
},
imagePlaceholderText: {
color: TEXT_COLORS.tertiary,
fontSize: 10,
},
callout: {
backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background)
padding: 12,
borderRadius: 6,
marginVertical: 8,
flexDirection: "row",
alignItems: "flex-start",
breakInside: "avoid",
},
calloutIconContainer: {
marginRight: 10,
marginTop: 2,
},
calloutContent: {
flex: 1,
color: TEXT_COLORS.primary, // text-primary
},
mention: {
backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent
color: MENTION_COLORS.text, // text-accent-primary
padding: 2,
paddingHorizontal: 4,
borderRadius: 2,
},
link: {
color: LINK_COLORS.primary, // --txt-link-primary
textDecoration: "underline",
},
bold: {
fontWeight: "bold",
},
italic: {
fontStyle: "italic",
},
underline: {
textDecoration: "underline",
},
strike: {
textDecoration: "line-through",
},
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Style } from "@react-pdf/types";
export type TipTapMark = {
type: string;
attrs?: Record<string, unknown>;
};
export type TipTapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TipTapNode[];
text?: string;
marks?: TipTapMark[];
};
export type TipTapDocument = {
type: "doc";
content?: TipTapNode[];
};
export type KeyGenerator = () => string;
export type PDFRenderContext = {
getKey: KeyGenerator;
metadata?: PDFExportMetadata;
};
export type PDFNodeRenderer = (
node: TipTapNode,
children: React.ReactElement[],
context: PDFRenderContext
) => React.ReactElement;
export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style;
export type NodeRendererRegistry = Record<string, PDFNodeRenderer>;
export type MarkRendererRegistry = Record<string, PDFMarkRenderer>;
export type PDFExportOptions = {
title?: string;
author?: string;
subject?: string;
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
pageOrientation?: "portrait" | "landscape";
metadata?: PDFExportMetadata;
/** When true, images and other assets are excluded from the PDF */
noAssets?: boolean;
};
/**
* Metadata for resolving entity references in PDF export
*/
export type PDFExportMetadata = {
/** User mentions (user_mention in mention node) */
userMentions?: PDFUserMention[];
/** Resolved image URLs: Map of asset ID to presigned URL */
resolvedImageUrls?: Record<string, string>;
/** When true, images and other assets are excluded from the PDF */
noAssets?: boolean;
};
export type PDFUserMention = {
id: string;
display_name: string;
avatar_url?: string;
};