diff --git a/frontend/packages/erd-core/package.json b/frontend/packages/erd-core/package.json index 38027a7497..2f6fd733c6 100644 --- a/frontend/packages/erd-core/package.json +++ b/frontend/packages/erd-core/package.json @@ -22,6 +22,8 @@ "clsx": "2.1.1", "cmdk": "1.1.1", "elkjs": "0.10.0", + "html-to-image": "1.11.13", + "jspdf": "4.0.0", "neverthrow": "8.2.0", "nuqs": "2.4.3", "pako": "2.1.0", diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/AppBar.tsx b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/AppBar.tsx index bd7ded9cb5..f7b22a6295 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/AppBar.tsx +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/AppBar.tsx @@ -7,10 +7,11 @@ import { TooltipTrigger, } from '@dlh/erd-viewer-ui' import type { FC } from 'react' +import { useMemo } from 'react' import { CommandPaletteTriggerButton } from '../CommandPalette' import styles from './AppBar.module.css' import { CopyLinkButton } from './CopyLinkButton' -import { ExportDropdown } from './ExportDropdown' +import { ExportConfigProvider, ExportDropdown } from './ExportDropdown' import { GithubButton } from './GithubButton' import { HelpButton } from './HelpButton' import { MenuButton } from './MenuButton' @@ -21,28 +22,41 @@ type Props = { config?: AppBarConfig } +const getAppBarSettings = (config?: AppBarConfig) => ({ + logoUrl: config?.logo?.url ?? 'https://liambx.com', + logoImgUrl: config?.logo?.imgUrl ?? '', + logoImgHeight: config?.logo?.imgHeight ?? '', + logoText: config?.logo?.text ?? 'Liam ERD', + showLogoText: config?.logo?.showText !== false, + showSearch: config?.search?.show !== false, + showGithub: config?.github?.show !== false, + githubUrl: config?.github?.url ?? 'https://github.com/liam-hq/liam', + showAnnouncements: config?.announcements?.show !== false, + announcementsUrl: + config?.announcements?.url ?? 'https://github.com/liam-hq/liam/releases', + showHelp: config?.help?.show !== false, + helpItems: config?.help?.items, + showExport: config?.export?.show !== false, + showCopyLink: config?.copyLink?.show !== false, + copyLinkValue: config?.copyLink?.value, + hasCustomLogo: Boolean(config?.logo?.imgUrl), +}) + export const AppBar: FC = ({ config }) => { + const exportConfig = useMemo( + () => ({ + printExportLogo: config?.export?.printExportLogo, + printExportLogoPosition: config?.export?.printExportLogoPosition, + }), + [config?.export?.printExportLogo, config?.export?.printExportLogoPosition], + ) + + const settings = useMemo(() => getAppBarSettings(config), [config]) + if (config?.show === false) { return null } - const logoUrl = config?.logo?.url ?? 'https://liambx.com' - const logoImgUrl = config?.logo?.imgUrl ?? '' - const logoImgHeight = config?.logo?.imgHeight ?? '' - const logoText = config?.logo?.text ?? 'Liam ERD' - const showLogoText = config?.logo?.showText !== false - const showSearch = config?.search?.show !== false - const showGithub = config?.github?.show !== false - const githubUrl = config?.github?.url ?? 'https://github.com/liam-hq/liam' - const showAnnouncements = config?.announcements?.show !== false - const announcementsUrl = - config?.announcements?.url ?? 'https://github.com/liam-hq/liam/releases' - const showHelp = config?.help?.show !== false - const helpItems = config?.help?.items - const showExport = config?.export?.show !== false - const showCopyLink = config?.copyLink?.show !== false - const copyLinkValue = config?.copyLink?.value - return (
@@ -53,13 +67,17 @@ export const AppBar: FC = ({ config }) => { - {config?.logo?.imgUrl ? ( - logo + {settings.hasCustomLogo ? ( + logo ) : ( )} @@ -72,21 +90,33 @@ export const AppBar: FC = ({ config }) => {
- {showLogoText &&

{logoText}

} + {settings.showLogoText && ( +

{settings.logoText}

+ )}
- {showSearch && } - {showGithub && } - {showAnnouncements && } - {showHelp && ( - + {settings.showSearch && } + {settings.showGithub && } + {settings.showAnnouncements && ( + + )} + {settings.showHelp && ( + )}
- {showExport && } - {showCopyLink && ( + {settings.showExport && ( + + + + )} + {settings.showCopyLink && ( )}
diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportConfigContext.tsx b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportConfigContext.tsx new file mode 100644 index 0000000000..f3b00ceee8 --- /dev/null +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportConfigContext.tsx @@ -0,0 +1,30 @@ +import { createContext, type FC, type ReactNode, useContext } from 'react' +import type { WatermarkPosition } from './useExportDiagram' + +type ExportConfig = { + printExportLogo?: string | undefined + printExportLogoPosition?: WatermarkPosition | undefined +} + +const ExportConfigContext = createContext(null) + +type ExportConfigProviderProps = { + config: ExportConfig + children: ReactNode +} + +export const ExportConfigProvider: FC = ({ + config, + children, +}) => { + return ( + + {children} + + ) +} + +export const useExportConfig = (): ExportConfig => { + const context = useContext(ExportConfigContext) + return context ?? {} +} diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportDropdown.tsx b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportDropdown.tsx index c717b3f608..4f060c9a54 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportDropdown.tsx +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/ExportDropdown.tsx @@ -6,20 +6,26 @@ import { Button, ChevronDown, Copy, + Download, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, + DropdownMenuSeparator, DropdownMenuTrigger, useToast, } from '@dlh/erd-viewer-ui' import type { FC } from 'react' import { useSchemaOrThrow } from '../../../../../../stores' import { fromPromise } from '../../../../../../utils/neverthrow' +import { useExportConfig } from './ExportConfigContext' +import { useExportDiagram } from './useExportDiagram' export const ExportDropdown: FC = () => { const toast = useToast() const schema = useSchemaOrThrow() + const { exportToPng, exportToPdf } = useExportDiagram() + const exportConfig = useExportConfig() const handleCopyPostgreSQL = async () => { // Feature detection for clipboard API @@ -104,6 +110,56 @@ export const ExportDropdown: FC = () => { ) } + const handleExportPng = async () => { + const result = await exportToPng({ + watermarkLogo: exportConfig.printExportLogo, + watermarkPosition: exportConfig.printExportLogoPosition, + }) + + result.match( + () => { + toast({ + title: 'PNG exported!', + description: 'Diagram has been exported as PNG', + status: 'success', + }) + }, + (error: Error) => { + console.error('Failed to export PNG:', error) + toast({ + title: 'Export failed', + description: error.message, + status: 'error', + }) + }, + ) + } + + const handleExportPdf = async () => { + const result = await exportToPdf({ + watermarkLogo: exportConfig.printExportLogo, + watermarkPosition: exportConfig.printExportLogoPosition, + }) + + result.match( + () => { + toast({ + title: 'PDF exported!', + description: 'Diagram has been exported as PDF', + status: 'success', + }) + }, + (error: Error) => { + console.error('Failed to export PDF:', error) + toast({ + title: 'Export failed', + description: error.message, + status: 'error', + }) + }, + ) + } + return ( @@ -117,6 +173,19 @@ export const ExportDropdown: FC = () => { + } + onSelect={handleExportPng} + > + Download PNG + + } + onSelect={handleExportPdf} + > + Download PDF + + } onSelect={handleCopyPostgreSQL} diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/index.ts b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/index.ts index 24aff0c623..47cbfb1944 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/index.ts +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/index.ts @@ -1 +1,3 @@ +export { ExportConfigProvider } from './ExportConfigContext' export * from './ExportDropdown' +export type { WatermarkPosition } from './useExportDiagram' diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/useExportDiagram.ts b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/useExportDiagram.ts new file mode 100644 index 0000000000..3dc651a053 --- /dev/null +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportDropdown/useExportDiagram.ts @@ -0,0 +1,257 @@ +import { useReactFlow } from '@xyflow/react' +import { toPng } from 'html-to-image' +import { jsPDF } from 'jspdf' +import { err, ok, type Result } from 'neverthrow' +import { useCallback } from 'react' + +export type WatermarkPosition = + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + +type ExportOptions = { + watermarkLogo?: string | undefined + watermarkPosition?: WatermarkPosition | undefined +} + +const addWatermarkToCanvas = ( + canvas: HTMLCanvasElement, + watermarkLogo: string, + position: WatermarkPosition, +): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + const ctx = canvas.getContext('2d') + if (!ctx) { + reject(new Error('Could not get canvas context')) + return + } + + const padding = 20 + const maxWatermarkWidth = Math.min(150, canvas.width * 0.15) + const scale = maxWatermarkWidth / img.width + const watermarkWidth = img.width * scale + const watermarkHeight = img.height * scale + + let x: number + let y: number + + switch (position) { + case 'top-left': + x = padding + y = padding + break + case 'top-right': + x = canvas.width - watermarkWidth - padding + y = padding + break + case 'bottom-left': + x = padding + y = canvas.height - watermarkHeight - padding + break + case 'bottom-right': + x = canvas.width - watermarkWidth - padding + y = canvas.height - watermarkHeight - padding + break + } + + ctx.globalAlpha = 0.7 + ctx.drawImage(img, x, y, watermarkWidth, watermarkHeight) + ctx.globalAlpha = 1.0 + + resolve(canvas) + } + img.onerror = () => { + reject(new Error('Failed to load watermark image')) + } + img.src = watermarkLogo + }) +} + +const dataUrlToCanvas = (dataUrl: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d') + if (!ctx) { + reject(new Error('Could not get canvas context')) + return + } + ctx.drawImage(img, 0, 0) + resolve(canvas) + } + img.onerror = () => { + reject(new Error('Failed to load image')) + } + img.src = dataUrl + }) +} + +export const useExportDiagram = () => { + const { getNodes } = useReactFlow() + + const getFlowElement = useCallback((): Result => { + const element = document.querySelector('.react-flow__viewport') + if (!element) { + return err(new Error('Could not find React Flow viewport')) + } + if (!(element instanceof HTMLElement)) { + return err(new Error('React Flow viewport is not an HTMLElement')) + } + return ok(element) + }, []) + + const exportToPng = useCallback( + async (options?: ExportOptions): Promise> => { + const flowElementResult = getFlowElement() + if (flowElementResult.isErr()) { + return err(flowElementResult.error) + } + const flowElement = flowElementResult.value + + const nodes = getNodes() + if (nodes.length === 0) { + return err(new Error('No nodes to export')) + } + + const nodesBounds = nodes.reduce( + (bounds, node) => { + const nodeWidth = node.measured?.width ?? node.width ?? 200 + const nodeHeight = node.measured?.height ?? node.height ?? 100 + return { + minX: Math.min(bounds.minX, node.position.x), + minY: Math.min(bounds.minY, node.position.y), + maxX: Math.max(bounds.maxX, node.position.x + nodeWidth), + maxY: Math.max(bounds.maxY, node.position.y + nodeHeight), + } + }, + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }, + ) + + const padding = 50 + const width = nodesBounds.maxX - nodesBounds.minX + padding * 2 + const height = nodesBounds.maxY - nodesBounds.minY + padding * 2 + + const dataUrl = await toPng(flowElement, { + backgroundColor: '#ffffff', + width, + height, + style: { + width: `${width}px`, + height: `${height}px`, + transform: `translate(${-nodesBounds.minX + padding}px, ${-nodesBounds.minY + padding}px)`, + }, + }) + + let finalDataUrl = dataUrl + + if (options?.watermarkLogo) { + const canvas = await dataUrlToCanvas(dataUrl) + const watermarkedCanvas = await addWatermarkToCanvas( + canvas, + options.watermarkLogo, + options.watermarkPosition ?? 'top-left', + ) + finalDataUrl = watermarkedCanvas.toDataURL('image/png') + } + + const link = document.createElement('a') + link.download = 'erd-diagram.png' + link.href = finalDataUrl + link.click() + + return ok(undefined) + }, + [getFlowElement, getNodes], + ) + + const exportToPdf = useCallback( + async (options?: ExportOptions): Promise> => { + const flowElementResult = getFlowElement() + if (flowElementResult.isErr()) { + return err(flowElementResult.error) + } + const flowElement = flowElementResult.value + + const nodes = getNodes() + if (nodes.length === 0) { + return err(new Error('No nodes to export')) + } + + const nodesBounds = nodes.reduce( + (bounds, node) => { + const nodeWidth = node.measured?.width ?? node.width ?? 200 + const nodeHeight = node.measured?.height ?? node.height ?? 100 + return { + minX: Math.min(bounds.minX, node.position.x), + minY: Math.min(bounds.minY, node.position.y), + maxX: Math.max(bounds.maxX, node.position.x + nodeWidth), + maxY: Math.max(bounds.maxY, node.position.y + nodeHeight), + } + }, + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }, + ) + + const padding = 50 + const width = nodesBounds.maxX - nodesBounds.minX + padding * 2 + const height = nodesBounds.maxY - nodesBounds.minY + padding * 2 + + const dataUrl = await toPng(flowElement, { + backgroundColor: '#ffffff', + width, + height, + style: { + width: `${width}px`, + height: `${height}px`, + transform: `translate(${-nodesBounds.minX + padding}px, ${-nodesBounds.minY + padding}px)`, + }, + }) + + let finalDataUrl = dataUrl + + if (options?.watermarkLogo) { + const canvas = await dataUrlToCanvas(dataUrl) + const watermarkedCanvas = await addWatermarkToCanvas( + canvas, + options.watermarkLogo, + options.watermarkPosition ?? 'top-left', + ) + finalDataUrl = watermarkedCanvas.toDataURL('image/png') + } + + const orientation = width > height ? 'landscape' : 'portrait' + const pdf = new jsPDF({ + orientation, + unit: 'px', + format: [width, height], + }) + + pdf.addImage(finalDataUrl, 'PNG', 0, 0, width, height) + pdf.save('erd-diagram.pdf') + + return ok(undefined) + }, + [getFlowElement, getNodes], + ) + + return { + exportToPng, + exportToPdf, + } +} diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/index.ts b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/index.ts index f6f2ada83f..127fae2e00 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/index.ts +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/index.ts @@ -1,2 +1,3 @@ export * from './AppBar' +export type { WatermarkPosition } from './ExportDropdown' export * from './types' diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/types.ts b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/types.ts index 4a95a03833..a60a67b1a8 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/types.ts +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/types.ts @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import type { WatermarkPosition } from './ExportDropdown' export type HelpMenuItem = { label: string @@ -32,6 +33,8 @@ export type AppBarConfig = { } export?: { show?: boolean + printExportLogo?: string + printExportLogoPosition?: WatermarkPosition } copyLink?: { show?: boolean diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/CommandPalettePreview/CommandPreview.tsx b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/CommandPalettePreview/CommandPreview.tsx index c4853d4007..e009fdaa5d 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/CommandPalettePreview/CommandPreview.tsx +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/CommandPalettePreview/CommandPreview.tsx @@ -5,11 +5,8 @@ type Props = { commandName: string } - // TODO: Work on this for DLH ERD VIEWER - - // TODO: set gif or image for "Show All Table" and "Hide All Table" commands const COMMAND_VIDEO_SOURCE: Record = { 'copy link': diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ERDRenderer.module.css b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ERDRenderer.module.css index 4ae12f1aff..d8b5b1e7e0 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ERDRenderer.module.css +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ERDRenderer.module.css @@ -42,6 +42,16 @@ pointer-events: none; } +.toolbarLeft { + justify-content: start; + padding-left: 1rem; +} + +.toolbarRight { + justify-content: end; + padding-right: 1rem; +} + /* NOTE: Make the toolbar itself clickable */ .toolbarWrapper > * { pointer-events: auto; diff --git a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ErdRenderer.tsx b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ErdRenderer.tsx index 1aa92a22fb..bd857c401b 100644 --- a/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ErdRenderer.tsx +++ b/frontend/packages/erd-core/src/features/erd/components/ERDRenderer/ErdRenderer.tsx @@ -9,6 +9,7 @@ import { ToastProvider, } from '@dlh/erd-viewer-ui' import { ReactFlowProvider } from '@xyflow/react' +import clsx from 'clsx' import { type ComponentProps, createRef, @@ -40,6 +41,8 @@ import { RelationshipEdgeParticleMarker } from './RelationshipEdgeParticleMarker import { TableDetailDrawer, TableDetailDrawerRoot } from './TableDetailDrawer' import { Toolbar } from './Toolbar' +export type ToolbarAlignment = 'left' | 'center' | 'right' + type Props = { defaultSidebarOpen?: boolean | undefined errorObjects?: ComponentProps['errors'] @@ -48,6 +51,7 @@ type Props = { appBarConfig?: AppBarConfig customToolbarActions?: ReactNode showMinimap?: boolean + toolbarAlignment?: ToolbarAlignment } const SIDEBAR_COOKIE_NAME = 'sidebar:state' @@ -62,6 +66,7 @@ export const ERDRenderer: FC = ({ appBarConfig, customToolbarActions, showMinimap = false, + toolbarAlignment = 'center', }) => { const [open, setOpen] = useState(defaultSidebarOpen) const [isResizing, setIsResizing] = useState(false) @@ -185,7 +190,13 @@ export const ERDRenderer: FC = ({ )} {errorObjects.length === 0 && ( -
+
)} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57bda3eaa0..3c15b56f62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,12 @@ importers: elkjs: specifier: 0.10.0 version: 0.10.0 + html-to-image: + specifier: 1.11.13 + version: 1.11.13 + jspdf: + specifier: 4.0.0 + version: 4.0.0 neverthrow: specifier: 8.2.0 version: 8.2.0 @@ -3005,6 +3011,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@19.2.2': resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} peerDependencies: @@ -3028,6 +3037,9 @@ packages: '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -3485,6 +3497,10 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3632,6 +3648,10 @@ packages: caniuse-lite@1.0.30001751: resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -3893,6 +3913,9 @@ packages: core-js-pure@3.46.0: resolution: {integrity: sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3933,6 +3956,9 @@ packages: resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} engines: {node: '>=12 || >=16'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-loader@6.11.0: resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} engines: {node: '>= 12.13.0'} @@ -4141,6 +4167,9 @@ packages: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -4397,6 +4426,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -4425,6 +4457,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -4763,6 +4798,9 @@ packages: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + html-webpack-plugin@5.6.4: resolution: {integrity: sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==} engines: {node: '>=10.13.0'} @@ -4775,6 +4813,10 @@ packages: webpack: optional: true + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} @@ -4907,6 +4949,9 @@ packages: resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -5164,6 +5209,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jspdf@4.0.0: + resolution: {integrity: sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -5999,6 +6047,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-query-emscripten@5.1.0: resolution: {integrity: sha512-H1ZWOzLRddmHuE4GZqFjjo55hA9zMiePz/WDDGANA/EnvILCJps9pcRucyGd+MFvapeYOy6TWSYz6DbtBOaxRQ==} @@ -6221,6 +6272,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -6363,6 +6417,9 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regex-parser@2.3.1: resolution: {integrity: sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==} @@ -6446,6 +6503,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -6699,6 +6760,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -6890,6 +6955,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} @@ -6912,10 +6981,12 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -6946,6 +7017,9 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -7322,6 +7396,9 @@ packages: utila@0.4.0: resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -10574,6 +10651,9 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/raf@3.4.3': + optional: true + '@types/react-dom@19.2.2(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -10595,6 +10675,9 @@ snapshots: '@types/tinycolor2@1.4.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/whatwg-mimetype@3.0.2': {} '@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': @@ -11237,6 +11320,9 @@ snapshots: balanced-match@2.0.0: {} + base64-arraybuffer@1.0.2: + optional: true + base64-js@1.5.1: {} baseline-browser-mapping@2.8.20: {} @@ -11409,6 +11495,18 @@ snapshots: caniuse-lite@1.0.30001751: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.28.4 + '@types/raf': 3.4.3 + core-js: 3.48.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + case-sensitive-paths-webpack-plugin@2.4.0: {} chai@5.3.3: @@ -11654,6 +11752,9 @@ snapshots: core-js-pure@3.46.0: {} + core-js@3.48.0: + optional: true + core-util-is@1.0.3: {} cosmiconfig@7.1.0: @@ -11720,6 +11821,11 @@ snapshots: css-functions-list@3.2.3: {} + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + css-loader@6.11.0(webpack@5.102.1(@swc/core@1.12.11)(esbuild@0.25.11)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) @@ -11922,6 +12028,11 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -12252,6 +12363,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fast-safe-stringify@2.1.1: {} fast-uri@3.1.0: {} @@ -12274,6 +12391,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -12694,6 +12813,8 @@ snapshots: html-tags@3.3.1: {} + html-to-image@1.11.13: {} + html-webpack-plugin@5.6.4(webpack@5.102.1(@swc/core@1.12.11)(esbuild@0.25.11)): dependencies: '@types/html-minifier-terser': 6.1.0 @@ -12714,6 +12835,12 @@ snapshots: optionalDependencies: webpack: 5.102.1(@swc/core@1.12.11) + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + htmlparser2@6.1.0: dependencies: domelementtype: 2.3.0 @@ -12889,6 +13016,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + iobuffer@5.4.0: {} + ip-address@10.0.1: {} is-arguments@1.2.0: @@ -13111,6 +13240,17 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jspdf@4.0.0: + dependencies: + '@babel/runtime': 7.28.4 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.48.0 + dompurify: 3.3.1 + html2canvas: 1.4.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -13971,6 +14111,9 @@ snapshots: pend@1.2.0: {} + performance-now@2.1.0: + optional: true + pg-query-emscripten@5.1.0: {} picocolors@1.0.0: {} @@ -14175,6 +14318,11 @@ snapshots: queue-microtask@1.2.3: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -14346,6 +14494,9 @@ snapshots: regenerate@1.4.2: {} + regenerator-runtime@0.13.11: + optional: true + regex-parser@2.3.1: {} regexp-tree@0.1.27: {} @@ -14433,6 +14584,9 @@ snapshots: reusify@1.1.0: {} + rgbcolor@1.0.1: + optional: true + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -14726,6 +14880,9 @@ snapshots: stackback@0.0.2: {} + stackblur-canvas@2.7.0: + optional: true + stackframe@1.3.4: {} stat-mode@0.3.0: {} @@ -14972,6 +15129,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + svg-tags@1.0.0: {} swap-case@1.1.2: @@ -15066,6 +15226,11 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -15425,6 +15590,11 @@ snapshots: utila@0.4.0: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + v8-compile-cache-lib@3.0.1: {} valibot@1.1.0(typescript@5.9.3):