From 107c2db0b611a8761647b955e1a622ffd5c835c4 Mon Sep 17 00:00:00 2001 From: Ibrahim El-bastawisi Date: Tue, 17 Mar 2026 12:31:35 +0200 Subject: [PATCH 1/2] update editor package and add embedpdf package --- packages/editor/src/Editor.tsx | 9 +- packages/editor/src/Viewer.tsx | 11 +- .../src/components/Tools/AlertTools.tsx | 8 +- .../src/components/Tools/AttachmentTools.tsx | 8 +- .../editor/src/components/Tools/CodeTools.tsx | 8 +- .../src/components/Tools/DetailsTools.tsx | 8 +- .../editor/src/components/Tools/HrTools.tsx | 8 +- .../src/components/Tools/ImageTools.tsx | 8 +- .../editor/src/components/Tools/MathTools.tsx | 8 +- .../editor/src/components/Tools/NoteTools.tsx | 8 +- .../src/components/Tools/TableTools.tsx | 9 +- .../src/nodes/MathNode/MathComponent.tsx | 25 +- packages/editor/src/nodes/MathNode/index.css | 1 - .../src/plugins/ContextMenuPlugin/index.tsx | 2 +- .../src/plugins/FloatingToolbar/index.tsx | 9 +- .../src/plugins/ToolbarPlugin/NodeTools.tsx | 24 +- packages/embed-pdf/eslint.config.mjs | 4 + packages/embed-pdf/package.json | 61 + .../src/components/command-button.tsx | 90 + .../src/components/command-tab-button.tsx | 75 + .../components/document-password-prompt.tsx | 132 + .../embed-pdf/src/components/empty-state.tsx | 70 + .../embed-pdf/src/components/icons/index.tsx | 1258 +++++ .../src/components/loading-spinner.tsx | 39 + .../src/components/outline-sidebar.tsx | 30 + .../src/components/page-controls.tsx | 146 + .../src/components/search-sidebar.tsx | 255 + .../src/components/tab-context-menu.tsx | 91 + .../src/components/thumbnails-sidebar.tsx | 82 + .../src/components/ui/dropdown-menu.tsx | 74 + packages/embed-pdf/src/components/ui/index.ts | 8 + .../src/components/ui/toolbar-button.tsx | 64 + .../src/components/ui/toolbar-divider.tsx | 18 + .../embed-pdf/src/components/zoom-toolbar.tsx | 167 + packages/embed-pdf/src/config/commands.ts | 1228 +++++ packages/embed-pdf/src/config/index.ts | 9 + packages/embed-pdf/src/config/translations.ts | 429 ++ packages/embed-pdf/src/config/types.ts | 46 + packages/embed-pdf/src/config/ui-schema.ts | 842 ++++ packages/embed-pdf/src/ui/index.ts | 16 + packages/embed-pdf/src/ui/schema-menu.tsx | 462 ++ packages/embed-pdf/src/ui/schema-panel.tsx | 425 ++ .../src/ui/schema-selection-menu.tsx | 95 + packages/embed-pdf/src/ui/schema-toolbar.tsx | 247 + packages/embed-pdf/src/viewer.tsx | 348 ++ packages/embed-pdf/tsconfig.json | 10 + packages/ui/src/hooks/use-container-query.ts | 63 +- packages/ui/src/lib/match-container.ts | 16 +- pnpm-lock.yaml | 4110 ++++++++++------- 49 files changed, 9481 insertions(+), 1683 deletions(-) create mode 100644 packages/embed-pdf/eslint.config.mjs create mode 100644 packages/embed-pdf/package.json create mode 100644 packages/embed-pdf/src/components/command-button.tsx create mode 100644 packages/embed-pdf/src/components/command-tab-button.tsx create mode 100644 packages/embed-pdf/src/components/document-password-prompt.tsx create mode 100644 packages/embed-pdf/src/components/empty-state.tsx create mode 100644 packages/embed-pdf/src/components/icons/index.tsx create mode 100644 packages/embed-pdf/src/components/loading-spinner.tsx create mode 100644 packages/embed-pdf/src/components/outline-sidebar.tsx create mode 100644 packages/embed-pdf/src/components/page-controls.tsx create mode 100644 packages/embed-pdf/src/components/search-sidebar.tsx create mode 100644 packages/embed-pdf/src/components/tab-context-menu.tsx create mode 100644 packages/embed-pdf/src/components/thumbnails-sidebar.tsx create mode 100644 packages/embed-pdf/src/components/ui/dropdown-menu.tsx create mode 100644 packages/embed-pdf/src/components/ui/index.ts create mode 100644 packages/embed-pdf/src/components/ui/toolbar-button.tsx create mode 100644 packages/embed-pdf/src/components/ui/toolbar-divider.tsx create mode 100644 packages/embed-pdf/src/components/zoom-toolbar.tsx create mode 100644 packages/embed-pdf/src/config/commands.ts create mode 100644 packages/embed-pdf/src/config/index.ts create mode 100644 packages/embed-pdf/src/config/translations.ts create mode 100644 packages/embed-pdf/src/config/types.ts create mode 100644 packages/embed-pdf/src/config/ui-schema.ts create mode 100644 packages/embed-pdf/src/ui/index.ts create mode 100644 packages/embed-pdf/src/ui/schema-menu.tsx create mode 100644 packages/embed-pdf/src/ui/schema-panel.tsx create mode 100644 packages/embed-pdf/src/ui/schema-selection-menu.tsx create mode 100644 packages/embed-pdf/src/ui/schema-toolbar.tsx create mode 100644 packages/embed-pdf/src/viewer.tsx create mode 100644 packages/embed-pdf/tsconfig.json diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 454899c..a27142c 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -55,8 +55,9 @@ import SelectionHighlightPlugin from '@repo/editor/plugins/SelectionHighlightPlu export const Editor: React.FC<{ documentId?: string; + tabId?: string; onChange?: (editorState: EditorState, editor: LexicalEditor, tags: Set) => void; -}> = ({ documentId, onChange }) => { +}> = ({ documentId, tabId, onChange }) => { const [editor] = useLexicalComposerContext(); const isPaged = useSelector((state) => state.pageSetup?.isPaged); const { updateEditorStoreState } = useActions(); @@ -67,7 +68,7 @@ export const Editor: React.FC<{ if (documentId) { // Dispatch custom event with checksum const event = new CustomEvent('checksum-change', { - detail: { documentId: documentId, checksum }, + detail: { documentId, tabId, checksum }, }); window.dispatchEvent(event); } @@ -97,7 +98,7 @@ export const Editor: React.FC<{ return (
diff --git a/packages/editor/src/Viewer.tsx b/packages/editor/src/Viewer.tsx index 4df206e..f848170 100644 --- a/packages/editor/src/Viewer.tsx +++ b/packages/editor/src/Viewer.tsx @@ -21,7 +21,10 @@ import { serializeEditorState } from '@repo/editor/utils/editorState'; import SelectionHighlightPlugin from '@repo/editor/plugins/SelectionHighlightPlugin'; import { LinkNavigatePlugin } from '@repo/editor/plugins/LinkPlugin'; -export const Viewer: React.FC<{ documentId?: string }> = ({ documentId }) => { +export const Viewer: React.FC<{ documentId?: string; tabId?: string }> = ({ + documentId, + tabId, +}) => { const [editor] = useLexicalComposerContext(); const isPaged = useSelector((state) => state.pageSetup?.isPaged); const { updateEditorStoreState } = useActions(); @@ -32,7 +35,7 @@ export const Viewer: React.FC<{ documentId?: string }> = ({ documentId }) => { if (documentId) { // Dispatch custom event with checksum const event = new CustomEvent('checksum-change', { - detail: { documentId: documentId, checksum }, + detail: { documentId, tabId, checksum }, }); window.dispatchEvent(event); } @@ -49,7 +52,7 @@ export const Viewer: React.FC<{ documentId?: string }> = ({ documentId }) => { return (
= ({ documentId }) => { } diff --git a/packages/editor/src/components/Tools/AlertTools.tsx b/packages/editor/src/components/Tools/AlertTools.tsx index d904dff..f8f5638 100644 --- a/packages/editor/src/components/Tools/AlertTools.tsx +++ b/packages/editor/src/components/Tools/AlertTools.tsx @@ -152,10 +152,14 @@ function AlertTools({ node }: { node: AlertNode }) { export default function AlertToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: AlertNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/AttachmentTools.tsx b/packages/editor/src/components/Tools/AttachmentTools.tsx index 2e69786..ab14229 100644 --- a/packages/editor/src/components/Tools/AttachmentTools.tsx +++ b/packages/editor/src/components/Tools/AttachmentTools.tsx @@ -166,10 +166,14 @@ function AttachmentTools({ node }: { node: AttachmentNode }) { export default function AttachmentToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: AttachmentNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/CodeTools.tsx b/packages/editor/src/components/Tools/CodeTools.tsx index 52bfba9..c8be967 100644 --- a/packages/editor/src/components/Tools/CodeTools.tsx +++ b/packages/editor/src/components/Tools/CodeTools.tsx @@ -180,10 +180,14 @@ function CodeTools({ node }: { node: CodeNode }) { export default function CodeToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: CodeNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/DetailsTools.tsx b/packages/editor/src/components/Tools/DetailsTools.tsx index b8c41f8..dff2e8b 100644 --- a/packages/editor/src/components/Tools/DetailsTools.tsx +++ b/packages/editor/src/components/Tools/DetailsTools.tsx @@ -167,10 +167,14 @@ function DetailsTools({ node }: { node: DetailsContainerNode }) { export default function DetailsToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: DetailsContainerNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/HrTools.tsx b/packages/editor/src/components/Tools/HrTools.tsx index 08884b2..7395292 100644 --- a/packages/editor/src/components/Tools/HrTools.tsx +++ b/packages/editor/src/components/Tools/HrTools.tsx @@ -143,10 +143,14 @@ function HrTools({ node }: { node: HorizontalRuleNode }) { export default function HrToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: HorizontalRuleNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/ImageTools.tsx b/packages/editor/src/components/Tools/ImageTools.tsx index 8d01db8..d8150d2 100644 --- a/packages/editor/src/components/Tools/ImageTools.tsx +++ b/packages/editor/src/components/Tools/ImageTools.tsx @@ -273,10 +273,14 @@ function ImageTools({ export default function ImageToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: ImageNode | SketchNode | DiagramNode | IFrameNode | ScoreNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/MathTools.tsx b/packages/editor/src/components/Tools/MathTools.tsx index bc83650..b45b997 100644 --- a/packages/editor/src/components/Tools/MathTools.tsx +++ b/packages/editor/src/components/Tools/MathTools.tsx @@ -415,10 +415,14 @@ function MathTools({ node }: { node: MathNode }) { export default function MathToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: MathNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/NoteTools.tsx b/packages/editor/src/components/Tools/NoteTools.tsx index 22f342f..a01054a 100644 --- a/packages/editor/src/components/Tools/NoteTools.tsx +++ b/packages/editor/src/components/Tools/NoteTools.tsx @@ -174,10 +174,14 @@ function NoteTools({ node }: { node: StickyNode }) { export default function NoteToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: StickyNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/components/Tools/TableTools.tsx b/packages/editor/src/components/Tools/TableTools.tsx index 15888a7..71da193 100644 --- a/packages/editor/src/components/Tools/TableTools.tsx +++ b/packages/editor/src/components/Tools/TableTools.tsx @@ -650,7 +650,6 @@ function TableTools({ node }: { node: TableNode }) { if (tableCellToolbarElem === null) return false; const tableCell = selectedCells[selectedCells.length - 1]; - if (!tableCell) return false; const tableCellKey = tableCell.getKey(); const tableCellElement = editor.getElementByKey(tableCellKey); if (tableCellElement === null) return false; @@ -937,10 +936,14 @@ function TableTools({ node }: { node: TableNode }) { export default function TableToolbar({ node, - anchorElem = document.querySelector('.editor-container') as HTMLElement, + anchorElem, }: { node: TableNode; anchorElem?: HTMLElement; }) { - return createPortal(, anchorElem); + const [editor] = useLexicalComposerContext(); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return createPortal(, container); } diff --git a/packages/editor/src/nodes/MathNode/MathComponent.tsx b/packages/editor/src/nodes/MathNode/MathComponent.tsx index ded128c..08b5a88 100644 --- a/packages/editor/src/nodes/MathNode/MathComponent.tsx +++ b/packages/editor/src/nodes/MathNode/MathComponent.tsx @@ -142,8 +142,7 @@ export default function MathComponent({ initialValue, nodeKey }: MathComponentPr }); } - function onFocus(event: FocusEvent) { - const mathfield = event.target as MathfieldElement; + function onFocus() { clearSelection(); setSelected(true); const mathVirtualKeyboard = window.mathVirtualKeyboard; @@ -152,33 +151,21 @@ export default function MathComponent({ initialValue, nodeKey }: MathComponentPr if (!element) return; element.ontransitionend = (event) => { if (event.propertyName !== 'transform') return; - mathfield.executeCommand('scrollIntoView'); - const mathTools = document.getElementById('math-tools'); const virtualKeyboard = window.mathVirtualKeyboard; const container = (virtualKeyboard as any)?.element?.firstElementChild as HTMLElement; - if (!container || !mathTools) return; + if (!container) return; document.documentElement.style.setProperty( '--keyboard-inset-height', container.clientHeight + 'px', ); - if (getComputedStyle(mathTools).position === 'fixed') { - const mathToolsBounds = mathTools.getBoundingClientRect(); - const mathfieldBounds = mathfield.getBoundingClientRect(); - const kbdBounds = container.getBoundingClientRect(); - if (mathfieldBounds.bottom > kbdBounds.top - mathToolsBounds.height) { - scrollBy(0, mathfieldBounds.bottom - kbdBounds.top + mathToolsBounds.height + 8); - } - } }; } const onBlur = (event: FocusEvent) => { if (!event.isTrusted) return; - const target = event.target as HTMLElement; - if (target.tagName === 'MATH-FIELD') return; const relatedTarget = event.relatedTarget as HTMLElement | null; - if (relatedTarget?.tagName === 'MATH-FIELD') return; - if (relatedTarget?.closest('.editor-toolbar')) return; + if (!relatedTarget || relatedTarget.tagName === 'MATH-FIELD') return; + if (relatedTarget.closest('.math-toolbar')) return; const mathVirtualKeyboard = window.mathVirtualKeyboard; mathVirtualKeyboard.hide(); document.documentElement.style.setProperty('--keyboard-inset-height', '0px'); @@ -237,7 +224,7 @@ export default function MathComponent({ initialValue, nodeKey }: MathComponentPr mathfield.addEventListener('input', onInput); mathfield.addEventListener('focus', onFocus); - mathfield.addEventListener('blur', onBlur, true); + mathfield.addEventListener('blur', onBlur); mathfield.addEventListener('keydown', onKeydown); mathfield.addEventListener('move-out', onMoveout); mathfield.addEventListener('contextmenu', onContextmenu, { capture: true }); @@ -245,7 +232,7 @@ export default function MathComponent({ initialValue, nodeKey }: MathComponentPr return () => { mathfield.removeEventListener('input', onInput); mathfield.removeEventListener('focus', onFocus); - mathfield.removeEventListener('blur', onBlur, true); + mathfield.removeEventListener('blur', onBlur); mathfield.removeEventListener('keydown', onKeydown); mathfield.removeEventListener('move-out', onMoveout); mathfield.removeEventListener('contextmenu', onContextmenu, { diff --git a/packages/editor/src/nodes/MathNode/index.css b/packages/editor/src/nodes/MathNode/index.css index 44bdaae..827060a 100644 --- a/packages/editor/src/nodes/MathNode/index.css +++ b/packages/editor/src/nodes/MathNode/index.css @@ -39,7 +39,6 @@ math-field::part(menu-toggle) { --keycap-gap: 6px; --_keyboard-height: 295px !important; --keyboard-zindex: 10000; - position: sticky !important; bottom: 0 !important; height: 100vh !important; } diff --git a/packages/editor/src/plugins/ContextMenuPlugin/index.tsx b/packages/editor/src/plugins/ContextMenuPlugin/index.tsx index f566533..bb46cc4 100644 --- a/packages/editor/src/plugins/ContextMenuPlugin/index.tsx +++ b/packages/editor/src/plugins/ContextMenuPlugin/index.tsx @@ -535,7 +535,7 @@ export default function ContextMenuPlugin({ children }: ContextMenuPluginProps): return ( {children} - + {isLink && ( <> diff --git a/packages/editor/src/plugins/FloatingToolbar/index.tsx b/packages/editor/src/plugins/FloatingToolbar/index.tsx index ed9f7e9..183d0df 100644 --- a/packages/editor/src/plugins/FloatingToolbar/index.tsx +++ b/packages/editor/src/plugins/FloatingToolbar/index.tsx @@ -143,7 +143,7 @@ function FloatingToolbar({ return (
@@ -221,10 +221,13 @@ function useFloatingToolbar(editor: LexicalEditor, anchorElem: HTMLElement) { } export default function FloatingTextFormatToolbarPlugin({ - anchorElem = document.body, + anchorElem, }: { anchorElem?: HTMLElement; }) { const [editor] = useLexicalComposerContext(); - return useFloatingToolbar(editor, anchorElem); + const rootElement = editor.getRootElement(); + const container = + anchorElem ?? rootElement?.closest('.editor-container') ?? document.body; + return useFloatingToolbar(editor, container); } diff --git a/packages/editor/src/plugins/ToolbarPlugin/NodeTools.tsx b/packages/editor/src/plugins/ToolbarPlugin/NodeTools.tsx index a44777d..08125fd 100644 --- a/packages/editor/src/plugins/ToolbarPlugin/NodeTools.tsx +++ b/packages/editor/src/plugins/ToolbarPlugin/NodeTools.tsx @@ -27,8 +27,11 @@ import HrTools from '@repo/editor/components/Tools/HrTools'; import AttachmentToolbar from '@repo/editor/components/Tools/AttachmentTools'; import { cn } from '@repo/ui/lib/utils'; import { useSelector } from '@repo/editor/store'; +import { Portal } from '@repo/ui/components/portal'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; export default function NodeTools() { + const [editor] = useLexicalComposerContext(); const blockType = useSelector((state) => state.blockType); const selectedNode = useSelector((state) => state.selectedNode); const isTable = useSelector((state) => state.isTable); @@ -77,12 +80,21 @@ export default function NodeTools() { {showHrTools && } {showAttachmentTools && } {showTextFormatTools && ( - + + )} + {showTextFormatTools && ( + + editor.getRootElement()?.closest('.editor-container') ?? document.body + } + > + + )} ); diff --git a/packages/embed-pdf/eslint.config.mjs b/packages/embed-pdf/eslint.config.mjs new file mode 100644 index 0000000..6d662d5 --- /dev/null +++ b/packages/embed-pdf/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config/react-internal" + +/** @type {import("eslint").Linter.Config} */ +export default config diff --git a/packages/embed-pdf/package.json b/packages/embed-pdf/package.json new file mode 100644 index 0000000..1b6e484 --- /dev/null +++ b/packages/embed-pdf/package.json @@ -0,0 +1,61 @@ +{ + "name": "@repo/embed-pdf", + "version": "0.0.0", + "private": true, + "scripts": { + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@embedpdf/core": "^2.8.0", + "@embedpdf/engines": "^2.8.0", + "@embedpdf/models": "^2.8.0", + "@embedpdf/pdfium": "^2.8.0", + "@embedpdf/plugin-annotation": "^2.8.0", + "@embedpdf/plugin-capture": "^2.8.0", + "@embedpdf/plugin-commands": "^2.8.0", + "@embedpdf/plugin-document-manager": "^2.8.0", + "@embedpdf/plugin-export": "^2.8.0", + "@embedpdf/plugin-fullscreen": "^2.8.0", + "@embedpdf/plugin-history": "^2.8.0", + "@embedpdf/plugin-i18n": "^2.8.0", + "@embedpdf/plugin-interaction-manager": "^2.8.0", + "@embedpdf/plugin-pan": "^2.8.0", + "@embedpdf/plugin-print": "^2.8.0", + "@embedpdf/plugin-redaction": "^2.8.0", + "@embedpdf/plugin-render": "^2.8.0", + "@embedpdf/plugin-rotate": "^2.8.0", + "@embedpdf/plugin-scroll": "^2.8.0", + "@embedpdf/plugin-search": "^2.8.0", + "@embedpdf/plugin-selection": "^2.8.0", + "@embedpdf/plugin-spread": "^2.8.0", + "@embedpdf/plugin-thumbnail": "^2.8.0", + "@embedpdf/plugin-tiling": "^2.8.0", + "@embedpdf/plugin-ui": "^2.8.0", + "@embedpdf/plugin-view-manager": "^2.8.0", + "@embedpdf/plugin-viewport": "^2.8.0", + "@embedpdf/plugin-zoom": "^2.8.0", + "@embedpdf/utils": "^2.8.0", + "@repo/ui": "workspace:*" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@tailwindcss/postcss": "^4.2.1", + "@turbo/gen": "^2.8.14", + "@types/node": "^25.2.1", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "eslint": "^10.0.0", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0" + }, + "peerDependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "exports": { + "./viewer": "./src/viewer.tsx" + } +} diff --git a/packages/embed-pdf/src/components/command-button.tsx b/packages/embed-pdf/src/components/command-button.tsx new file mode 100644 index 0000000..822ac82 --- /dev/null +++ b/packages/embed-pdf/src/components/command-button.tsx @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useCommand } from '@embedpdf/plugin-commands/react'; +import { useRegisterAnchor } from '@embedpdf/plugin-ui/react'; +import { cn } from '@repo/ui/lib/utils'; +import { ToolbarButton } from './ui'; +import * as Icons from './icons'; + +type CommandButtonProps = { + commandId: string; + documentId: string; + variant?: 'icon' | 'text' | 'icon-text' | 'tab'; + itemId?: string; // Unique ID for this button instance (for anchor registry) + className?: string; +}; + +/** + * A button that executes a command when clicked. + * Uses the useCommand hook to get the command state and execution function. + * The icon is automatically retrieved from the command definition. + * + * Automatically registers itself with the anchor registry so menus can anchor to it. + */ +export function CommandButton({ + commandId, + documentId, + variant = 'icon', + itemId, + className, +}: CommandButtonProps) { + const command = useCommand(commandId, documentId); + // Register this button with the anchor registry if itemId is provided + // This allows menus to anchor to it when opened via UI state changes + const finalItemId = itemId || commandId; + const anchorRef = useRegisterAnchor(documentId, finalItemId); + + if (!command) return null; + + // Get the icon component from the command's icon property + // Add 'Icon' suffix to match the exported icon component names + const iconName = command.icon ? `${command.icon}Icon` : null; + const IconComponent = iconName ? Icons[iconName as keyof typeof Icons] : null; + + // Get iconProps if available (for dynamic colors, etc.) + const iconProps = command.iconProps || {}; + + return ( + command.execute()} + isActive={command.active} + disabled={command.disabled || !command.visible} + aria-label={command.label} + title={command.label} + className={className} + > + {variant === 'text' ? ( + {command.label} + ) : variant === 'icon-text' ? ( + <> + {IconComponent && ( + + )} + {command.label} + + ) : variant === 'tab' ? ( + {command.label} + ) : // Default: icon only + IconComponent ? ( + + ) : ( + {command.label} + )} + + ); +} diff --git a/packages/embed-pdf/src/components/command-tab-button.tsx b/packages/embed-pdf/src/components/command-tab-button.tsx new file mode 100644 index 0000000..227045e --- /dev/null +++ b/packages/embed-pdf/src/components/command-tab-button.tsx @@ -0,0 +1,75 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useCommand } from '@embedpdf/plugin-commands/react'; +import { useRegisterAnchor } from '@embedpdf/plugin-ui/react'; +import { cn } from '@repo/ui/lib/utils'; +import * as Icons from './icons'; + +type CommandTabButtonProps = { + commandId: string; + documentId: string; + itemId?: string; // Unique ID for this button instance (for anchor registry) + variant?: 'text' | 'icon'; +}; + +/** + * A tab button that executes a command when clicked. + * Styled to match the modern tab design with rounded background and active state. + * + * Automatically registers itself with the anchor registry so menus can anchor to it. + */ +export function CommandTabButton({ + commandId, + documentId, + itemId, + variant = 'text', +}: CommandTabButtonProps) { + const command = useCommand(commandId, documentId); + + // Register this button with the anchor registry if itemId is provided + const finalItemId = itemId || commandId; + const anchorRef = useRegisterAnchor(documentId, finalItemId); + + if (!command || !command.visible) return null; + + // Get the icon component from the command's icon property + const iconName = command.icon ? `${command.icon}Icon` : null; + const IconComponent = iconName ? Icons[iconName as keyof typeof Icons] : null; + const iconProps = command.iconProps || {}; + + const baseClasses = `rounded transition-colors disabled:cursor-not-allowed disabled:opacity-50`; + const activeClasses = command.active + ? 'bg-accent text-accent-foreground shadow-sm' + : 'text-muted-foreground hover:text-foreground'; + + const sizeClasses = variant === 'icon' ? 'p-1.5' : 'px-4 py-1'; + + return ( + + ); +} diff --git a/packages/embed-pdf/src/components/document-password-prompt.tsx b/packages/embed-pdf/src/components/document-password-prompt.tsx new file mode 100644 index 0000000..3682275 --- /dev/null +++ b/packages/embed-pdf/src/components/document-password-prompt.tsx @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useState } from 'react'; +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; +import { PdfErrorCode } from '@embedpdf/models'; +import { AlertIcon } from './icons'; +import { DocumentState } from '@embedpdf/core'; + +interface DocumentPasswordPromptProps { + documentState: DocumentState; +} + +export function DocumentPasswordPrompt({ documentState }: DocumentPasswordPromptProps) { + const { provides } = useDocumentManagerCapability(); + const [password, setPassword] = useState(''); + const [isRetrying, setIsRetrying] = useState(false); + + if (!documentState) return null; + + const { name, errorCode, passwordProvided } = documentState; + + // Clean logic using state + error code! + const isPasswordError = errorCode === PdfErrorCode.Password; + const isPasswordRequired = isPasswordError && !passwordProvided; + const isPasswordIncorrect = isPasswordError && passwordProvided; + + if (!isPasswordError) { + return ( +
+
+ +

Error loading document

+

+ {documentState.error || 'An unknown error occurred'} +

+ {errorCode &&

Error Code: {errorCode}

} + +
+
+ ); + } + + const handleRetry = async () => { + if (!provides || !password.trim()) return; + setIsRetrying(true); + + const task = provides.retryDocument(documentState.id, { password }); + task.wait( + () => { + setPassword(''); + setIsRetrying(false); + }, + (error) => { + console.error('Retry failed:', error); + setIsRetrying(false); + }, + ); + }; + + return ( +
+
+
+
+

Password Required

+ {name &&

{name}

} +
+ +
+ + {/* Different message based on state */} +

+ {isPasswordRequired && + 'This document is password protected. Please enter the password to open it.'} + {isPasswordIncorrect && 'The password you entered was incorrect. Please try again.'} +

+ +
+ + setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isRetrying && password.trim() && handleRetry()} + disabled={isRetrying} + placeholder="Enter document password" + className="mt-1 block w-full rounded-md border px-3 py-2" + autoFocus + /> +
+ + {/* Show error feedback for incorrect password */} + {isPasswordIncorrect && ( +
+

Incorrect password. Please check and try again.

+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/packages/embed-pdf/src/components/empty-state.tsx b/packages/embed-pdf/src/components/empty-state.tsx new file mode 100644 index 0000000..bc75811 --- /dev/null +++ b/packages/embed-pdf/src/components/empty-state.tsx @@ -0,0 +1,70 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; + +interface EmptyStateProps { + onDocumentOpened?: (documentId: string) => void; +} + +export function EmptyState({ onDocumentOpened }: EmptyStateProps) { + const { provides } = useDocumentManagerCapability(); + + const handleOpenFile = () => { + const openTask = provides?.openFileDialog(); + openTask?.wait( + (result) => { + onDocumentOpened?.(result.documentId); + }, + (error) => { + console.error('Open file failed:', error); + }, + ); + }; + + return ( +
+
+
+
+ + + + +
+
+

No Documents Open

+

+ Get started by opening a PDF document. You can view multiple documents at once using tabs. +

+ +
Supported format: PDF
+
+
+ ); +} diff --git a/packages/embed-pdf/src/components/icons/index.tsx b/packages/embed-pdf/src/components/icons/index.tsx new file mode 100644 index 0000000..6a1ebb9 --- /dev/null +++ b/packages/embed-pdf/src/components/icons/index.tsx @@ -0,0 +1,1258 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +type IconProps = { + className?: string; + title?: string; + style?: React.CSSProperties; +}; + +export function DocumentIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function CloseIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function PlusIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function HandIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function SearchMinusIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function SearchPlusIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function ChevronDownIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ZoomChevronDownIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function FitPageIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function FitWidthIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function MarqueeIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + ); +} + +export function RotateRightIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function RotateLeftIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function SinglePageIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function BookOpenIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function SettingsIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + ); +} + +export function PrintIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function DownloadIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ScreenshotIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + ); +} + +export function FullscreenIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function FullscreenExitIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function MenuIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function MenuDotsIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function AlertIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function RefreshIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function CheckIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function SearchIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ThumbnailsIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ChevronLeftIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ChevronRightIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function TextIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function PenIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function CircleIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function SquareIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function ArrowIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function HighlightIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function LineIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function PolygonIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function SquigglyIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function StrikethroughIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function UnderlineIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function ZigzagIcon({ className, title, style }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +export function PolylineIcon({ className, title, style }: IconProps) { + return ; +} + +export function ItalicIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + ); +} + +export function SquaresIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function TrashIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + ); +} + +export function UndoIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function RedoIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function RedactTextIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function RedactAreaIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function PhotoIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function ArrowBackUpIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function ArrowForwardUpIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function PointerIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + ); +} + +export function SidebarIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} + +export function CopyIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} diff --git a/packages/embed-pdf/src/components/loading-spinner.tsx b/packages/embed-pdf/src/components/loading-spinner.tsx new file mode 100644 index 0000000..3f9e8fe --- /dev/null +++ b/packages/embed-pdf/src/components/loading-spinner.tsx @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +type LoadingSpinnerProps = { + size?: 'sm' | 'md' | 'lg'; + message?: string; + className?: string; +}; + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-8 w-8', +}; + +export function LoadingSpinner({ size = 'md', message, className = '' }: LoadingSpinnerProps) { + return ( +
+ + + + + {message && {message}} +
+ ); +} diff --git a/packages/embed-pdf/src/components/outline-sidebar.tsx b/packages/embed-pdf/src/components/outline-sidebar.tsx new file mode 100644 index 0000000..676e516 --- /dev/null +++ b/packages/embed-pdf/src/components/outline-sidebar.tsx @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +type OutlineSidebarProps = { + documentId: string; +}; + +/** + * Placeholder Outline Sidebar + * + * This component will eventually render the document outline / table of contents. + * For now it simply renders a placeholder so that we can test tabbed panels. + */ +export function OutlineSidebar({ documentId }: OutlineSidebarProps) { + return ( +
+
Outline (Coming Soon)
+

+ Placeholder outline for document{' '} + {documentId}. +

+

+ Implement the actual outline sidebar by replacing this placeholder with a component that + reads the document outline from the appropriate plugin. +

+
+ ); +} diff --git a/packages/embed-pdf/src/components/page-controls.tsx b/packages/embed-pdf/src/components/page-controls.tsx new file mode 100644 index 0000000..22f7930 --- /dev/null +++ b/packages/embed-pdf/src/components/page-controls.tsx @@ -0,0 +1,146 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useViewportCapability } from '@embedpdf/plugin-viewport/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from './icons'; + +type PageControlsProps = { + documentId: string; +}; + +export function PageControls({ documentId }: PageControlsProps) { + const { provides: viewport } = useViewportCapability(); + const { + provides: scroll, + state: { currentPage, totalPages }, + } = useScroll(documentId); + const [isVisible, setIsVisible] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const hideTimeoutRef = useRef(null); + const [inputValue, setInputValue] = useState(currentPage.toString()); + + useEffect(() => { + setInputValue(currentPage.toString()); + }, [currentPage]); + + const startHideTimer = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + hideTimeoutRef.current = setTimeout(() => { + if (!isHovering) { + setIsVisible(false); + } + }, 4000); + }, [isHovering]); + + useEffect(() => { + if (!viewport) return; + + return viewport.onScrollActivity((activity) => { + if (activity.documentId === documentId) { + setIsVisible(true); + startHideTimer(); + } + }); + }, [viewport, startHideTimer]); + + useEffect(() => { + return () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = () => { + setIsHovering(true); + setIsVisible(true); + }; + + const handleMouseLeave = () => { + setIsHovering(false); + startHideTimer(); + }; + + const handlePageChange = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const pageStr = formData.get('page') as string; + const page = parseInt(pageStr); + + if (!isNaN(page) && page >= 1 && page <= totalPages) { + scroll?.scrollToPage?.({ + pageNumber: page, + }); + } + }; + + const handlePreviousPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.currentTarget.blur(); + if (currentPage > 1) { + scroll?.scrollToPreviousPage(); + } + }; + + const handleNextPage = (e: React.MouseEvent) => { + e.preventDefault(); + e.currentTarget.blur(); + if (currentPage < totalPages) { + scroll?.scrollToNextPage(); + } + }; + + return ( +
+
+ {/* Previous Button */} + + + {/* Page Input */} +
+ { + const value = e.target.value.replace(/[^0-9]/g, ''); + setInputValue(value); + }} + className="h-7 w-10 bg-accent text-accent-foreground rounded border border-border px-1 text-center text-sm focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> + / + {totalPages} +
+ + {/* Next Button */} + +
+
+ ); +} diff --git a/packages/embed-pdf/src/components/search-sidebar.tsx b/packages/embed-pdf/src/components/search-sidebar.tsx new file mode 100644 index 0000000..b330bb8 --- /dev/null +++ b/packages/embed-pdf/src/components/search-sidebar.tsx @@ -0,0 +1,255 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useSearch } from '@embedpdf/plugin-search/react'; +import { useScrollCapability } from '@embedpdf/plugin-scroll/react'; +import { useState, useRef, useEffect } from 'react'; +import { MatchFlag } from '@embedpdf/models'; +import { SearchResult } from '@embedpdf/models'; +import { SearchIcon, CloseIcon, ChevronRightIcon, ChevronLeftIcon } from './icons'; + +const HitLine = ({ + hit, + onClick, + active, +}: { + hit: SearchResult; + onClick: () => void; + active: boolean; +}) => { + const ref = useRef(null); + + useEffect(() => { + if (active && ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [active]); + + return ( + + ); +}; + +type SearchSidebarProps = { + documentId: string; + onClose?: () => void; +}; + +export function SearchSidebar({ documentId, onClose }: SearchSidebarProps) { + const { state, provides } = useSearch(documentId); + const { provides: scroll } = useScrollCapability(); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + + // Sync inputValue with persisted state.query when state loads + useEffect(() => { + setInputValue(state.query || ''); + }, [state.query, documentId]); // Include documentId to reset on tab change + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [provides]); + + useEffect(() => { + if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0 && !state.loading) { + scrollToItem(state.activeResultIndex); + } + }, [state.activeResultIndex, state.loading, state.query, state.flags]); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setInputValue(value); + + // Trigger search immediately on user input + if (value === '') { + provides?.stopSearch(); + } else { + provides?.searchAllPages(value); + } + }; + + const handleFlagChange = (flag: MatchFlag, checked: boolean) => { + if (checked) { + provides?.setFlags([...state.flags, flag]); + } else { + provides?.setFlags(state.flags.filter((f) => f !== flag)); + } + }; + + const clearInput = () => { + setInputValue(''); + provides?.stopSearch(); + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const scrollToItem = (index: number) => { + const item = state.results[index]; + if (!item) return; + + const minCoordinates = item.rects.reduce( + (min, rect) => ({ + x: Math.min(min.x, rect.origin.x), + y: Math.min(min.y, rect.origin.y), + }), + { x: Infinity, y: Infinity }, + ); + + scroll?.forDocument(documentId).scrollToPage({ + pageNumber: item.pageIndex + 1, + pageCoordinates: minCoordinates, + }); + }; + + const groupByPage = (results: typeof state.results) => { + return results.reduce>( + (map, r, i) => { + (map[r.pageIndex] ??= []).push({ hit: r, index: i }); + return map; + }, + {}, + ); + }; + + if (!provides) return null; + + const grouped = groupByPage(state.results); + + return ( +
+ {/* Header */} +
+

Search

+ +
+ + {/* Search Input */} +
+
+
+ +
+ + {inputValue && ( + + )} +
+ + {/* Options */} +
+ + +
+ + {/* Results count and navigation */} + {state.active && !state.loading && state.total > 0 && ( +
+ {state.total} results found + {state.total > 1 && ( +
+ + +
+ )} +
+ )} +
+ + {/* Results */} +
+ {state.loading ? ( +
+
+
+ ) : ( +
+ {Object.entries(grouped).map(([page, hits]) => ( +
+
+ Page {Number(page) + 1} +
+
+ {hits.map(({ hit, index }) => ( + provides.goToResult(index)} + /> + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/embed-pdf/src/components/tab-context-menu.tsx b/packages/embed-pdf/src/components/tab-context-menu.tsx new file mode 100644 index 0000000..80fe3ba --- /dev/null +++ b/packages/embed-pdf/src/components/tab-context-menu.tsx @@ -0,0 +1,91 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useEffect, useRef } from 'react'; +import { DocumentState } from '@embedpdf/core'; +import { useViewManagerCapability, useAllViews } from '@embedpdf/plugin-view-manager/react'; + +interface TabContextMenuProps { + documentState: DocumentState; + currentViewId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +export function TabContextMenu({ + documentState, + currentViewId, + position, + onClose, +}: TabContextMenuProps) { + const menuRef = useRef(null); + const { provides: viewManager } = useViewManagerCapability(); + const allViews = useAllViews(); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + + const handleOpenInNewView = () => { + if (!viewManager) return; + + const newViewId = viewManager.createView(); + viewManager.addDocumentToView(newViewId, documentState.id); + viewManager.removeDocumentFromView(currentViewId, documentState.id); + viewManager.setFocusedView(newViewId); + onClose(); + }; + + const handleMoveToView = (targetViewId: string) => { + if (!viewManager) return; + viewManager.moveDocumentBetweenViews(currentViewId, targetViewId, documentState.id); + viewManager.setFocusedView(targetViewId); + viewManager.setViewActiveDocument(targetViewId, documentState.id); + onClose(); + }; + + const otherViews = allViews.filter((v) => v.id !== currentViewId); + + return ( +
+
+ + + {otherViews.length > 0 && ( + <> +
+
+ Move to View +
+ {otherViews.map((view, index) => ( + + ))} + + )} +
+
+ ); +} diff --git a/packages/embed-pdf/src/components/thumbnails-sidebar.tsx b/packages/embed-pdf/src/components/thumbnails-sidebar.tsx new file mode 100644 index 0000000..f5c39f3 --- /dev/null +++ b/packages/embed-pdf/src/components/thumbnails-sidebar.tsx @@ -0,0 +1,82 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ThumbnailsPane, ThumbImg } from '@embedpdf/plugin-thumbnail/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; + +type ThumbnailsSidebarProps = { + documentId: string; + onClose?: () => void; +}; + +export function ThumbnailsSidebar({ documentId }: ThumbnailsSidebarProps) { + const { state, provides } = useScroll(documentId); + + return ( +
+ {/* Thumbnails */} +
+ + {(m) => ( +
{ + provides?.scrollToPage?.({ + pageNumber: m.pageIndex + 1, + }); + }} + > +
+ +
+
+ {m.pageIndex + 1} +
+
+ )} +
+
+
+ ); +} diff --git a/packages/embed-pdf/src/components/ui/dropdown-menu.tsx b/packages/embed-pdf/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..061683c --- /dev/null +++ b/packages/embed-pdf/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ReactNode } from 'react'; + +type DropdownMenuProps = { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + className?: string; +}; + +export function DropdownMenu({ isOpen, onClose, children, className = '' }: DropdownMenuProps) { + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Menu */} +
+ {children} +
+ + ); +} + +type DropdownItemProps = { + onClick: () => void; + icon?: ReactNode; + children: ReactNode; + isActive?: boolean; +}; + +export function DropdownItem({ onClick, icon, children, isActive = false }: DropdownItemProps) { + return ( + + ); +} + +type DropdownSectionProps = { + title?: string; + children: ReactNode; +}; + +export function DropdownSection({ title, children }: DropdownSectionProps) { + return ( + <> + {title && ( +
+ {title} +
+ )} + {children} + + ); +} + +export function DropdownDivider() { + return
; +} diff --git a/packages/embed-pdf/src/components/ui/index.ts b/packages/embed-pdf/src/components/ui/index.ts new file mode 100644 index 0000000..f00c114 --- /dev/null +++ b/packages/embed-pdf/src/components/ui/index.ts @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export * from './toolbar-button'; +export * from './dropdown-menu'; +export * from './toolbar-divider'; diff --git a/packages/embed-pdf/src/components/ui/toolbar-button.tsx b/packages/embed-pdf/src/components/ui/toolbar-button.tsx new file mode 100644 index 0000000..6024ac0 --- /dev/null +++ b/packages/embed-pdf/src/components/ui/toolbar-button.tsx @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ReactNode, forwardRef } from 'react'; +import { cn } from '@repo/ui/lib/utils'; + +type ToolbarButtonProps = { + onClick?: () => void; + isActive?: boolean; + disabled?: boolean; + children: ReactNode; + 'aria-label'?: string; + title?: string; + className?: string; +}; + +export const ToolbarButton = forwardRef( + ( + { + onClick, + isActive = false, + disabled = false, + children, + 'aria-label': ariaLabel, + title, + className = '', + }, + ref, + ) => { + const baseClasses = isActive + ? 'border-none bg-accent text-accent-foreground shadow ring ring-accent' + : 'text-muted-foreground hover:bg-accent/75 hover:text-accent-foreground hover:ring hover:ring-accent'; + + const disabledClasses = disabled + ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-600 hover:ring-0' + : ''; + + const mergedClasses = cn( + 'rounded p-1.5 transition-colors', + baseClasses, + disabledClasses, + className, + ); + + return ( + + ); + }, +); + +ToolbarButton.displayName = 'ToolbarButton'; diff --git a/packages/embed-pdf/src/components/ui/toolbar-divider.tsx b/packages/embed-pdf/src/components/ui/toolbar-divider.tsx new file mode 100644 index 0000000..7a39c32 --- /dev/null +++ b/packages/embed-pdf/src/components/ui/toolbar-divider.tsx @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +type ToolbarDividerProps = { + orientation?: 'vertical' | 'horizontal'; + className?: string; +}; + +export function ToolbarDivider({ orientation = 'vertical', className = '' }: ToolbarDividerProps) { + const dividerClasses = + orientation === 'horizontal' + ? `my-1 h-px w-full bg-border ${className}` + : `mx-1 h-6 w-px bg-border ${className}`; + + return
; +} diff --git a/packages/embed-pdf/src/components/zoom-toolbar.tsx b/packages/embed-pdf/src/components/zoom-toolbar.tsx new file mode 100644 index 0000000..414d801 --- /dev/null +++ b/packages/embed-pdf/src/components/zoom-toolbar.tsx @@ -0,0 +1,167 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { ZoomMode } from '@embedpdf/plugin-zoom'; +import { useState } from 'react'; +import { + ChevronDownIcon, + FitPageIcon, + FitWidthIcon, + SearchMinusIcon, + SearchPlusIcon, + MarqueeIcon, +} from './icons'; +import { DropdownMenu, DropdownItem, DropdownDivider } from './ui'; + +interface ZoomToolbarProps { + documentId: string; +} + +interface ZoomPreset { + value: number; + label: string; +} + +interface ZoomModeItem { + value: ZoomMode; + label: string; +} + +const ZOOM_PRESETS: ZoomPreset[] = [ + { value: 0.5, label: '50%' }, + { value: 1, label: '100%' }, + { value: 1.5, label: '150%' }, + { value: 2, label: '200%' }, + { value: 4, label: '400%' }, + { value: 8, label: '800%' }, +]; + +const ZOOM_MODES: ZoomModeItem[] = [ + { value: ZoomMode.FitPage, label: 'Fit to Page' }, + { value: ZoomMode.FitWidth, label: 'Fit to Width' }, +]; + +export function ZoomToolbar({ documentId }: ZoomToolbarProps) { + const { state, provides } = useZoom(documentId); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + if (!provides) return null; + + const zoomPercentage = Math.round(state.currentZoomLevel * 100); + + const handleZoomIn = () => { + provides.zoomIn(); + setIsMenuOpen(false); + }; + + const handleZoomOut = () => { + provides.zoomOut(); + setIsMenuOpen(false); + }; + + const handleSelectZoom = (value: number | ZoomMode) => { + provides.requestZoom(value); + setIsMenuOpen(false); + }; + + const handleToggleMarquee = () => { + provides.toggleMarqueeZoom(); + setIsMenuOpen(false); + }; + + return ( +
+
+ {/* Zoom Out Button */} + + + {/* Zoom Percentage Display */} + + + {/* Zoom In Button */} + +
+ + setIsMenuOpen(false)} className="w-48"> + } + > + Zoom In + + } + > + Zoom Out + + + + + {/* Zoom Presets */} + {ZOOM_PRESETS.map(({ value, label }) => ( + handleSelectZoom(value)} + isActive={Math.abs(state.currentZoomLevel - value) < 0.01} + > + {label} + + ))} + + + + {/* Zoom Modes */} + {ZOOM_MODES.map(({ value, label }) => ( + handleSelectZoom(value)} + icon={ + value === ZoomMode.FitPage ? ( + + ) : ( + + ) + } + isActive={state.zoomLevel === value} + > + {label} + + ))} + + + + } + isActive={state.isMarqueeZoomActive} + > + Marquee Zoom + + +
+ ); +} diff --git a/packages/embed-pdf/src/config/commands.ts b/packages/embed-pdf/src/config/commands.ts new file mode 100644 index 0000000..e28aa90 --- /dev/null +++ b/packages/embed-pdf/src/config/commands.ts @@ -0,0 +1,1228 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Command } from '@embedpdf/plugin-commands/react'; +import { CapturePlugin } from '@embedpdf/plugin-capture/react'; +import { ZoomMode, ZoomPlugin } from '@embedpdf/plugin-zoom/react'; +import { PanPlugin } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPlugin } from '@embedpdf/plugin-spread/react'; +import { RotatePlugin } from '@embedpdf/plugin-rotate/react'; +import { + ANNOTATION_PLUGIN_ID, + AnnotationPlugin, + getToolDefaultsById, +} from '@embedpdf/plugin-annotation/react'; +import { + REDACTION_PLUGIN_ID, + RedactionMode, + RedactionPlugin, +} from '@embedpdf/plugin-redaction/react'; +import { PrintPlugin } from '@embedpdf/plugin-print/react'; +import { ExportPlugin } from '@embedpdf/plugin-export/react'; +import { DocumentManagerPlugin } from '@embedpdf/plugin-document-manager/react'; +import { HISTORY_PLUGIN_ID, HistoryPlugin } from '@embedpdf/plugin-history/react'; +import { State } from './types'; +import { isSidebarOpen, isToolbarOpen, UI_PLUGIN_ID, UIPlugin } from '@embedpdf/plugin-ui'; +import { ScrollPlugin, ScrollStrategy } from '@embedpdf/plugin-scroll/react'; +import { InteractionManagerPlugin } from '@embedpdf/plugin-interaction-manager'; +import { SelectionPlugin } from '@embedpdf/plugin-selection/react'; + +export const commands: Record> = { + // ───────────────────────────────────────────────────────── + // Zoom Commands + // ───────────────────────────────────────────────────────── + 'zoom:in': { + id: 'zoom:in', + labelKey: 'zoom.in', + label: 'Zoom In', + icon: 'SearchPlus', + shortcuts: ['Ctrl+=', 'Meta+=', 'Ctrl+NumpadAdd', 'Meta+NumpadAdd'], + categories: ['view'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.zoomIn(); + }, + }, + + 'zoom:out': { + id: 'zoom:out', + labelKey: 'zoom.out', + label: 'Zoom Out', + icon: 'SearchMinus', + shortcuts: ['Ctrl+-', 'Meta+-', 'Ctrl+NumpadSubtract', 'Meta+NumpadSubtract'], + categories: ['view'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.zoomOut(); + }, + }, + + 'zoom:fit-page': { + id: 'zoom:fit-page', + labelKey: 'zoom.fitPage', + label: 'Fit to Page', + icon: 'FitPage', + shortcuts: ['Ctrl+0', 'Meta+0'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(ZoomMode.FitPage); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === ZoomMode.FitPage, + }, + + 'zoom:fit-width': { + id: 'zoom:fit-width', + labelKey: 'zoom.fitWidth', + label: 'Fit to Width', + icon: 'FitWidth', + shortcuts: ['Ctrl+1', 'Meta+1'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(ZoomMode.FitWidth); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === ZoomMode.FitWidth, + }, + + 'zoom:marquee': { + id: 'zoom:marquee', + labelKey: 'zoom.marquee', + label: 'Marquee Zoom', + icon: 'Marquee', + shortcuts: ['Ctrl+M', 'Meta+M'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.toggleMarqueeZoom(); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.isMarqueeZoomActive ?? false, + }, + + 'zoom:25': { + id: 'zoom:25', + labelKey: 'zoom.25', + label: '25%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(0.25); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 0.25, + }, + + 'zoom:50': { + id: 'zoom:50', + labelKey: 'zoom.50', + label: '50%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(0.5); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 0.5, + }, + + 'zoom:100': { + id: 'zoom:100', + labelKey: 'zoom.100', + label: '100%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(1); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 1, + }, + + 'zoom:125': { + id: 'zoom:125', + labelKey: 'zoom.125', + label: '125%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(1.25); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 1.25, + }, + + 'zoom:150': { + id: 'zoom:150', + labelKey: 'zoom.150', + label: '150%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(1.5); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 1.5, + }, + + 'zoom:200': { + id: 'zoom:200', + labelKey: 'zoom.200', + label: '200%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(2); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 2, + }, + + 'zoom:400': { + id: 'zoom:400', + labelKey: 'zoom.400', + label: '400%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(4); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 4, + }, + + 'zoom:800': { + id: 'zoom:800', + labelKey: 'zoom.800', + label: '800%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(8); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 8, + }, + + 'zoom:1600': { + id: 'zoom:1600', + labelKey: 'zoom.1600', + label: '1600%', + action: ({ registry, documentId }) => { + const zoom = registry.getPlugin('zoom')?.provides(); + if (!zoom) return; + + const scope = zoom.forDocument(documentId); + scope.requestZoom(16); + }, + active: ({ state, documentId }) => + state.plugins['zoom']?.documents[documentId]?.zoomLevel === 16, + }, + + 'zoom:toggle-menu': { + id: 'zoom:toggle-menu', + labelKey: 'zoom.menu', + label: 'Zoom Menu', + icon: 'ZoomChevronDown', + iconProps: { + className: 'h-3.5 w-3.5', + }, + categories: ['tools'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + const scope = ui.forDocument(documentId); + scope.toggleMenu('zoom-menu', 'zoom:toggle-menu', 'zoom-menu-button'); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['zoom-menu'] !== undefined; + }, + }, + + 'zoom:toggle-menu-mobile': { + id: 'zoom:toggle-menu-mobile', + labelKey: 'zoom.menu', + label: 'Zoom Menu', + icon: 'SearchPlus', + categories: ['tools'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + const scope = ui.forDocument(documentId); + scope.toggleMenu('zoom-menu', 'zoom:toggle-menu-mobile', 'zoom-menu-button'); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['zoom-menu'] !== undefined; + }, + }, + + // ───────────────────────────────────────────────────────── + // Pan Command + // ───────────────────────────────────────────────────────── + 'pan:toggle': { + id: 'pan:toggle', + labelKey: 'pan.toggle', + label: 'Toggle Pan Mode', + icon: 'Hand', + shortcuts: ['h'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const pan = registry.getPlugin('pan')?.provides(); + if (!pan) return; + + const scope = pan.forDocument(documentId); + scope.togglePan(); + }, + active: ({ state, documentId }) => + state.plugins['pan']?.documents[documentId]?.isPanMode ?? false, + }, + + // ───────────────────────────────────────────────────────── + // Pointer Command + // ───────────────────────────────────────────────────────── + 'pointer:toggle': { + id: 'pointer:toggle', + labelKey: 'pointer.toggle', + label: 'Toggle Pointer Mode', + icon: 'Pointer', + shortcuts: ['p'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const pointer = registry + .getPlugin('interaction-manager') + ?.provides(); + if (!pointer) return; + + const scope = pointer.forDocument(documentId); + scope.activate('pointerMode'); + }, + active: ({ state, documentId }) => + state.plugins['interaction-manager']?.documents[documentId]?.activeMode === 'pointerMode', + }, + + // ───────────────────────────────────────────────────────── + // Capture Command + // ───────────────────────────────────────────────────────── + 'capture:screenshot': { + id: 'capture:screenshot', + labelKey: 'capture.screenshot', + label: 'Screenshot', + icon: 'Screenshot', + shortcuts: ['Ctrl+Shift+S', 'Meta+Shift+S'], + categories: ['tools'], + action: ({ registry, documentId }) => { + const capture = registry.getPlugin('capture')?.provides(); + if (!capture) return; + + const scope = capture.forDocument(documentId); + if (scope.isMarqueeCaptureActive()) { + scope.disableMarqueeCapture(); + } else { + scope.enableMarqueeCapture(); + } + }, + active: ({ state, documentId }) => + state.plugins['interaction-manager'].documents[documentId]?.activeMode === 'marqueeCapture', + }, + + // ───────────────────────────────────────────────────────── + // Document Commands + // ───────────────────────────────────────────────────────── + 'document:menu': { + id: 'document:menu', + labelKey: 'document.menu', + label: 'Document Menu', + icon: 'Menu', + categories: ['document'], + action: ({ registry, documentId }) => { + // Toggle the document menu via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleMenu( + 'document-menu', + 'document:menu', + 'document-menu-button', // Must match the item ID in ui-schema + ); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['document-menu'] !== undefined; + }, + }, + + 'document:open': { + id: 'document:open', + labelKey: 'document.open', + label: 'Open', + icon: 'Document', + shortcuts: ['Ctrl+O', 'Meta+O'], + categories: ['document'], + action: ({ registry }) => { + const docManager = registry.getPlugin('document-manager')?.provides(); + docManager?.openFileDialog(); + }, + }, + + 'document:close': { + id: 'document:close', + labelKey: 'document.close', + label: 'Close', + icon: 'Close', + shortcuts: ['Ctrl+W', 'Meta+W'], + categories: ['document'], + action: ({ registry, documentId }) => { + const docManager = registry.getPlugin('document-manager')?.provides(); + docManager?.closeDocument(documentId); + }, + }, + + 'document:print': { + id: 'document:print', + labelKey: 'document.print', + label: 'Print', + icon: 'Print', + shortcuts: ['Ctrl+P', 'Meta+P'], + categories: ['document'], + action: ({ registry, documentId }) => { + const print = registry.getPlugin('print')?.provides(); + print?.forDocument(documentId).print(); + }, + }, + + 'document:export': { + id: 'document:export', + labelKey: 'document.export', + label: 'Export', + icon: 'Download', + categories: ['document'], + action: ({ registry, documentId }) => { + const exportPlugin = registry.getPlugin('export')?.provides(); + exportPlugin?.forDocument(documentId).download(); + }, + }, + + 'document:properties': { + id: 'document:properties', + labelKey: 'document.properties', + label: 'Properties', + icon: 'Alert', + categories: ['document'], + action: () => { + console.log('Document properties clicked'); + }, + }, + + // ───────────────────────────────────────────────────────── + // Panel Commands + // ───────────────────────────────────────────────────────── + 'panel:toggle-sidebar': { + id: 'panel:toggle-sidebar', + labelKey: 'panel.sidebar', + label: 'Sidebar', + icon: 'Sidebar', + categories: ['panels'], + action: ({ registry, documentId }) => { + // Toggle the thumbnails panel via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleSidebar('left', 'main', 'sidebar-panel'); + }, + active: ({ state, documentId }) => { + return isSidebarOpen(state.plugins, documentId, 'left', 'main', 'sidebar-panel'); + }, + }, + + 'panel:toggle-search': { + id: 'panel:toggle-search', + labelKey: 'panel.search', + label: 'Search', + icon: 'Search', + shortcuts: ['Ctrl+F', 'Meta+F'], + categories: ['panels'], + action: ({ registry, documentId }) => { + // Toggle the search panel via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleSidebar('right', 'main', 'search-panel'); + }, + active: ({ state, documentId }) => { + return isSidebarOpen(state.plugins, documentId, 'right', 'main', 'search-panel'); + }, + }, + + // ───────────────────────────────────────────────────────── + // Page Settings Commands + // ───────────────────────────────────────────────────────── + 'page:settings': { + id: 'page:settings', + labelKey: 'page.settings', + label: 'Page Settings', + icon: 'Settings', + categories: ['page'], + action: ({ registry, documentId }) => { + // Toggle the page settings menu via UI plugin + const uiPlugin = registry.getPlugin(UI_PLUGIN_ID); + if (!uiPlugin || !uiPlugin.provides) return; + + const uiCapability = uiPlugin.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + scope.toggleMenu( + 'page-settings-menu', + 'page:settings', + 'page-settings-button', // Must match the item ID in ui-schema + ); + }, + active: ({ state, documentId }) => { + const uiState = state.plugins['ui']?.documents[documentId]; + return uiState?.openMenus['page-settings-menu'] !== undefined; + }, + }, + + 'spread:none': { + id: 'spread:none', + labelKey: 'page.single', + label: 'Single Page', + categories: ['page'], + action: ({ registry, documentId }) => { + const spread = registry.getPlugin('spread')?.provides(); + spread?.forDocument(documentId).setSpreadMode(SpreadMode.None); + }, + active: ({ state, documentId }) => + state.plugins['spread']?.documents[documentId]?.spreadMode === SpreadMode.None, + }, + + 'spread:odd': { + id: 'spread:odd', + labelKey: 'page.twoOdd', + label: 'Two Page (Odd)', + categories: ['page'], + action: ({ registry, documentId }) => { + const spread = registry.getPlugin('spread')?.provides(); + spread?.forDocument(documentId).setSpreadMode(SpreadMode.Odd); + }, + active: ({ state, documentId }) => + state.plugins['spread']?.documents[documentId]?.spreadMode === SpreadMode.Odd, + }, + + 'spread:even': { + id: 'spread:even', + labelKey: 'page.twoEven', + label: 'Two Page (Even)', + categories: ['page'], + action: ({ registry, documentId }) => { + const spread = registry.getPlugin('spread')?.provides(); + spread?.forDocument(documentId).setSpreadMode(SpreadMode.Even); + }, + active: ({ state, documentId }) => + state.plugins['spread']?.documents[documentId]?.spreadMode === SpreadMode.Even, + }, + + 'rotate:clockwise': { + id: 'rotate:clockwise', + labelKey: 'rotate.clockwise', + label: 'Rotate Clockwise', + icon: 'RotateRight', + shortcuts: ['Ctrl+]', 'Meta+]'], + categories: ['page'], + action: ({ registry, documentId }) => { + const rotate = registry.getPlugin('rotate')?.provides(); + rotate?.forDocument(documentId).rotateForward(); + }, + }, + + 'rotate:counter-clockwise': { + id: 'rotate:counter-clockwise', + labelKey: 'rotate.counterClockwise', + label: 'Rotate Counter-Clockwise', + icon: 'RotateLeft', + shortcuts: ['Ctrl+[', 'Meta+['], + categories: ['page'], + action: ({ registry, documentId }) => { + const rotate = registry.getPlugin('rotate')?.provides(); + rotate?.forDocument(documentId).rotateBackward(); + }, + }, + + 'scroll:vertical': { + id: 'scroll:vertical', + labelKey: 'page.vertical', + label: 'Vertical', + categories: ['page'], + action: ({ registry, documentId }) => { + const scroll = registry.getPlugin('scroll')?.provides(); + scroll?.forDocument(documentId).setScrollStrategy(ScrollStrategy.Vertical); + }, + active: ({ state, documentId }) => + state.plugins['scroll']?.documents[documentId]?.strategy === ScrollStrategy.Vertical, + }, + + 'scroll:horizontal': { + id: 'scroll:horizontal', + labelKey: 'page.horizontal', + label: 'Horizontal', + categories: ['page'], + action: ({ registry, documentId }) => { + const scroll = registry.getPlugin('scroll')?.provides(); + scroll?.forDocument(documentId).setScrollStrategy(ScrollStrategy.Horizontal); + }, + active: ({ state, documentId }) => + state.plugins['scroll']?.documents[documentId]?.strategy === ScrollStrategy.Horizontal, + }, + + // ───────────────────────────────────────────────────────── + // Mode Commands + // ───────────────────────────────────────────────────────── + 'mode:view': { + id: 'mode:view', + labelKey: 'mode.view', + label: 'View', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + ui.forDocument(documentId).closeToolbarSlot('top', 'secondary'); + }, + active: ({ state, documentId }) => { + // Active if no secondary toolbar is shown + return !isToolbarOpen(state.plugins, documentId, 'top', 'secondary'); + }, + }, + + 'mode:annotate': { + id: 'mode:annotate', + labelKey: 'mode.annotate', + label: 'Annotate', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Show the annotation toolbar + ui.setActiveToolbar('top', 'secondary', 'annotation-toolbar', documentId); + }, + active: ({ state, documentId }) => { + return isToolbarOpen(state.plugins, documentId, 'top', 'secondary', 'annotation-toolbar'); + }, + }, + + 'mode:shapes': { + id: 'mode:shapes', + labelKey: 'mode.shapes', + label: 'Shapes', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Show the annotation toolbar (shapes use the same toolbar) + ui.setActiveToolbar('top', 'secondary', 'shapes-toolbar', documentId); + }, + active: ({ state, documentId }) => { + return isToolbarOpen(state.plugins, documentId, 'top', 'secondary', 'shapes-toolbar'); + }, + }, + + 'mode:redact': { + id: 'mode:redact', + labelKey: 'mode.redact', + label: 'Redact', + categories: ['mode'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Show the redaction toolbar + ui.setActiveToolbar('top', 'secondary', 'redaction-toolbar', documentId); + }, + active: ({ state, documentId }) => { + // Active when redaction toolbar is shown + return isToolbarOpen(state.plugins, documentId, 'top', 'secondary', 'redaction-toolbar'); + }, + }, + + 'tabs:overflow-menu': { + id: 'tabs:overflow-menu', + labelKey: 'tabs.overflowMenu', + icon: 'MenuDots', + label: 'More tabs', + categories: ['ui'], + action: ({ registry, documentId }) => { + const ui = registry.getPlugin('ui')?.provides(); + if (!ui) return; + + // Toggle the overflow tabs menu + ui.toggleMenu( + 'mode-tabs-overflow-menu', + 'tabs:overflow-menu', + 'overflow-tabs-button', + documentId, + ); + }, + }, + + // ───────────────────────────────────────────────────────── + // Annotation Commands + // ───────────────────────────────────────────────────────── + 'annotation:add-text': { + id: 'annotation:add-text', + labelKey: 'annotation.text', + label: 'Text', + icon: 'Text', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'freeText')?.fontColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'freeText') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('freeText'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'freeText'; + }, + }, + + 'annotation:add-highlight': { + id: 'annotation:add-highlight', + labelKey: 'annotation.highlight', + label: 'Highlight', + icon: 'Highlight', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'highlight')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'highlight') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('highlight'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'highlight'; + }, + }, + + 'annotation:add-strikeout': { + id: 'annotation:add-strikeout', + labelKey: 'annotation.strikeout', + label: 'Strikeout', + icon: 'Strikethrough', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'strikeout')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'strikeout') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('strikeout'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'strikeout'; + }, + }, + + 'annotation:add-underline': { + id: 'annotation:add-underline', + labelKey: 'annotation.underline', + label: 'Underline', + icon: 'Underline', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'underline')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'underline') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('underline'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'underline'; + }, + }, + + 'annotation:add-rectangle': { + id: 'annotation:add-rectangle', + labelKey: 'annotation.rectangle', + label: 'Rectangle', + icon: 'Square', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'square')?.strokeColor, + secondaryColor: getToolDefaultsById(state.plugins.annotation, 'square')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'square') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('square'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'square'; + }, + }, + + 'annotation:add-circle': { + id: 'annotation:add-circle', + labelKey: 'annotation.circle', + label: 'Circle', + icon: 'Circle', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'circle')?.strokeColor, + secondaryColor: getToolDefaultsById(state.plugins.annotation, 'circle')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'circle') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('circle'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'circle'; + }, + }, + + 'annotation:add-line': { + id: 'annotation:add-line', + labelKey: 'annotation.line', + label: 'Line', + icon: 'Line', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'line')?.strokeColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'line') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('line'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'line'; + }, + }, + + 'annotation:add-arrow': { + id: 'annotation:add-arrow', + labelKey: 'annotation.arrow', + label: 'Arrow', + icon: 'Arrow', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'line')?.strokeColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'lineArrow') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('lineArrow'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'lineArrow'; + }, + }, + + 'annotation:add-polygon': { + id: 'annotation:add-polygon', + labelKey: 'annotation.polygon', + label: 'Polygon', + icon: 'Polygon', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'polygon')?.strokeColor, + secondaryColor: getToolDefaultsById(state.plugins.annotation, 'polygon')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'polygon') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('polygon'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'polygon'; + }, + }, + + 'annotation:add-polyline': { + id: 'annotation:add-polyline', + labelKey: 'annotation.polyline', + label: 'Polyline', + icon: 'Polyline', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'polyline')?.strokeColor, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'polyline') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('polyline'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'polyline'; + }, + }, + + 'annotation:add-ink': { + id: 'annotation:add-ink', + labelKey: 'annotation.ink', + label: 'Ink', + icon: 'Pen', + iconProps: ({ state }) => ({ + primaryColor: getToolDefaultsById(state.plugins.annotation, 'ink')?.color, + }), + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'ink') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('ink'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'ink'; + }, + }, + + 'annotation:add-stamp': { + id: 'annotation:add-stamp', + labelKey: 'annotation.stamp', + label: 'Stamp', + icon: 'Photo', + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + if (annotationScope.getActiveTool()?.id === 'stamp') { + annotationScope.setActiveTool(null); + } else { + annotationScope.setActiveTool('stamp'); + } + }, + active: ({ state, documentId }) => { + const annotation = state.plugins[ANNOTATION_PLUGIN_ID]?.documents[documentId]; + return annotation?.activeToolId === 'stamp'; + }, + }, + + 'annotation:delete-selected': { + id: 'annotation:delete-selected', + labelKey: 'annotation.deleteSelected', + label: 'Delete Selected', + icon: 'Trash', + categories: ['annotation'], + action: ({ registry, documentId }) => { + const annotation = registry.getPlugin(ANNOTATION_PLUGIN_ID)?.provides(); + + const annotationScope = annotation?.forDocument(documentId); + if (!annotationScope) return; + + const selectedAnnotation = annotationScope.getSelectedAnnotation(); + if (!selectedAnnotation) return; + + annotationScope.deleteAnnotation( + selectedAnnotation.object.pageIndex, + selectedAnnotation.object.id, + ); + }, + }, + + // ───────────────────────────────────────────────────────── + // Redaction Commands + // ───────────────────────────────────────────────────────── + 'redaction:redact-area': { + id: 'redaction:redact-area', + labelKey: 'redaction.area', + label: 'Redact Area', + icon: 'RedactArea', + categories: ['redaction'], + action: ({ registry, documentId }) => { + const redaction = registry.getPlugin('redaction')?.provides(); + redaction?.forDocument(documentId).toggleMarqueeRedact(); + }, + active: ({ state, documentId }) => { + const redaction = state.plugins[REDACTION_PLUGIN_ID]?.documents[documentId]; + return redaction?.activeType === RedactionMode.MarqueeRedact; + }, + }, + + 'redaction:redact-text': { + id: 'redaction:redact-text', + labelKey: 'redaction.text', + label: 'Redact Text', + icon: 'RedactText', + categories: ['redaction'], + action: ({ registry, documentId }) => { + const redaction = registry.getPlugin('redaction')?.provides(); + redaction?.forDocument(documentId).toggleRedactSelection(); + }, + active: ({ state, documentId }) => { + const redaction = state.plugins[REDACTION_PLUGIN_ID]?.documents[documentId]; + return redaction?.activeType === RedactionMode.RedactSelection; + }, + }, + + 'redaction:apply-all': { + id: 'redaction:apply-all', + labelKey: 'redaction.applyAll', + label: 'Apply All', + icon: 'Check', + categories: ['redaction'], + action: ({ registry, documentId }) => { + const redaction = registry.getPlugin('redaction')?.provides(); + redaction?.forDocument(documentId).commitAllPending(); + }, + }, + + 'redaction:clear-all': { + id: 'redaction:clear-all', + labelKey: 'redaction.clearAll', + label: 'Clear All', + icon: 'Close', + categories: ['redaction'], + action: ({ registry, documentId }) => { + const redaction = registry.getPlugin('redaction')?.provides(); + redaction?.forDocument(documentId).clearPending(); + }, + }, + + 'redaction:delete-selected': { + id: 'redaction:delete-selected', + labelKey: 'redaction.deleteSelected', + label: 'Delete Selected', + icon: 'Trash', + categories: ['redaction'], + action: ({ registry, documentId }) => { + const redaction = registry.getPlugin('redaction')?.provides(); + const selectedRedaction = redaction?.forDocument(documentId).getSelectedPending(); + if (!selectedRedaction) return; + redaction + ?.forDocument(documentId) + .removePending(selectedRedaction.page, selectedRedaction.id); + }, + }, + + 'redaction:commit-selected': { + id: 'redaction:commit-selected', + labelKey: 'redaction.commitSelected', + label: 'Commit Selected', + icon: 'Check', + categories: ['redaction'], + action: ({ registry, documentId }) => { + const redaction = registry.getPlugin('redaction')?.provides(); + const selectedRedaction = redaction?.forDocument(documentId).getSelectedPending(); + if (!selectedRedaction) return; + redaction + ?.forDocument(documentId) + .commitPending(selectedRedaction.page, selectedRedaction.id); + }, + }, + + 'selection:copy': { + id: 'selection:copy', + labelKey: 'selection.copy', + label: 'Copy', + icon: 'Copy', + categories: ['selection'], + action: ({ registry, documentId }) => { + const plugin = registry.getPlugin('selection'); + const scope = plugin?.provides().forDocument(documentId); + scope?.copyToClipboard(); + scope?.clear(); + }, + }, + + // ───────────────────────────────────────────────────────── + // History Commands + // ───────────────────────────────────────────────────────── + 'history:undo': { + id: 'history:undo', + labelKey: 'history.undo', + label: 'Undo', + icon: 'ArrowBackUp', + shortcuts: ['Ctrl+Z', 'Meta+Z'], + categories: ['edit'], + action: ({ registry, documentId }) => { + const history = registry.getPlugin(HISTORY_PLUGIN_ID)?.provides(); + if (!history) return; + + const scope = history.forDocument(documentId); + scope.undo(); + }, + disabled: ({ state, documentId }) => { + const history = state.plugins[HISTORY_PLUGIN_ID]?.documents[documentId]; + return !history?.global.canUndo; + }, + }, + + 'history:redo': { + id: 'history:redo', + labelKey: 'history.redo', + label: 'Redo', + icon: 'ArrowForwardUp', + shortcuts: ['Ctrl+Y', 'Meta+Shift+Z'], + categories: ['edit'], + action: ({ registry, documentId }) => { + const history = registry.getPlugin(HISTORY_PLUGIN_ID)?.provides(); + if (!history) return; + + const scope = history.forDocument(documentId); + scope.redo(); + }, + disabled: ({ state, documentId }) => { + const history = state.plugins[HISTORY_PLUGIN_ID]?.documents[documentId]; + return !history?.global.canRedo; + }, + }, + + 'annotation:overflow-tools': { + id: 'annotation:overflow-tools', + labelKey: 'annotation.overflowTools', + label: 'Overflow Tools', + icon: 'MenuDots', + categories: ['annotation'], + action: ({ registry, documentId }) => { + const uiCapability = registry.getPlugin('ui')?.provides(); + if (!uiCapability) return; + + const scope = uiCapability.forDocument(documentId); + if (!scope) return; + + scope.toggleMenu( + 'annotation-tools-menu', + 'annotation:overflow-tools', + 'overflow-annotation-tools', + ); + }, + active: ({ state, documentId }) => { + const ui = state.plugins['ui']?.documents[documentId]; + return ui?.openMenus['annotation-tools-menu'] !== undefined; + }, + }, +}; diff --git a/packages/embed-pdf/src/config/index.ts b/packages/embed-pdf/src/config/index.ts new file mode 100644 index 0000000..cf69b8a --- /dev/null +++ b/packages/embed-pdf/src/config/index.ts @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export * from './commands'; +export * from './types'; +export * from './ui-schema'; +export * from './translations'; diff --git a/packages/embed-pdf/src/config/translations.ts b/packages/embed-pdf/src/config/translations.ts new file mode 100644 index 0000000..6668d03 --- /dev/null +++ b/packages/embed-pdf/src/config/translations.ts @@ -0,0 +1,429 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ParamResolvers, Locale } from '@embedpdf/plugin-i18n'; +import { State } from './types'; +import { ZOOM_PLUGIN_ID } from '@embedpdf/plugin-zoom'; + +export const englishTranslations: Locale = { + code: 'en', + name: 'English', + translations: { + zoom: { + in: 'Zoom In', + out: 'Zoom Out', + fitWidth: 'Fit to Width', + fitPage: 'Fit to Page', + marquee: 'Marquee Zoom', + automatic: 'Automatic', + level: 'Zoom Level ({level}%)', + inArea: 'Zoom In Area', + menu: 'Zoom Menu', + }, + pan: { + toggle: 'Toggle Pan Mode', + }, + pointer: { + toggle: 'Toggle Pointer Mode', + }, + capture: { + screenshot: 'Screenshot', + }, + document: { + menu: 'Document Menu', + open: 'Open', + close: 'Close', + print: 'Print', + export: 'Export', + properties: 'Properties', + }, + panel: { + sidebar: 'Sidebar', + search: 'Search', + thumbnails: 'Thumbnails', + outline: 'Outline', + }, + page: { + settings: 'Page Settings', + single: 'Single Page', + twoOdd: 'Two Page (Odd)', + twoEven: 'Two Page (Even)', + vertical: 'Vertical', + horizontal: 'Horizontal', + spreadMode: 'Spread Mode', + scrollLayout: 'Scroll Layout', + rotation: 'Page Rotation', + }, + rotate: { + clockwise: 'Rotate Clockwise', + counterClockwise: 'Rotate Counter-Clockwise', + }, + mode: { + view: 'View', + annotate: 'Annotate', + shapes: 'Shapes', + redact: 'Redact', + }, + tabs: { + overflowMenu: 'More tabs', + }, + annotation: { + text: 'Text', + highlight: 'Highlight', + strikeout: 'Strikeout', + underline: 'Underline', + rectangle: 'Rectangle', + circle: 'Circle', + line: 'Line', + arrow: 'Arrow', + polygon: 'Polygon', + polyline: 'Polyline', + ink: 'Ink', + stamp: 'Stamp', + overflowTools: 'Overflow Tools', + }, + redaction: { + area: 'Redact Area', + text: 'Redact Text', + applyAll: 'Apply All', + clearAll: 'Clear All', + }, + history: { + undo: 'Undo', + redo: 'Redo', + }, + search: { + title: 'Search', + placeholder: 'Search', + close: 'Close search', + caseSensitive: 'Case sensitive', + wholeWord: 'Whole word', + resultsFound: '{count} results found', + previousResult: 'Previous result', + nextResult: 'Next result', + page: 'Page {number}', + }, + }, +}; + +export const spanishTranslations: Locale = { + code: 'es', + name: 'Español', + translations: { + zoom: { + in: 'Acercar', + out: 'Alejar', + fitWidth: 'Ajustar al ancho', + fitPage: 'Ajustar a la página', + marquee: 'Zoom de marquesina', + automatic: 'Automático', + level: 'Nivel de zoom ({level}%)', + inArea: 'Acercar área', + menu: 'Menú de zoom', + }, + pan: { + toggle: 'Alternar modo panorámico', + }, + pointer: { + toggle: 'Alternar modo puntero', + }, + capture: { + screenshot: 'Captura de pantalla', + }, + document: { + menu: 'Menú de documento', + open: 'Abrir', + close: 'Cerrar', + print: 'Imprimir', + export: 'Exportar', + properties: 'Propiedades', + }, + panel: { + sidebar: 'Barra lateral', + search: 'Buscar', + thumbnails: 'Miniaturas', + outline: 'Esquema', + }, + page: { + settings: 'Configuración de página', + single: 'Página única', + twoOdd: 'Dos páginas (impar)', + twoEven: 'Dos páginas (par)', + vertical: 'Vertical', + horizontal: 'Horizontal', + spreadMode: 'Modo de extensión', + scrollLayout: 'Diseño de desplazamiento', + rotation: 'Rotación de página', + }, + rotate: { + clockwise: 'Girar en sentido horario', + counterClockwise: 'Girar en sentido antihorario', + }, + mode: { + view: 'Ver', + annotate: 'Anotar', + shapes: 'Formas', + redact: 'Redactar', + }, + tabs: { + overflowMenu: 'Más pestañas', + }, + annotation: { + text: 'Texto', + highlight: 'Resaltar', + strikeout: 'Tachar', + underline: 'Subrayar', + rectangle: 'Rectángulo', + circle: 'Círculo', + line: 'Línea', + arrow: 'Flecha', + polygon: 'Polígono', + polyline: 'Polilínea', + ink: 'Tinta', + stamp: 'Sello', + overflowTools: 'Más herramientas', + }, + redaction: { + area: 'Redactar área', + text: 'Redactar texto', + applyAll: 'Aplicar todo', + clearAll: 'Borrar todo', + }, + history: { + undo: 'Deshacer', + redo: 'Rehacer', + }, + search: { + title: 'Buscar', + placeholder: 'Buscar', + close: 'Cerrar búsqueda', + caseSensitive: 'Distinguir mayúsculas', + wholeWord: 'Palabra completa', + resultsFound: '{count} resultados encontrados', + previousResult: 'Resultado anterior', + nextResult: 'Resultado siguiente', + page: 'Página {number}', + }, + }, +}; + +export const germanTranslations: Locale = { + code: 'de', + name: 'Deutsch', + translations: { + zoom: { + in: 'Vergrößern', + out: 'Verkleinern', + fitWidth: 'An Breite anpassen', + fitPage: 'An Seite anpassen', + marquee: 'Auswahlzoom', + automatic: 'Automatisch', + level: 'Zoomstufe ({level}%)', + inArea: 'Bereich vergrößern', + menu: 'Zoom-Menü', + }, + pan: { + toggle: 'Schwenkmodus umschalten', + }, + pointer: { + toggle: 'Zeigermodus umschalten', + }, + capture: { + screenshot: 'Screenshot', + }, + document: { + menu: 'Dokumentmenü', + open: 'Öffnen', + close: 'Schließen', + print: 'Drucken', + export: 'Exportieren', + properties: 'Eigenschaften', + }, + panel: { + sidebar: 'Seitenleiste', + search: 'Suchen', + thumbnails: 'Miniaturansichten', + outline: 'Gliederung', + }, + page: { + settings: 'Seiteneinstellungen', + single: 'Einzelne Seite', + twoOdd: 'Zwei Seiten (ungerade)', + twoEven: 'Zwei Seiten (gerade)', + vertical: 'Vertikal', + horizontal: 'Horizontal', + spreadMode: 'Seitenmodus', + scrollLayout: 'Scroll-Layout', + rotation: 'Seitendrehung', + }, + rotate: { + clockwise: 'Im Uhrzeigersinn drehen', + counterClockwise: 'Gegen den Uhrzeigersinn drehen', + }, + mode: { + view: 'Ansicht', + annotate: 'Annotieren', + shapes: 'Formen', + redact: 'Schwärzen', + }, + tabs: { + overflowMenu: 'Weitere Tabs', + }, + annotation: { + text: 'Text', + highlight: 'Hervorheben', + strikeout: 'Durchstreichen', + underline: 'Unterstreichen', + rectangle: 'Rechteck', + circle: 'Kreis', + line: 'Linie', + arrow: 'Pfeil', + polygon: 'Polygon', + polyline: 'Polylinie', + ink: 'Stift', + stamp: 'Stempel', + overflowTools: 'Weitere Werkzeuge', + }, + redaction: { + area: 'Bereich schwärzen', + text: 'Text schwärzen', + applyAll: 'Alles anwenden', + clearAll: 'Alles löschen', + }, + history: { + undo: 'Rückgängig', + redo: 'Wiederholen', + }, + search: { + title: 'Suchen', + placeholder: 'Suchen', + close: 'Suche schließen', + caseSensitive: 'Groß-/Kleinschreibung', + wholeWord: 'Ganzes Wort', + resultsFound: '{count} Ergebnisse gefunden', + previousResult: 'Vorheriges Ergebnis', + nextResult: 'Nächstes Ergebnis', + page: 'Seite {number}', + }, + }, +}; + +export const dutchTranslations: Locale = { + code: 'nl', + name: 'Nederlands', + translations: { + zoom: { + in: 'Inzoomen', + out: 'Uitzoomen', + fitWidth: 'Aan breedte aanpassen', + fitPage: 'Aan pagina aanpassen', + marquee: 'Selectiezoom', + automatic: 'Automatisch', + level: 'Zoomniveau ({level}%)', + inArea: 'Gebied inzoomen', + menu: 'Zoommenu', + }, + pan: { + toggle: 'Panbewegingsmodus schakelen', + }, + pointer: { + toggle: 'Aanwijzermodus schakelen', + }, + capture: { + screenshot: 'Schermafbeelding', + }, + document: { + menu: 'Documentmenu', + open: 'Openen', + close: 'Sluiten', + print: 'Afdrukken', + export: 'Exporteren', + properties: 'Eigenschappen', + }, + panel: { + sidebar: 'Zijbalk', + search: 'Zoeken', + thumbnails: 'Miniaturen', + outline: 'Overzicht', + }, + page: { + settings: 'Pagina-instellingen', + single: 'Enkele pagina', + twoOdd: "Twee pagina's (oneven)", + twoEven: "Twee pagina's (even)", + vertical: 'Verticaal', + horizontal: 'Horizontaal', + spreadMode: 'Spreidmodus', + scrollLayout: 'Scroll-indeling', + rotation: 'Paginadraaiing', + }, + rotate: { + clockwise: 'Met de klok mee draaien', + counterClockwise: 'Tegen de klok in draaien', + }, + mode: { + view: 'Weergave', + annotate: 'Annoteren', + shapes: 'Vormen', + redact: 'Redigeren', + }, + tabs: { + overflowMenu: 'Meer tabbladen', + }, + annotation: { + text: 'Tekst', + highlight: 'Markeren', + strikeout: 'Doorhalen', + underline: 'Onderstrepen', + rectangle: 'Rechthoek', + circle: 'Cirkel', + line: 'Lijn', + arrow: 'Pijl', + polygon: 'Veelhoek', + polyline: 'Polylijn', + ink: 'Inkt', + stamp: 'Stempel', + overflowTools: 'Meer gereedschappen', + }, + redaction: { + area: 'Gebied redigeren', + text: 'Tekst redigeren', + applyAll: 'Alles toepassen', + clearAll: 'Alles wissen', + }, + history: { + undo: 'Ongedaan maken', + redo: 'Opnieuw uitvoeren', + }, + search: { + title: 'Zoeken', + placeholder: 'Zoeken', + close: 'Zoekopdracht sluiten', + caseSensitive: 'Hoofdlettergevoelig', + wholeWord: 'Heel woord', + resultsFound: '{count} resultaten gevonden', + previousResult: 'Vorig resultaat', + nextResult: 'Volgend resultaat', + page: 'Pagina {number}', + }, + }, +}; + +export const paramResolvers: ParamResolvers = { + 'zoom.level': ({ state, documentId }) => { + const zoomLevel = documentId + ? (state.plugins[ZOOM_PLUGIN_ID]?.documents[documentId]?.currentZoomLevel ?? 1) + : 1; + return { + level: Math.round(zoomLevel * 100), + }; + }, + 'search.resultsFound': ({ state, documentId }) => { + const searchState = documentId ? state.plugins['search']?.documents[documentId] : null; + return { + count: searchState?.total ?? 0, + }; + }, +}; diff --git a/packages/embed-pdf/src/config/types.ts b/packages/embed-pdf/src/config/types.ts new file mode 100644 index 0000000..6034fef --- /dev/null +++ b/packages/embed-pdf/src/config/types.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { GlobalStoreState } from '@embedpdf/core'; +import { CAPTURE_PLUGIN_ID, CaptureState } from '@embedpdf/plugin-capture/react'; +import { ZOOM_PLUGIN_ID, ZoomState } from '@embedpdf/plugin-zoom/react'; +import { VIEWPORT_PLUGIN_ID, ViewportState } from '@embedpdf/plugin-viewport/react'; +import { SCROLL_PLUGIN_ID, ScrollState } from '@embedpdf/plugin-scroll/react'; +import { SPREAD_PLUGIN_ID, SpreadState } from '@embedpdf/plugin-spread/react'; +import { SEARCH_PLUGIN_ID, SearchState } from '@embedpdf/plugin-search/react'; +import { SELECTION_PLUGIN_ID, SelectionState } from '@embedpdf/plugin-selection/react'; +import { ANNOTATION_PLUGIN_ID, AnnotationState } from '@embedpdf/plugin-annotation/react'; +import { FULLSCREEN_PLUGIN_ID, FullscreenState } from '@embedpdf/plugin-fullscreen/react'; +import { + INTERACTION_MANAGER_PLUGIN_ID, + InteractionManagerState, +} from '@embedpdf/plugin-interaction-manager/react'; +import { HISTORY_PLUGIN_ID, HistoryState } from '@embedpdf/plugin-history/react'; +import { REDACTION_PLUGIN_ID, RedactionState } from '@embedpdf/plugin-redaction/react'; +import { PAN_PLUGIN_ID, PanState } from '@embedpdf/plugin-pan/react'; +import { UI_PLUGIN_ID, UIState } from '@embedpdf/plugin-ui'; + +export type State = GlobalStoreState<{ + [CAPTURE_PLUGIN_ID]: CaptureState; + [ZOOM_PLUGIN_ID]: ZoomState; + [VIEWPORT_PLUGIN_ID]: ViewportState; + [SCROLL_PLUGIN_ID]: ScrollState; + [SPREAD_PLUGIN_ID]: SpreadState; + [SEARCH_PLUGIN_ID]: SearchState; + [SELECTION_PLUGIN_ID]: SelectionState; + [ANNOTATION_PLUGIN_ID]: AnnotationState; + [FULLSCREEN_PLUGIN_ID]: FullscreenState; + [INTERACTION_MANAGER_PLUGIN_ID]: InteractionManagerState; + [HISTORY_PLUGIN_ID]: HistoryState; + [REDACTION_PLUGIN_ID]: RedactionState; + [PAN_PLUGIN_ID]: PanState; + [UI_PLUGIN_ID]: UIState; +}>; + +// Type for tracking sidebar state per document +export type SidebarState = { + search: boolean; + thumbnails: boolean; +}; diff --git a/packages/embed-pdf/src/config/ui-schema.ts b/packages/embed-pdf/src/config/ui-schema.ts new file mode 100644 index 0000000..e4af6ee --- /dev/null +++ b/packages/embed-pdf/src/config/ui-schema.ts @@ -0,0 +1,842 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { UISchema } from '@embedpdf/plugin-ui'; + +/** + * UI Schema Configuration + * + * This defines the complete UI structure for the PDF viewer application. + * The schema is a declarative, type-safe way to define toolbars, menus, and panels. + */ +export const viewerUISchema: UISchema = { + id: 'pdf-viewer-ui', + version: '1.0.0', + + // ───────────────────────────────────────────────────────── + // Toolbars + // ───────────────────────────────────────────────────────── + toolbars: { + // Main toolbar at the top + 'main-toolbar': { + id: 'main-toolbar', + position: { + placement: 'top', + slot: 'main', + order: 0, + }, + permanent: true, + responsive: { + localeOverrides: { + groups: [ + { + id: 'germanic-languages', + locales: ['de', 'nl'], + breakpoints: { + md: { + replaceShow: ['annotate-mode', 'zoom-toolbar'], + }, + }, + }, + ], + }, + breakpoints: { + xs: { + maxWidth: 640, + hide: [ + 'annotate-mode', + 'shapes-mode', + 'redact-mode', + 'zoom-toolbar', + 'pan-button', + 'pointer-button', + 'divider-3', + ], + show: ['overflow-tabs-button'], + }, + sm: { + minWidth: 640, + maxWidth: 768, + hide: ['shapes-mode', 'redact-mode', 'zoom-toolbar'], + show: [ + 'annotate-mode', + 'overflow-tabs-button', + 'pan-button', + 'pointer-button', + 'divider-3', + ], + }, + md: { + minWidth: 768, + show: ['annotate-mode', 'shapes-mode', 'zoom-toolbar'], + hide: ['zoom-menu-button'], + }, + lg: { + minWidth: 1024, + show: ['shapes-mode', 'redact-mode'], + hide: ['overflow-tabs-button'], + }, + }, + }, + items: [ + // ───────── Left Section: Document & Navigation ───────── + { + type: 'group', + id: 'left-group', + alignment: 'start', + gap: 1, + items: [ + { + type: 'command-button', + id: 'document-menu-button', + commandId: 'document:menu', + variant: 'icon', + }, + { + type: 'divider', + id: 'divider-1', + orientation: 'vertical', + }, + { + type: 'command-button', + id: 'sidebar-button', + commandId: 'panel:toggle-sidebar', + variant: 'icon', + }, + { + type: 'command-button', + id: 'page-settings-button', + commandId: 'page:settings', + variant: 'icon', + }, + ], + }, + + // ───────── Center Section: Zoom & Tools ───────── + { + type: 'divider', + id: 'divider-2', + orientation: 'vertical', + }, + { + type: 'group', + id: 'center-group', + alignment: 'center', + gap: 2, + items: [ + { + type: 'command-button', + id: 'zoom-menu-button', + commandId: 'zoom:toggle-menu-mobile', + variant: 'icon', + }, + { + type: 'custom', + id: 'zoom-toolbar', + componentId: 'zoom-toolbar', + }, + { + type: 'divider', + id: 'divider-3', + orientation: 'vertical', + }, + { + type: 'command-button', + id: 'pan-button', + commandId: 'pan:toggle', + variant: 'icon', + }, + { + type: 'command-button', + id: 'pointer-button', + commandId: 'pointer:toggle', + variant: 'icon', + }, + ], + }, + + // ───────── Spacer: Flexible space ───────── + { + type: 'spacer', + id: 'spacer-1', + flex: true, + }, + + // ───────── Mode Tabs ───────── + { + type: 'tab-group', + id: 'mode-tabs', + tabs: [ + { + id: 'view-mode', + commandId: 'mode:view', + variant: 'text', + }, + { + id: 'annotate-mode', + commandId: 'mode:annotate', + variant: 'text', + }, + { + id: 'shapes-mode', + commandId: 'mode:shapes', + variant: 'text', + }, + { + id: 'redact-mode', + commandId: 'mode:redact', + variant: 'text', + }, + { + id: 'overflow-tabs-button', + commandId: 'tabs:overflow-menu', + variant: 'icon', + }, + ], + }, + + // ───────── Spacer: Flexible space ───────── + { + type: 'spacer', + id: 'spacer-2', + flex: true, + }, + + // ───────── Right Section: Search & Actions ───────── + { + type: 'group', + id: 'right-group', + alignment: 'end', + gap: 2, + items: [ + { + type: 'command-button', + id: 'search-button', + commandId: 'panel:toggle-search', + variant: 'icon', + }, + ], + }, + ], + }, + + // Annotation toolbar (shown when in annotate mode) + 'annotation-toolbar': { + id: 'annotation-toolbar', + position: { + placement: 'top', + slot: 'secondary', + order: 0, + }, + responsive: { + breakpoints: { + sm: { + maxWidth: 640, + hide: ['redo-button', 'undo-button'], + show: ['overflow-annotation-tools'], + }, + md: { + minWidth: 640, + show: ['redo-button', 'undo-button'], + hide: ['overflow-annotation-tools'], + }, + }, + }, + permanent: false, + items: [ + { type: 'spacer', id: 'spacer-3', flex: true }, + { + type: 'group', + id: 'annotation-tools', + alignment: 'start', + gap: 2, + items: [ + { + type: 'command-button', + id: 'add-highlight', + commandId: 'annotation:add-highlight', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-strikeout', + commandId: 'annotation:add-strikeout', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-underline', + commandId: 'annotation:add-underline', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-ink', + commandId: 'annotation:add-ink', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-text', + commandId: 'annotation:add-text', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-stamp', + commandId: 'annotation:add-stamp', + variant: 'icon', + }, + { + type: 'divider', + id: 'divider-6', + orientation: 'vertical', + }, + { + type: 'command-button', + id: 'undo-button', + commandId: 'history:undo', + variant: 'icon', + }, + { + type: 'command-button', + id: 'redo-button', + commandId: 'history:redo', + variant: 'icon', + }, + { + type: 'command-button', + id: 'overflow-annotation-tools', + commandId: 'annotation:overflow-tools', + variant: 'icon', + }, + ], + }, + { type: 'spacer', id: 'spacer-4', flex: true }, + ], + }, + + 'shapes-toolbar': { + id: 'shapes-toolbar', + position: { + placement: 'top', + slot: 'secondary', + order: 0, + }, + permanent: false, + items: [ + { type: 'spacer', id: 'spacer-5', flex: true }, + { + type: 'group', + id: 'shapes-tools', + alignment: 'start', + gap: 2, + items: [ + { + type: 'command-button', + id: 'add-rectangle', + commandId: 'annotation:add-rectangle', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-circle', + commandId: 'annotation:add-circle', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-line', + commandId: 'annotation:add-line', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-arrow', + commandId: 'annotation:add-arrow', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-polygon', + commandId: 'annotation:add-polygon', + variant: 'icon', + }, + { + type: 'command-button', + id: 'add-polyline', + commandId: 'annotation:add-polyline', + variant: 'icon', + }, + { + type: 'divider', + id: 'divider-7', + orientation: 'vertical', + }, + { + type: 'command-button', + id: 'undo-button', + commandId: 'history:undo', + variant: 'icon', + }, + { + type: 'command-button', + id: 'redo-button', + commandId: 'history:redo', + variant: 'icon', + }, + ], + }, + { type: 'spacer', id: 'spacer-6', flex: true }, + ], + }, + + // Redaction toolbar (shown when in redact mode) + 'redaction-toolbar': { + id: 'redaction-toolbar', + position: { + placement: 'top', + slot: 'secondary', + order: 0, + }, + permanent: false, + items: [ + { type: 'spacer', id: 'spacer-7', flex: true }, + { + type: 'group', + id: 'redaction-tools', + alignment: 'start', + gap: 2, + items: [ + { + type: 'command-button', + id: 'redact-text', + commandId: 'redaction:redact-text', + variant: 'icon', + }, + { + type: 'command-button', + id: 'redact-area', + commandId: 'redaction:redact-area', + variant: 'icon', + }, + { + type: 'divider', + id: 'divider-5', + orientation: 'vertical', + }, + { + type: 'command-button', + id: 'apply-redactions', + commandId: 'redaction:apply-all', + variant: 'icon', + }, + { + type: 'command-button', + id: 'clear-redactions', + commandId: 'redaction:clear-all', + variant: 'icon', + }, + ], + }, + { type: 'spacer', id: 'spacer-8', flex: true }, + ], + }, + }, + + // ───────────────────────────────────────────────────────── + // Menus + // ───────────────────────────────────────────────────────── + menus: { + 'mode-tabs-overflow-menu': { + id: 'mode-tabs-overflow-menu', + items: [ + { + type: 'command', + id: 'mode:annotate', + commandId: 'mode:annotate', + }, + { + type: 'command', + id: 'mode:shapes', + commandId: 'mode:shapes', + }, + { + type: 'command', + id: 'mode:redact', + commandId: 'mode:redact', + }, + ], + responsive: { + breakpoints: { + xs: { + maxWidth: 640, + show: ['mode:annotate', 'mode:shapes', 'mode:redact'], + }, + md: { + minWidth: 640, + hide: ['mode:annotate'], + }, + }, + }, + }, + 'zoom-levels-menu': { + id: 'zoom-levels-menu', + items: [ + { + type: 'command', + id: 'zoom:25', + commandId: 'zoom:25', + }, + { + type: 'command', + id: 'zoom:50', + commandId: 'zoom:50', + }, + { + type: 'command', + id: 'zoom:100', + commandId: 'zoom:100', + }, + { + type: 'command', + id: 'zoom:125', + commandId: 'zoom:125', + }, + { + type: 'command', + id: 'zoom:150', + commandId: 'zoom:150', + }, + { + type: 'command', + id: 'zoom:200', + commandId: 'zoom:200', + }, + { + type: 'command', + id: 'zoom:400', + commandId: 'zoom:400', + }, + { + type: 'command', + id: 'zoom:800', + commandId: 'zoom:800', + }, + { + type: 'command', + id: 'zoom:1600', + commandId: 'zoom:1600', + }, + ], + }, + 'zoom-menu': { + id: 'zoom-menu', + items: [ + { + type: 'submenu', + id: 'zoom-levels-submenu', + labelKey: 'zoom.level', + label: 'Zoom Levels', + menuId: 'zoom-levels-menu', + }, + { + type: 'divider', + id: 'divider-zoom-in-out', + }, + { + type: 'command', + id: 'zoom:in', + commandId: 'zoom:in', + }, + { + type: 'command', + id: 'zoom:out', + commandId: 'zoom:out', + }, + { + type: 'divider', + id: 'divider-8', + }, + { + type: 'command', + id: 'zoom:fit-page', + commandId: 'zoom:fit-page', + }, + { + type: 'command', + id: 'zoom:fit-width', + commandId: 'zoom:fit-width', + }, + { + type: 'divider', + id: 'divider-9', + }, + { + type: 'command', + id: 'zoom:marquee', + commandId: 'zoom:marquee', + }, + ], + responsive: { + breakpoints: { + xs: { + maxWidth: 640, + show: ['zoom-levels-submenu', 'divider-zoom-in-out'], + }, + md: { + minWidth: 768, + hide: ['zoom-levels-submenu', 'divider-zoom-in-out'], + }, + }, + }, + }, + 'document-menu': { + id: 'document-menu', + items: [ + { + type: 'command', + id: 'document:print', + commandId: 'document:print', + }, + ], + }, + 'annotation-tools-menu': { + id: 'annotation-tools-menu', + items: [ + { + type: 'command', + id: 'annotation:add-text', + commandId: 'annotation:add-text', + }, + { + type: 'command', + id: 'annotation:add-highlight', + commandId: 'annotation:add-highlight', + }, + { + type: 'command', + id: 'annotation:add-strikeout', + commandId: 'annotation:add-strikeout', + }, + { + type: 'command', + id: 'annotation:add-underline', + commandId: 'annotation:add-underline', + }, + { + type: 'divider', + id: 'divider-12', + }, + { + type: 'command', + id: 'annotation:add-rectangle', + commandId: 'annotation:add-rectangle', + }, + { + type: 'command', + id: 'annotation:add-circle', + commandId: 'annotation:add-circle', + }, + { + type: 'command', + id: 'annotation:add-line', + commandId: 'annotation:add-line', + }, + { + type: 'command', + id: 'annotation:add-arrow', + commandId: 'annotation:add-arrow', + }, + { + type: 'command', + id: 'annotation:add-polygon', + commandId: 'annotation:add-polygon', + }, + { + type: 'command', + id: 'annotation:add-polyline', + commandId: 'annotation:add-polyline', + }, + { + type: 'command', + id: 'annotation:add-ink', + commandId: 'annotation:add-ink', + }, + { + type: 'command', + id: 'annotation:add-stamp', + commandId: 'annotation:add-stamp', + }, + ], + }, + 'page-settings-menu': { + id: 'page-settings-menu', + items: [ + { + type: 'section', + id: 'spread-mode-section', + labelKey: 'page.spreadMode', + label: 'Spread Mode', + items: [ + { + type: 'command', + id: 'spread:none', + commandId: 'spread:none', + }, + { + type: 'command', + id: 'spread:odd', + commandId: 'spread:odd', + }, + { + type: 'command', + id: 'spread:even', + commandId: 'spread:even', + }, + ], + }, + { type: 'divider', id: 'divider-13' }, + { + type: 'section', + id: 'scroll-layout-section', + labelKey: 'page.scrollLayout', + label: 'Scroll Layout', + items: [ + { + type: 'command', + id: 'scroll:vertical', + commandId: 'scroll:vertical', + }, + { + type: 'command', + id: 'scroll:horizontal', + commandId: 'scroll:horizontal', + }, + ], + }, + { + type: 'divider', + id: 'divider-14', + }, + { + type: 'section', + id: 'page-rotation-section', + labelKey: 'page.rotation', + label: 'Page Rotation', + items: [ + { + type: 'command', + id: 'rotate:clockwise', + commandId: 'rotate:clockwise', + }, + { + type: 'command', + id: 'rotate:counter-clockwise', + commandId: 'rotate:counter-clockwise', + }, + ], + }, + ], + }, + }, + + modals: {}, + + // ───────────────────────────────────────────────────────── + // Panels (Sidebars) + // ───────────────────────────────────────────────────────── + sidebars: { + 'sidebar-panel': { + id: 'sidebar-panel', + position: { + placement: 'left', + slot: 'main', + order: 0, + }, + content: { + type: 'tabs', + tabs: [ + { + id: 'thumbnails', + labelKey: 'panel.thumbnails', + label: 'Thumbnails', + componentId: 'thumbnails-sidebar', + }, + { + id: 'outline', + labelKey: 'panel.outline', + label: 'Outline', + componentId: 'outline-sidebar', + }, + ], + }, + width: '250px', + collapsible: true, + defaultOpen: false, + }, + + 'search-panel': { + id: 'search-panel', + position: { + placement: 'right', + slot: 'main', + order: 0, + }, + content: { + type: 'component', + componentId: 'search-sidebar', + }, + width: '250px', + collapsible: true, + defaultOpen: false, + }, + }, + + // ───────────────────────────────────────────────────────── + // Selection Menus + // ───────────────────────────────────────────────────────── + selectionMenus: { + annotation: { + id: 'annotation', + items: [ + { + type: 'command-button', + id: 'delete-annotation', + commandId: 'annotation:delete-selected', + variant: 'icon', + }, + ], + }, + redaction: { + id: 'redaction', + items: [ + { + type: 'command-button', + id: 'delete-redaction', + commandId: 'redaction:delete-selected', + variant: 'icon', + }, + { + type: 'command-button', + id: 'commit-redaction', + commandId: 'redaction:commit-selected', + variant: 'icon', + }, + ], + }, + selection: { + id: 'selection', + items: [ + { + type: 'command-button', + id: 'copy-selection', + commandId: 'selection:copy', + variant: 'icon', + }, + ], + }, + }, +}; diff --git a/packages/embed-pdf/src/ui/index.ts b/packages/embed-pdf/src/ui/index.ts new file mode 100644 index 0000000..0dff825 --- /dev/null +++ b/packages/embed-pdf/src/ui/index.ts @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * UI Module + * + * This module provides schema-driven UI rendering capabilities for the PDF viewer. + * + * Components in this module are the app's custom renderers that are passed to UIProvider. + */ + +export * from './schema-toolbar'; +export * from './schema-menu'; +export * from './schema-panel'; diff --git a/packages/embed-pdf/src/ui/schema-menu.tsx b/packages/embed-pdf/src/ui/schema-menu.tsx new file mode 100644 index 0000000..0a1f4be --- /dev/null +++ b/packages/embed-pdf/src/ui/schema-menu.tsx @@ -0,0 +1,462 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { useEffect, useRef, useState } from 'react'; +import { + MenuRendererProps, + MenuItem, + useUISchema, + MenuSchema, + getUIItemProps, +} from '@embedpdf/plugin-ui/react'; +import { useCommand } from '@embedpdf/plugin-commands/react'; +import * as Icons from '../components/icons'; +import { cn } from '@repo/ui/lib/utils'; + +/** + * Schema-driven Menu Renderer + * + * Renders menus defined in the UI schema with responsive behavior: + * - Desktop: Anchored dropdown menu + * - Mobile: Bottom sheet modal with submenu navigation + * + * Visibility is controlled entirely by CSS via data attributes. + */ + +interface MenuStackItem { + menuId: string; + schema: MenuSchema; + title?: string; +} + +export function SchemaMenu({ schema, documentId, anchorEl, onClose }: MenuRendererProps) { + const menuRef = useRef(null); + const [isMobile, setIsMobile] = useState(false); + const [position, setPosition] = useState<{ + top: number; + left: number; + } | null>(null); + const uiSchema = useUISchema(); + + // Navigation stack for mobile submenus + const [menuStack, setMenuStack] = useState([ + { menuId: schema.id, schema, title: undefined }, + ]); + + // Reset stack when schema changes + useEffect(() => { + setMenuStack([{ menuId: schema.id, schema, title: undefined }]); + }, [schema]); + + const currentMenu = menuStack[menuStack.length - 1]; + + const navigateToSubmenu = (submenuId: string, title: string) => { + if (!uiSchema) return; + const submenuSchema = uiSchema.menus[submenuId]; + if (!submenuSchema) { + console.warn(`Submenu schema not found: ${submenuId}`); + return; + } + setMenuStack([...menuStack, { menuId: submenuId, schema: submenuSchema, title }]); + }; + + const navigateBack = () => { + if (menuStack.length > 1) { + setMenuStack(menuStack.slice(0, -1)); + } + }; + + // Detect mobile/desktop + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // Calculate menu position relative to anchor + useEffect(() => { + if (!anchorEl || isMobile) return; + + const updatePosition = () => { + const rect = anchorEl.getBoundingClientRect(); + const menuWidth = menuRef.current?.offsetWidth || 200; + + let top = rect.bottom + 4; + let left = rect.left; + + if (left + menuWidth > window.innerWidth) { + left = window.innerWidth - menuWidth - 8; + } + if (left < 8) { + left = 8; + } + + setPosition({ top, left }); + }; + + updatePosition(); + window.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, [anchorEl, isMobile]); + + // Close on outside click + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + anchorEl && + !anchorEl.contains(event.target as Node) + ) { + onClose(); + } + }; + + setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose, anchorEl]); + + // Close on escape key + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [onClose]); + + if (!currentMenu) return null; + + if (isMobile) { + return ( + <> + {/* Backdrop */} +
+ + {/* Bottom Sheet */} +
+ {/* Header */} + {menuStack.length > 1 ? ( +
+ + {currentMenu.title && ( + + {currentMenu.title} + + )} +
+ ) : ( +
+
+
+ )} + +
+ {currentMenu.schema.items.map((item, index) => ( + + ))} +
+
+ + ); + } + + // Desktop dropdown + return ( +
+ {/* Header for submenus */} + {menuStack.length > 1 && ( +
+ + {currentMenu.title && ( + + {currentMenu.title} + + )} +
+ )} + + {/* Menu items */} +
+ {currentMenu.schema.items.map((item, index) => ( + + ))} +
+
+ ); +} + +// ───────────────────────────────────────────────────────── +// Menu Item Renderer +// ───────────────────────────────────────────────────────── + +interface MenuItemRendererProps { + item: MenuItem; + documentId: string; + onClose: () => void; + isMobile: boolean; + onNavigateToSubmenu?: (submenuId: string, title: string) => void; +} + +function MenuItemRenderer({ + item, + documentId, + onClose, + isMobile, + onNavigateToSubmenu, +}: MenuItemRendererProps) { + switch (item.type) { + case 'command': + return ( + + ); + + case 'submenu': + return ( + + ); + + case 'divider': + return ( +
+
+
+ ); + + case 'section': + return ( + + ); + + default: + return null; + } +} + +// ───────────────────────────────────────────────────────── +// Command Menu Item +// ───────────────────────────────────────────────────────── + +function CommandMenuItem({ + item, + documentId, + onClose, + isMobile, +}: { + item: Extract; + documentId: string; + onClose: () => void; + isMobile: boolean; +}) { + const command = useCommand(item.commandId, documentId); + + if (!command || !command.visible) return null; + + const iconName = command.icon ? `${command.icon}Icon` : null; + const IconComponent = iconName ? Icons[iconName as keyof typeof Icons] : null; + + const baseClasses = isMobile + ? 'flex items-center gap-3 px-4 py-3 text-base transition-colors active:bg-accent/75 hover:text-accent-foreground' + : 'flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-accent/75 hover:text-accent-foreground'; + + const disabledClasses = command.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'; + const activeClasses = command.active + ? 'bg-accent text-accent-foreground' + : 'text-muted-foreground'; + + const handleClick = () => { + if (!command.disabled) { + command.execute(); + onClose(); + } + }; + + const iconProps = command.iconProps || {}; + + return ( + + ); +} + +// ───────────────────────────────────────────────────────── +// Submenu Item +// ───────────────────────────────────────────────────────── + +function SubmenuItem({ + item, + isMobile, + onNavigateToSubmenu, +}: { + item: Extract; + documentId: string; + isMobile: boolean; + onNavigateToSubmenu?: (submenuId: string, title: string) => void; +}) { + const iconName = item.icon ? `${item.icon}Icon` : null; + const IconComponent = iconName ? Icons[iconName as keyof typeof Icons] : null; + + const baseClasses = isMobile + ? 'flex items-center gap-3 px-4 py-3 text-base transition-colors active:bg-accent/75 hover:text-accent-foreground' + : 'flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-accent/75 hover:text-accent-foreground'; + + const handleClick = () => { + if (onNavigateToSubmenu) { + onNavigateToSubmenu(item.menuId, item.label || item.id); + } + }; + + return ( + + ); +} + +// ───────────────────────────────────────────────────────── +// Menu Section +// ───────────────────────────────────────────────────────── + +function MenuSection({ + item, + documentId, + onClose, + isMobile, + onNavigateToSubmenu, +}: { + item: Extract; + documentId: string; + onClose: () => void; + isMobile: boolean; + onNavigateToSubmenu?: (submenuId: string, title: string) => void; +}) { + return ( +
+ {(item.labelKey || item.label) && ( +
+ {item.label || item.id} +
+ )} + {item.items.map((subItem, index) => ( + + ))} +
+ ); +} diff --git a/packages/embed-pdf/src/ui/schema-panel.tsx b/packages/embed-pdf/src/ui/schema-panel.tsx new file mode 100644 index 0000000..04abe79 --- /dev/null +++ b/packages/embed-pdf/src/ui/schema-panel.tsx @@ -0,0 +1,425 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + SidebarRendererProps, + useUICapability, + useUIState, + useItemRenderer, +} from '@embedpdf/plugin-ui/react'; +import { useEffect, useMemo, useState, useRef } from 'react'; +import * as Icons from '../components/icons'; + +/** + * Schema-driven Panel Renderer + * + * Renders panels (sidebars) defined in the UI schema. + * - Desktop: Side panel (left/right) + * - Mobile: Bottom sheet with swipe gestures + * + * This is the app's custom panel renderer, passed to UIProvider. + */ + +type BottomSheetHeight = 'half' | 'full'; + +export function SchemaPanel({ schema, documentId, isOpen, onClose }: SidebarRendererProps) { + // Only render if open (allows for animation in the future) + if (!isOpen) return null; + const { position, content, width } = schema; + const { provides } = useUICapability(); + const uiState = useUIState(documentId); + const { renderCustomComponent } = useItemRenderer(); + + // Mobile detection - initialize immediately to prevent flash + const [isMobile, setIsMobile] = useState(() => { + if (typeof window !== 'undefined') { + return window.innerWidth < 768; + } + return false; + }); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // Bottom sheet state for mobile + const [sheetHeight, setSheetHeight] = useState('half'); + const panelRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [startY, setStartY] = useState(0); + const [currentY, setCurrentY] = useState(0); + + const positionClasses = getPositionClasses(position?.placement ?? 'left'); + const widthStyle = width ? { width } : undefined; + + const scope = useMemo( + () => (provides ? provides.forDocument(documentId) : null), + [provides, documentId], + ); + + // Swipe gesture handlers + const handleTouchStart = (e: React.TouchEvent) => { + if (!e.touches[0]) return; + setIsDragging(true); + setStartY(e.touches[0].clientY); + setCurrentY(e.touches[0].clientY); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging || !e.touches[0]) return; + setCurrentY(e.touches[0].clientY); + }; + + const handleTouchEnd = () => { + if (!isDragging) return; + setIsDragging(false); + + const deltaY = currentY - startY; + const threshold = 100; // pixels to trigger state change + + if (deltaY > threshold) { + // Swiped down + if (sheetHeight === 'full') { + setSheetHeight('half'); + } else { + onClose?.(); + } + } else if (deltaY < -threshold) { + // Swiped up + if (sheetHeight === 'half') { + setSheetHeight('full'); + } + } + + setStartY(0); + setCurrentY(0); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + setStartY(e.clientY); + setCurrentY(e.clientY); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + setCurrentY(e.clientY); + }; + + const handleMouseUp = () => { + if (!isDragging) return; + setIsDragging(false); + + const deltaY = currentY - startY; + const threshold = 100; + + if (deltaY > threshold) { + if (sheetHeight === 'full') { + setSheetHeight('half'); + } else { + onClose?.(); + } + } else if (deltaY < -threshold) { + if (sheetHeight === 'half') { + setSheetHeight('full'); + } + } + + setStartY(0); + setCurrentY(0); + }; + + // Mouse event listeners + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + return undefined; + }, [isDragging, currentY, startY, sheetHeight]); + + // Render mobile bottom sheet + if (isMobile) { + const heightClass = sheetHeight === 'full' ? 'h-[100vh]' : 'h-[50vh]'; + const dragOffset = isDragging ? Math.max(0, currentY - startY) : 0; + + if (content.type === 'tabs') { + const availableTabs = content.tabs ?? []; + + const resolvedActiveTabId = useMemo(() => { + const stateActive = uiState?.sidebarTabs?.[schema.id]; + if (stateActive) return stateActive; + const scopeActive = scope?.getSidebarTab?.(schema.id); + if (scopeActive) return scopeActive; + return stateActive ?? content.defaultTab ?? availableTabs[0]?.id ?? null; + }, [uiState?.sidebarTabs, scope, schema.id, content.defaultTab, availableTabs]); + + const [localActiveTabId, setLocalActiveTabId] = useState(null); + + useEffect(() => { + if (localActiveTabId !== null && resolvedActiveTabId === localActiveTabId) { + setLocalActiveTabId(null); + } + }, [resolvedActiveTabId, localActiveTabId]); + + const activeTabId = localActiveTabId ?? resolvedActiveTabId; + + const handleTabSelect = (tabId: string) => { + if (tabId === activeTabId) return; + setLocalActiveTabId(tabId); + + if (scope) { + scope.setSidebarTab(schema.id, tabId); + } + }; + + const activeTab = + availableTabs.find((tab) => tab.id === activeTabId) ?? + availableTabs.find((tab) => tab.id === resolvedActiveTabId) ?? + availableTabs[0]; + + if (!activeTab) { + console.warn(`No tabs defined for panel ${schema.id}`); + return null; + } + + return ( + <> + {/* Backdrop */} +
+ + {/* Bottom Sheet */} +
+ {/* Drag Handle & Header */} +
+
+
+
+ +
+ + {/* Tabs */} +
+ {availableTabs.map((tab) => { + const isActive = tab.id === (activeTab?.id ?? activeTabId); + return ( + + ); + })} +
+ + {/* Content */} +
+ {renderCustomComponent(activeTab.componentId, documentId, { + tabId: activeTab.id, + onClose, + })} +
+
+ + ); + } + + // Mobile: component-only panel + if (content.type === 'component') { + return ( + <> + {/* Backdrop */} +
+ + {/* Bottom Sheet */} +
+ {/* Drag Handle & Header */} +
+
+
+
+ +
+ + {/* Content */} +
+ {renderCustomComponent(content.componentId, documentId, { + onClose, + })} +
+
+ + ); + } + } + + // Desktop rendering + if (content.type === 'tabs') { + const availableTabs = content.tabs ?? []; + + const resolvedActiveTabId = useMemo(() => { + const stateActive = uiState?.sidebarTabs?.[schema.id]; + if (stateActive) return stateActive; + const scopeActive = scope?.getSidebarTab?.(schema.id); + if (scopeActive) return scopeActive; + return stateActive ?? content.defaultTab ?? availableTabs[0]?.id ?? null; + }, [uiState?.sidebarTabs, scope, schema.id, content.defaultTab, availableTabs]); + + const [localActiveTabId, setLocalActiveTabId] = useState(null); + + useEffect(() => { + if (localActiveTabId !== null && resolvedActiveTabId === localActiveTabId) { + setLocalActiveTabId(null); + } + }, [resolvedActiveTabId, localActiveTabId]); + + const activeTabId = localActiveTabId ?? resolvedActiveTabId; + + const handleTabSelect = (tabId: string) => { + if (tabId === activeTabId) return; + setLocalActiveTabId(tabId); + + if (scope) { + scope.setSidebarTab(schema.id, tabId); + } + }; + + const activeTab = + availableTabs.find((tab) => tab.id === activeTabId) ?? + availableTabs.find((tab) => tab.id === resolvedActiveTabId) ?? + availableTabs[0]; + + if (!activeTab) { + console.warn(`No tabs defined for panel ${schema.id}`); + return null; + } + + return ( +
+
+ {availableTabs.map((tab) => { + const isActive = tab.id === (activeTab?.id ?? activeTabId); + return ( + + ); + })} +
+ +
+ {renderCustomComponent(activeTab.componentId, documentId, { + tabId: activeTab.id, + onClose, + })} +
+
+ ); + } + + if (content.type === 'component') { + return ( +
+ {renderCustomComponent(content.componentId, documentId, { + onClose, + })} +
+ ); + } + + return null; +} + +/** + * Get positioning classes based on panel placement + */ +function getPositionClasses(placement: 'left' | 'right' | 'top' | 'bottom'): string { + switch (placement) { + case 'left': + return 'h-full border-r border-border bg-background'; + case 'right': + return 'h-full border-l border-border bg-background'; + case 'top': + return 'w-full border-b border-border bg-background'; + case 'bottom': + return 'w-full border-t border-border bg-background'; + default: + return 'h-full bg-background'; + } +} diff --git a/packages/embed-pdf/src/ui/schema-selection-menu.tsx b/packages/embed-pdf/src/ui/schema-selection-menu.tsx new file mode 100644 index 0000000..4c0f50c --- /dev/null +++ b/packages/embed-pdf/src/ui/schema-selection-menu.tsx @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + SelectionMenuRendererProps, + SelectionMenuItem, + SelectionMenuPropsBase, + getUIItemProps, +} from '@embedpdf/plugin-ui/react'; +import { CommandButton } from '../components/command-button'; + +export function SchemaSelectionMenu({ schema, documentId, props }: SelectionMenuRendererProps) { + const { menuWrapperProps, rect, placement } = props; + + // Calculate position + const menuStyle: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'auto', + cursor: 'default', + left: '50%', + transform: 'translateX(-50%)', + }; + + if (placement?.suggestTop) { + menuStyle.top = -40 - 8; + } else { + menuStyle.top = rect.size.height + 8; + } + + return ( +
+
+
+ {schema.items.map((item) => ( + + ))} +
+
+
+ ); +} + +function SelectionMenuItemRenderer({ + item, + documentId, + props, +}: { + item: SelectionMenuItem; + documentId: string; + props: SelectionMenuPropsBase; +}) { + switch (item.type) { + case 'command-button': + return ( +
+ +
+ ); + + case 'divider': + return ( +
+ + ); + + case 'group': + return ( +
+ {item.items.map((child) => ( + + ))} +
+ ); + + default: + return null; + } +} diff --git a/packages/embed-pdf/src/ui/schema-toolbar.tsx b/packages/embed-pdf/src/ui/schema-toolbar.tsx new file mode 100644 index 0000000..9b355df --- /dev/null +++ b/packages/embed-pdf/src/ui/schema-toolbar.tsx @@ -0,0 +1,247 @@ +/** + * SPDX-FileCopyrightText: 2026 TeamCoderz Ltd + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + ToolbarItem, + ToolbarRendererProps, + useItemRenderer, + getUIItemProps, +} from '@embedpdf/plugin-ui/react'; +import { CommandButton } from '../components/command-button'; +import { CommandTabButton } from '../components/command-tab-button'; +import { ToolbarDivider } from '../components/ui'; +import { cn } from '@repo/ui/lib/utils'; + +/** + * Schema-driven Toolbar Renderer + * + * Renders a toolbar based on a ToolbarSchema definition from the UI plugin. + * Visibility is controlled entirely by CSS via data attributes. + */ +export function SchemaToolbar({ + schema, + documentId, + isOpen, + className = '', +}: ToolbarRendererProps) { + if (!isOpen) { + return null; + } + + const isSecondarySlot = schema.position.slot === 'secondary'; + const placementClasses = getPlacementClasses(schema.position.placement); + const slotClasses = isSecondarySlot ? '' : ''; + + return ( +
+ {schema.items.map((item) => ( + + ))} +
+ ); +} + +/** + * Renders a single toolbar item + */ +function ToolbarItemRenderer({ item, documentId }: { item: ToolbarItem; documentId: string }) { + switch (item.type) { + case 'command-button': + return ; + + case 'tab-group': + return ; + + case 'divider': + return ; + + case 'spacer': + return ; + + case 'group': + return ; + + case 'custom': + return ; + + default: + console.warn(`Unknown toolbar item type:`, item); + return null; + } +} + +/** + * Renders a command button + */ +function CommandButtonRenderer({ + item, + documentId, +}: { + item: Extract; + documentId: string; +}) { + const variantClasses = getVariantClasses(item.variant); + + return ( +
+ +
+ ); +} + +/** + * Renders a tab group + */ +function TabGroupRenderer({ + item, + documentId, +}: { + item: Extract; + documentId: string; +}) { + const alignmentClass = getAlignmentClass(item.alignment); + + return ( +
+
+ {item.tabs.map((tab) => { + if (!tab.commandId) { + return null; + } + + return ( +
+ +
+ ); + })} +
+
+ ); +} + +/** + * Renders a divider + */ +function DividerRenderer({ item }: { item: Extract }) { + return ( +
+ +
+ ); +} + +/** + * Renders a spacer + */ +function SpacerRenderer({ item }: { item: Extract }) { + return ( +