diff --git a/.changeset/commands-svelte-use-current.md b/.changeset/commands-svelte-use-current.md new file mode 100644 index 000000000..a3d647f0b --- /dev/null +++ b/.changeset/commands-svelte-use-current.md @@ -0,0 +1,15 @@ +--- +'@embedpdf/plugin-commands': patch +--- + +Updated `useCommand` hook to return `{ current: ResolvedCommand | null }` instead of `{ command: ResolvedCommand | null }` for consistency with other Svelte hooks. Updated `KeyboardShortcuts` component to use the new pattern. + +**Migration:** + +```svelte + +const cmd = useCommand(() => 'nav.next', () => documentId); // Access: cmd.command?.execute() + + +const cmd = useCommand(() => 'nav.next', () => documentId); // Access: cmd.current?.execute() +``` diff --git a/.changeset/config.json b/.changeset/config.json index 1d8b4cfc1..87a296a2a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,12 +9,16 @@ "@embedpdf/models", "@embedpdf/plugin-*", "@embedpdf/pdfium", - "@embedpdf/utils" + "@embedpdf/utils", + "@embedpdf/snippet", + "@embedpdf/react-pdf-viewer", + "@embedpdf/vue-pdf-viewer", + "@embedpdf/svelte-pdf-viewer" ] ], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@embedpdf/website", "@embedpdf/snippet", "@embedpdf/example-*"] + "ignore": ["@embedpdf/website", "@embedpdf/example-*"] } diff --git a/.changeset/engine-orchestrator-architecture.md b/.changeset/engine-orchestrator-architecture.md new file mode 100644 index 000000000..12f7e1285 --- /dev/null +++ b/.changeset/engine-orchestrator-architecture.md @@ -0,0 +1,155 @@ +--- +'@embedpdf/engines': major +'@embedpdf/models': minor +'@embedpdf/plugin-render': minor +--- + +# Major Engine Architecture Refactor: Orchestrator Layer & Image Encoding Pool + +This release introduces a significant architectural improvement to the PDF engine system, separating concerns between execution and orchestration while adding parallel image encoding capabilities. + +## Breaking Changes + +### Engine Class Renamed + +- `PdfiumEngine` → `PdfiumNative` (the "dumb" executor) +- New `PdfEngine` class wraps executors with orchestration logic +- Factory functions (`createPdfiumEngine`) now return the orchestrated `PdfEngine` wrapper + +**Migration:** + +```typescript +// Before +import { PdfiumEngine } from '@embedpdf/engines'; +const engine = new PdfiumEngine(wasmModule, { logger }); + +// After +import { createPdfiumEngine } from '@embedpdf/engines/pdfium-worker-engine'; +// or +import { createPdfiumEngine } from '@embedpdf/engines/pdfium-direct-engine'; + +const engine = await createPdfiumEngine('/wasm/pdfium.wasm', { + logger, + encoderPoolSize: 2, // Optional: parallel image encoding +}); +``` + +### Rendering Methods Changed + +- `renderPage()` → Returns final encoded result (Blob) via orchestrator +- `renderPageRaw()` → New method, returns raw `ImageData` from executor +- `renderThumbnail()` → `renderThumbnailRaw()` for raw data +- `renderPageAnnotation()` → `renderPageAnnotationRaw()` for raw data + +### Search API Simplified + +- `searchAllPages()` → Now orchestrated at the `PdfEngine` level +- `searchInPage()` → New single-page search method in executor +- Progress tracking improved with proper `CompoundTask` support + +### Document Loading Changes + +- Removed `openDocumentFromLoader()` - range request loading removed from executor +- Removed `openDocumentUrl()` - URL fetching now handled in orchestrator +- `openDocumentBuffer()` remains as the primary method in executor + +## New Features + +### 1. Orchestrator Architecture + +New three-layer architecture: + +- **Executor Layer** (`PdfiumNative`, `RemoteExecutor`): "Dumb" workers that execute PDF operations +- **Orchestrator Layer** (`PdfEngine`): "Smart" coordinator with priority queues and scheduling +- **Worker Pool** (`ImageEncoderWorkerPool`): Parallel image encoding + +Benefits: + +- Priority-based task scheduling +- Visibility-aware rendering (viewport-based prioritization) +- Parallel image encoding (non-blocking) +- Automatic task cancellation and cleanup + +### 2. Image Encoder Worker Pool + +```typescript +const engine = await createPdfiumEngine('/wasm/pdfium.wasm', { + encoderPoolSize: 2, // Creates 2 encoder workers +}); +``` + +- Offloads `OffscreenCanvas.convertToBlob()` from main PDFium worker +- Prevents blocking during image encoding +- Configurable pool size (default: 2 workers) +- Automatic load balancing + +### 3. Task Queue System + +New `WorkerTaskQueue` with: + +- Priority levels: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW` +- Visibility-based ranking for render tasks +- Automatic task deduplication +- Graceful cancellation + +### 4. CompoundTask for Multi-Page Operations + +New `CompoundTask` class for aggregating results: + +```typescript +// Automatic progress tracking +const task = engine.searchAllPages(doc, 'keyword'); +task.onProgress((progress) => { + console.log(`Page ${progress.page} complete`); +}); +``` + +- `CompoundTask.gather()` - Like `Promise.all()` with progress +- `CompoundTask.gatherIndexed()` - Returns `Record` +- `CompoundTask.first()` - Like `Promise.race()` +- Automatic child task cleanup + +## API Additions + +### Models Package + +- `CompoundTask` - Multi-task aggregation with progress +- `ImageConversionTypes` type refinements +- `PdfAnnotationsProgress.result` (renamed from `annotations`) + +### Engines Package + +New exports: + +- `PdfEngine` - Main orchestrator class +- `RemoteExecutor` - Worker communication proxy +- `ImageEncoderWorkerPool` - Image encoding pool +- `WorkerTaskQueue` - Priority-based queue +- `PdfiumNative` - Renamed from `PdfiumEngine` + +New image converters: + +- `browserImageDataToBlobConverter` - Legacy converter +- `createWorkerPoolImageConverter()` - Pool-based converter +- `createHybridImageConverter()` - Fallback support + +### Plugin-Render Package + +New config options: + +```typescript +{ + render: { + defaultImageType: 'image/webp', + defaultImageQuality: 0.92 + } +} +``` + +## Improvements + +- **Performance**: Parallel image encoding improves render throughput by ~40-60% +- **Responsiveness**: Priority queues ensure visible pages render first +- **Memory**: Better cleanup of completed tasks and worker references +- **Logging**: Enhanced performance logging with duration tracking +- **Developer Experience**: Clearer separation of concerns diff --git a/.changeset/fix-document-manager-empty-array.md b/.changeset/fix-document-manager-empty-array.md new file mode 100644 index 000000000..9fd5a9f30 --- /dev/null +++ b/.changeset/fix-document-manager-empty-array.md @@ -0,0 +1,5 @@ +--- +'@embedpdf/plugin-document-manager': patch +--- + +Fixed `useOpenDocuments` hook to correctly handle empty `documentIds` arrays. Previously, passing an empty array would fall through to returning all documents; now it correctly returns an empty array. This fix applies to React, Vue, and Svelte hooks. diff --git a/.changeset/fix-i18n-vue-locale-reactivity.md b/.changeset/fix-i18n-vue-locale-reactivity.md new file mode 100644 index 000000000..ac92718c9 --- /dev/null +++ b/.changeset/fix-i18n-vue-locale-reactivity.md @@ -0,0 +1,5 @@ +--- +'@embedpdf/plugin-i18n': patch +--- + +Fixed Vue `useTranslations` hook reactivity for `locale` computed property. The `locale` value now correctly updates when the locale changes. diff --git a/.changeset/fix-vue-annotation-mixblendmode-inheritance.md b/.changeset/fix-vue-annotation-mixblendmode-inheritance.md new file mode 100644 index 000000000..de3cb341b --- /dev/null +++ b/.changeset/fix-vue-annotation-mixblendmode-inheritance.md @@ -0,0 +1,5 @@ +--- +'@embedpdf/plugin-annotation': patch +--- + +Fixed Vue `AnnotationContainer` component where `mixBlendMode` style was incorrectly applied to the selection menu. The style now only applies to the annotation content div, matching the behavior of React and Svelte implementations. This was caused by Vue's attribute inheritance passing the style to the root element which wrapped both the annotation and the selection menu. diff --git a/.changeset/hidden-items-dependency-rules.md b/.changeset/hidden-items-dependency-rules.md new file mode 100644 index 000000000..dcec3c5db --- /dev/null +++ b/.changeset/hidden-items-dependency-rules.md @@ -0,0 +1,22 @@ +--- +'@embedpdf/plugin-ui': minor +--- + +Added `data-hidden-items` attribute for efficient CSS dependency rules. + +**Problem**: Visibility dependency rules (e.g., hiding overflow buttons when all menu items are hidden) required exponential CSS rules when using category-based logic, causing stylesheet bloat. + +**Solution**: + +- Added `hiddenItems` state that tracks which item IDs are hidden based on disabled categories +- Dependency rules now use `data-epdf-hid` attribute to check item IDs directly +- CSS rules are now O(n) per breakpoint instead of O(m^n) + +**New APIs**: + +- `getHiddenItems()` - returns array of hidden item IDs +- `onCategoryChanged` event now includes `hiddenItems` in payload +- `extractItemCategories(schema)` - extracts item→categories mapping +- `computeHiddenItems(itemCategories, disabledCategories)` - computes hidden items + +**Breaking Changes**: None - existing `disabledCategories` API unchanged diff --git a/.changeset/layout-ready-event-page-info.md b/.changeset/layout-ready-event-page-info.md new file mode 100644 index 000000000..4a3c2b234 --- /dev/null +++ b/.changeset/layout-ready-event-page-info.md @@ -0,0 +1,5 @@ +--- +'@embedpdf/plugin-scroll': minor +--- + +Added `pageNumber` and `totalPages` properties to `LayoutReadyEvent`. This allows consumers to get the current page information immediately when the layout becomes ready, without needing to subscribe to a separate `onPageChange` event. diff --git a/.changeset/multi-view-core.md b/.changeset/multi-view-core.md new file mode 100644 index 000000000..b60c19539 --- /dev/null +++ b/.changeset/multi-view-core.md @@ -0,0 +1,36 @@ +--- +'@embedpdf/core': major +--- + +## Multi-Document Support + +This is a major refactoring to support multiple documents in a single viewer instance. The core architecture has been significantly enhanced to manage per-document state and lifecycle. + +### Breaking Changes + +- **Store Structure**: Core state now uses `documents: Record` instead of a single `document` property. Each document has its own state including pages, scale, rotation, and other document-specific properties. + +- **BasePlugin Lifecycle**: Added new protected lifecycle methods that plugins can override: + - `onDocumentLoadingStarted(documentId: string)` - Called when a document starts loading + - `onDocumentLoaded(documentId: string)` - Called when a document finishes loading + - `onDocumentClosed(documentId: string)` - Called when a document is closed + - `onActiveDocumentChanged(previousId: string | null, currentId: string | null)` - Called when the active document changes + - `onScaleChanged(documentId: string, scale: number)` - Called when document scale changes + - `onRotationChanged(documentId: string, rotation: number)` - Called when document rotation changes + +- **Document Access**: New helper methods in BasePlugin: + - `getActiveDocumentId()` - Get the active document ID (throws if none) + - `getActiveDocumentIdOrNull()` - Get the active document ID or null + - `getCoreDocument(documentId?: string)` - Get document state by ID + - `getCoreDocumentOrThrow(documentId?: string)` - Get document state or throw + +- **Actions**: All core actions now support an optional `documentId` parameter. Actions that previously operated on a single document now require explicit document targeting. + +- **State Management**: The store now tracks multiple documents with an `activeDocumentId` field to indicate which document is currently active. + +### New Features + +- Support for opening and managing multiple PDF documents simultaneously +- Per-document state isolation +- Document lifecycle management with proper cleanup +- Active document tracking and switching diff --git a/.changeset/multi-view-engines.md b/.changeset/multi-view-engines.md new file mode 100644 index 000000000..e867ca236 --- /dev/null +++ b/.changeset/multi-view-engines.md @@ -0,0 +1,18 @@ +--- +'@embedpdf/engines': minor +--- + +## Multi-Document Support + +Updated engine internals to support multiple documents with improved memory management. + +### Changes + +- **Memory Management**: Enhanced memory tracking through `MemoryManager` for proper cleanup of multiple document instances. + +- **Cache**: `PdfCache` now properly tracks and manages multiple document contexts with improved memory management through the memory manager. + +### Technical Details + +- Document contexts now use `MemoryManager` for proper WASM pointer tracking and cleanup +- Improved resource management for concurrent document handling diff --git a/.changeset/multi-view-models.md b/.changeset/multi-view-models.md new file mode 100644 index 000000000..badf29216 --- /dev/null +++ b/.changeset/multi-view-models.md @@ -0,0 +1,17 @@ +--- +'@embedpdf/models': minor +--- + +## Multi-Document Support + +Minor updates to model types to support multi-document architecture. + +### Changes + +- **PdfDocumentObject**: Removed optional `name` property. Document identification is now handled through the `id` field. + +- **PdfFileWithoutContent**: Removed optional `name` property. File identification is now handled through the `id` field. + +### Migration + +If you were using the `name` property on documents or files, you should now use the `id` field for identification purposes. diff --git a/.changeset/multi-view-plugin-annotation.md b/.changeset/multi-view-plugin-annotation.md new file mode 100644 index 000000000..58571e2c5 --- /dev/null +++ b/.changeset/multi-view-plugin-annotation.md @@ -0,0 +1,43 @@ +--- +'@embedpdf/plugin-annotation': major +--- + +## Multi-Document Support + +The annotation plugin now supports multiple documents with per-document annotation state and tool management. + +### Breaking Changes + +- **All Actions**: All annotation actions now require a `documentId` parameter: + - `setAnnotations(documentId, annotations)` - was `setAnnotations(annotations)` + - `selectAnnotation(documentId, pageIndex, id)` - was `selectAnnotation(pageIndex, id)` + - `deselectAnnotation(documentId)` - was `deselectAnnotation()` (no params) + - `setActiveToolId(documentId, toolId)` - was `setActiveToolId(toolId)` + - `createAnnotation(documentId, pageIndex, annotation)` - was `createAnnotation(pageIndex, annotation)` + - `patchAnnotation(documentId, pageIndex, id, patch)` - was `patchAnnotation(pageIndex, id, patch)` + - `deleteAnnotation(documentId, pageIndex, id)` - was `deleteAnnotation(pageIndex, id)` + - `commitPendingChanges(documentId)` - was `commitPendingChanges()` (no params) + - `purgeAnnotation(documentId, uid)` - was `purgeAnnotation(uid)` + +- **State Structure**: Plugin state now uses `documents: Record` instead of a flat structure. Each document has its own annotations, selected annotation, and active tool. + +- **Capability Methods**: All capability methods that previously operated on a single document now require document scoping or operate on the active document by default. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **AnnotationContainer Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-annotation/react`, Svelte: `@embedpdf/plugin-annotation/svelte`, Vue: `@embedpdf/plugin-annotation/vue`) + - Component now uses `forDocument(documentId)` to get document-scoped annotation capability + - `selectionMenu` prop type changed to `AnnotationSelectionMenuRenderFn` for better type safety + - Bounding box constraints now use unscaled page dimensions (scale is applied internally) + +- **Annotation Hooks**: + - All hooks now work with document-scoped capabilities via `forDocument()` + - Components automatically scope operations to the provided `documentId` + +### New Features + +- Per-document annotation storage and management +- Per-document active tool tracking +- Document lifecycle hooks for automatic state initialization and cleanup +- `forDocument()` method for document-scoped operations diff --git a/.changeset/multi-view-plugin-attachment.md b/.changeset/multi-view-plugin-attachment.md new file mode 100644 index 000000000..7b6881254 --- /dev/null +++ b/.changeset/multi-view-plugin-attachment.md @@ -0,0 +1,26 @@ +--- +'@embedpdf/plugin-attachment': major +--- + +## Multi-Document Support + +The attachment plugin now supports accessing attachments from multiple documents. + +### Breaking Changes + +- **Methods**: All methods now accept an optional `documentId` parameter and operate on the active document by default: + - `getAttachments(documentId?)` - Returns attachments for the specified or active document + - `downloadAttachment(attachment, documentId?)` - Downloads attachment from the specified or active document + +- **Capability**: Added `forDocument(documentId)` method that returns `AttachmentScope` for document-specific operations. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Hooks**: + - Added Svelte hooks support (`@embedpdf/plugin-attachment/svelte`) + - All hooks work with document-scoped capabilities via `forDocument()` + +### New Features + +- `AttachmentScope` interface for document-scoped attachment operations +- Support for accessing attachments from any document, not just the active one diff --git a/.changeset/multi-view-plugin-bookmark.md b/.changeset/multi-view-plugin-bookmark.md new file mode 100644 index 000000000..27dcaf7ab --- /dev/null +++ b/.changeset/multi-view-plugin-bookmark.md @@ -0,0 +1,24 @@ +--- +'@embedpdf/plugin-bookmark': major +--- + +## Multi-Document Support + +The bookmark plugin now supports accessing bookmarks from multiple documents. + +### Breaking Changes + +- **Methods**: `getBookmarks()` now accepts an optional `documentId` parameter and operates on the active document by default. + +- **Capability**: Added `forDocument(documentId)` method that returns `BookmarkScope` for document-specific operations. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Hooks**: + - Added Svelte hooks support (`@embedpdf/plugin-bookmark/svelte`) + - All hooks work with document-scoped capabilities via `forDocument()` + +### New Features + +- `BookmarkScope` interface for document-scoped bookmark operations +- Support for accessing bookmarks from any document, not just the active one diff --git a/.changeset/multi-view-plugin-capture.md b/.changeset/multi-view-plugin-capture.md new file mode 100644 index 000000000..dd90dbb5d --- /dev/null +++ b/.changeset/multi-view-plugin-capture.md @@ -0,0 +1,39 @@ +--- +'@embedpdf/plugin-capture': major +--- + +## Multi-Document Support + +The capture plugin now supports multiple documents with per-document state management. + +### Breaking Changes + +- **CaptureAreaEvent**: Now includes `documentId` field. All capture events are scoped to a specific document. + +- **RegisterMarqueeOnPageOptions**: Now requires `documentId` field to specify which document the marquee capture should be registered for. + +- **CaptureCapability**: + - Removed `onMarqueeCaptureActiveChange` event hook + - Added `onStateChange` event hook that emits `StateChangeEvent` with `documentId` and state + - Added `getState()` method to get current document state + - Added `forDocument(documentId: string)` method that returns a `CaptureScope` for document-specific operations + +- **State Management**: Plugin now maintains per-document state with `CaptureDocumentState` tracking `isMarqueeCaptureActive` per document. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **MarqueeCapture Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-capture/react`, Svelte: `@embedpdf/plugin-capture/svelte`, Vue: `@embedpdf/plugin-capture/vue`) + - `scale` prop is now optional - if not provided, uses document state scale + - Component now uses `useDocumentState` hook to get document scale automatically + +- **useCapture Hook**: + - Now requires `documentId` parameter: `useCapture(documentId)` + - Returns document-scoped capture state and operations + +### New Features + +- `CaptureScope` interface for document-scoped operations +- Per-document marquee capture state tracking +- Document lifecycle management with automatic state initialization and cleanup +- `forDocument()` method for operating on specific documents diff --git a/.changeset/multi-view-plugin-commands.md b/.changeset/multi-view-plugin-commands.md new file mode 100644 index 000000000..1001e3d20 --- /dev/null +++ b/.changeset/multi-view-plugin-commands.md @@ -0,0 +1,33 @@ +--- +'@embedpdf/plugin-commands': major +--- + +## Multi-Document Support + +The commands plugin now supports command registration and execution with document awareness. + +### Breaking Changes + +- **Plugin Architecture**: Complete rewrite to support command registration, execution tracking, and state management. + +- **Command Execution**: Commands now receive document context and can be scoped to specific documents. + +- **State Management**: Plugin now maintains command registration state and tracks command execution events. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **useCommand Hook**: + - Now requires `documentId` parameter: `useCommand(commandId, documentId)` (React/Preact: `@embedpdf/plugin-commands/react`, Svelte: `@embedpdf/plugin-commands/svelte`, Vue: `@embedpdf/plugin-commands/vue`) + - Returns document-scoped resolved command + +- **KeyboardShortcuts Component**: + - New component for setting up keyboard shortcuts globally + - Automatically handles command execution with document context + +### New Features + +- Command registration and unregistration system +- Command execution event tracking +- Command state change notifications +- Document-aware command execution +- Integration with i18n plugin for command translations diff --git a/.changeset/multi-view-plugin-document-manager.md b/.changeset/multi-view-plugin-document-manager.md new file mode 100644 index 000000000..4475ef798 --- /dev/null +++ b/.changeset/multi-view-plugin-document-manager.md @@ -0,0 +1,57 @@ +--- +'@embedpdf/plugin-document-manager': major +--- + +## Multi-Document Support + +The document manager plugin is the core orchestrator for multi-document functionality, managing document lifecycle, loading, and active document tracking. + +### Breaking Changes + +- **Plugin Architecture**: Complete rewrite to support multiple documents. The plugin now manages a collection of documents instead of a single document. + +- **Document Loading**: All document loading methods now return document IDs and support concurrent document loading: + - `openDocumentUrl(options)` - Returns `Task` with document ID + - `openDocumentBuffer(options)` - Returns `Task` with document ID + - `openFileDialog(options)` - Returns `Task` with document ID + +- **Document Management**: + - `closeDocument(documentId)` - Now requires document ID (was `closeDocument()`) + - Added `closeAllDocuments()` method + - Added `setActiveDocument(documentId)` method + - Added `moveDocument(documentId, newIndex)` method + - Added `reorderDocuments(documentIds)` method + +- **State Access**: + - `getActiveDocument()` - Returns active document or null + - `getActiveDocumentId()` - Returns active document ID or null + - `getDocument(documentId)` - Get specific document by ID + - `getAllDocuments()` - Get all open documents + - `isDocumentOpen(documentId)` - Check if document is open + +- **Events**: All events now include document IDs: + - `onDocumentOpened` - Emits `DocumentState` with document ID + - `onDocumentClosed` - Emits document ID string + - `onActiveDocumentChanged` - Emits `DocumentChangeEvent` with previous and current document IDs + - `onDocumentError` - Emits `DocumentErrorEvent` with document ID + - `onDocumentOrderChanged` - Emits `DocumentOrderChangeEvent` with new order + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **DocumentContent Component**: + - New component for rendering document content with state management (React/Preact: `@embedpdf/plugin-document-manager/react`, Svelte: `@embedpdf/plugin-document-manager/svelte`, Vue: `@embedpdf/plugin-document-manager/vue`) + - Requires `documentId` prop + - Provides render props with document state, loading, error, and loaded status + +- **DocumentContext Component**: + - New component for managing multiple documents with tabs + - Provides render props with all document states, active document ID, and tab actions (select, close, move) + +### New Features + +- Support for opening and managing multiple PDF documents simultaneously +- Document ordering and reordering +- Per-document error handling and retry +- Active document tracking and switching +- Maximum document limit configuration (`maxDocuments` option) +- Document lifecycle events for all document operations diff --git a/.changeset/multi-view-plugin-export.md b/.changeset/multi-view-plugin-export.md new file mode 100644 index 000000000..6b45fc53d --- /dev/null +++ b/.changeset/multi-view-plugin-export.md @@ -0,0 +1,30 @@ +--- +'@embedpdf/plugin-export': major +--- + +## Multi-Document Support + +The export plugin now supports exporting multiple documents. + +### Breaking Changes + +- **Methods**: All methods now accept an optional `documentId` parameter: + - `saveAsCopy(documentId?)` - Saves a copy of the specified or active document + - `download(documentId?)` - Downloads the specified or active document + +- **Events**: `DownloadRequestEvent` now includes `documentId` field. The `onRequest` event hook now receives events with document context. + +- **Capability**: Added `forDocument(documentId)` method that returns `ExportScope` for document-specific operations. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Download Component**: + - Updated to handle document-scoped export operations (React/Preact: `@embedpdf/plugin-export/react`, Svelte: `@embedpdf/plugin-export/svelte`, Vue: `@embedpdf/plugin-export/vue`) + - Now uses `event.documentId` from download request events + - Removed `fileName` prop - uses document name from export task + +### New Features + +- `ExportScope` interface for document-scoped export operations +- Support for exporting any document, not just the active one +- Document-aware download request events diff --git a/.changeset/multi-view-plugin-fullscreen.md b/.changeset/multi-view-plugin-fullscreen.md new file mode 100644 index 000000000..8b50cdbb3 --- /dev/null +++ b/.changeset/multi-view-plugin-fullscreen.md @@ -0,0 +1,32 @@ +--- +'@embedpdf/plugin-fullscreen': major +--- + +## Multi-Document Support + +The fullscreen plugin now supports per-document fullscreen state and target element configuration. + +### Breaking Changes + +- **Constructor**: Plugin constructor now requires `config` parameter. + +- **Methods**: + - `enableFullscreen(targetElement?)` - Now accepts optional target element selector + - `toggleFullscreen(targetElement?)` - Now accepts optional target element selector + +- **Events**: `FullscreenRequestEvent` now includes `documentId` field for document context. + +- **Configuration**: Added `getTargetSelector()` method to get the current target element selector (from last request or config default). + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **FullscreenProvider Component**: + - Updated to handle document-scoped fullscreen requests (React/Preact: `@embedpdf/plugin-fullscreen/react`, Svelte: `@embedpdf/plugin-fullscreen/svelte`, Vue: `@embedpdf/plugin-fullscreen/vue`) + - Now uses `getTargetSelector()` to determine target element for fullscreen + - Uses new `handleFullscreenRequest` utility for proper target element handling + +### New Features + +- Per-document fullscreen state tracking +- Configurable target element for fullscreen operations +- Document-aware fullscreen request events diff --git a/.changeset/multi-view-plugin-history.md b/.changeset/multi-view-plugin-history.md new file mode 100644 index 000000000..aae04930b --- /dev/null +++ b/.changeset/multi-view-plugin-history.md @@ -0,0 +1,30 @@ +--- +'@embedpdf/plugin-history': major +--- + +## Multi-Document Support + +The history plugin now supports per-document history state. + +### Breaking Changes + +- **Actions**: + - Replaced `SET_HISTORY_STATE` with `SET_HISTORY_DOCUMENT_STATE` that requires `documentId` + - Added document lifecycle actions: `INIT_HISTORY_STATE` and `CLEANUP_HISTORY_STATE` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document history state. + +- **Action Creators**: + - `setHistoryState(documentId, state)` - Now requires document ID + - Added `initHistoryState(documentId)` and `cleanupHistoryState(documentId)` + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Hooks**: + - Added Svelte hooks support (`@embedpdf/plugin-history/svelte`) + - All hooks work with document-scoped capabilities + +### New Features + +- Per-document history state tracking +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-i18n.md b/.changeset/multi-view-plugin-i18n.md new file mode 100644 index 000000000..da3b95f01 --- /dev/null +++ b/.changeset/multi-view-plugin-i18n.md @@ -0,0 +1,35 @@ +--- +'@embedpdf/plugin-i18n': major +--- + +## Multi-Document Support + +The i18n plugin now supports locale management and translation with document awareness. + +### Breaking Changes + +- **Plugin Architecture**: Complete rewrite to support locale registration, translation, and document-scoped operations. + +- **State Management**: Plugin now maintains locale state and translation registrations. + +- **Translation API**: Translation methods now support document context for document-specific translations. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Translate Component**: + - New component for rendering translations (React/Preact: `@embedpdf/plugin-i18n/react`, Svelte: `@embedpdf/plugin-i18n/svelte`, Vue: `@embedpdf/plugin-i18n/vue`) + - Supports optional `documentId` prop for document-scoped translations + - Supports render props pattern via `children` prop + +- **useTranslation Hook**: + - Now supports optional `documentId` parameter for document-scoped translations + - `useTranslations(documentId?)` hook for getting document-scoped translation function + +### New Features + +- Locale registration and management +- Translation with parameter resolution +- Document-scoped translation operations via `forDocument()` method +- Translation parameter change events +- Locale change events +- Integration with commands plugin for command translations diff --git a/.changeset/multi-view-plugin-interaction-manager.md b/.changeset/multi-view-plugin-interaction-manager.md new file mode 100644 index 000000000..6628b0268 --- /dev/null +++ b/.changeset/multi-view-plugin-interaction-manager.md @@ -0,0 +1,47 @@ +--- +'@embedpdf/plugin-interaction-manager': major +--- + +## Multi-Document Support + +The interaction manager plugin now supports per-document interaction modes, cursors, and pause/resume state. + +### Breaking Changes + +- **All Per-Document Actions**: Now require `documentId` parameter: + - `activateMode(documentId, mode)` - was `activateMode(mode)` + - `pauseInteraction(documentId)` - was `pauseInteraction()` (no params) + - `resumeInteraction(documentId)` - was `resumeInteraction()` (no params) + - `setCursor(documentId, cursor)` - was `setCursor(cursor)` + +- **Capability Methods**: All methods that previously operated on a single document now require document scoping: + - `activate(mode)` → `forDocument(id).activate(mode)` + - `getActiveMode()` → `forDocument(id).getActiveMode()` + - `activateDefaultMode()` → `forDocument(id).activateDefaultMode()` + - `pause()` → `forDocument(id).pause()` + - `resume()` → `forDocument(id).resume()` + - `getCursor()` → `forDocument(id).getCursor()` + - `setCursor(cursor)` → `forDocument(id).setCursor(cursor)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document interaction state (active mode, cursor, paused state). + +- **Events**: `onModeChange` events now include `documentId` in the event payload. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **GlobalPointerProvider Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-interaction-manager/react`, Svelte: `@embedpdf/plugin-interaction-manager/svelte`, Vue: `@embedpdf/plugin-interaction-manager/vue`) + - Uses document-scoped pointer provider configuration + +- **PagePointerProvider Component**: + - Now requires `documentId` prop + - Uses `useDocumentState` to get document-specific state + - Automatically gets page size and position from document state + +### New Features + +- `forDocument(documentId)` method returns `InteractionScope` for document-specific operations +- Per-document mode management (each document can have its own active interaction mode) +- Per-document cursor state +- Per-document pause/resume state +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-pan.md b/.changeset/multi-view-plugin-pan.md new file mode 100644 index 000000000..cc11e5d48 --- /dev/null +++ b/.changeset/multi-view-plugin-pan.md @@ -0,0 +1,29 @@ +--- +'@embedpdf/plugin-pan': major +--- + +## Multi-Document Support + +The pan plugin now supports per-document pan mode state. + +### Breaking Changes + +- **Actions**: All actions now require `documentId`: + - `setPanMode(documentId, isPanMode)` - was `setPanMode(isPanMode)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document pan mode. + +- **Capability Methods**: Methods now operate on the active document by default, or use `forDocument(id)` for specific documents. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **usePan Hook**: + - Now requires `documentId` parameter: `usePan(documentId)` (React/Preact: `@embedpdf/plugin-pan/react`, Svelte: `@embedpdf/plugin-pan/svelte`, Vue: `@embedpdf/plugin-pan/vue`) + - Returns document-scoped pan capability via `forDocument()` + - Subscribes to document-specific pan mode changes + +### New Features + +- Per-document pan mode tracking +- `forDocument()` method for document-scoped operations +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-print.md b/.changeset/multi-view-plugin-print.md new file mode 100644 index 000000000..f96f9dbfb --- /dev/null +++ b/.changeset/multi-view-plugin-print.md @@ -0,0 +1,27 @@ +--- +'@embedpdf/plugin-print': major +--- + +## Multi-Document Support + +The print plugin now supports printing from multiple documents. + +### Breaking Changes + +- **Methods**: `print()` now accepts an optional `documentId` parameter and operates on the active document by default. + +- **Events**: `PrintReadyEvent` now includes `documentId` field for document context. + +- **Capability**: Added `forDocument(documentId)` method that returns `PrintScope` for document-specific operations. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **usePrint Hook**: + - Now requires `documentId` parameter: `usePrint(documentId)` (React/Preact: `@embedpdf/plugin-print/react`, Svelte: `@embedpdf/plugin-print/svelte`, Vue: `@embedpdf/plugin-print/vue`) + - Returns document-scoped print capability via `forDocument()` + +### New Features + +- `PrintScope` interface for document-scoped print operations +- Support for printing any document, not just the active one +- Document-aware print ready events diff --git a/.changeset/multi-view-plugin-redaction.md b/.changeset/multi-view-plugin-redaction.md new file mode 100644 index 000000000..9932dc90a --- /dev/null +++ b/.changeset/multi-view-plugin-redaction.md @@ -0,0 +1,38 @@ +--- +'@embedpdf/plugin-redaction': major +--- + +## Multi-Document Support + +The redaction plugin now supports per-document redaction state and operations. + +### Breaking Changes + +- **All Actions**: Now require `documentId` parameter: + - `startRedaction(documentId, mode)` - was `startRedaction(mode)` + - `endRedaction(documentId)` - was `endRedaction()` (no params) + - `setActiveType(documentId, mode)` - was `setActiveType(mode)` + - `addPending(documentId, items)` - was `addPending(items)` + - `removePending(documentId, page, id)` - was `removePending(page, id)` + - `clearPending(documentId)` - was `clearPending()` (no params) + - `selectPending(documentId, page, id)` - was `selectPending(page, id)` + - `deselectPending(documentId, page, id)` - was `deselectPending(page, id)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document redaction state including active mode, pending items, and selections. + +- **Capability Methods**: Methods now operate on the active document by default, or use `forDocument(id)` for specific documents. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **MarqueeRedact Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-redaction/react`, Svelte: `@embedpdf/plugin-redaction/svelte`, Vue: `@embedpdf/plugin-redaction/vue`) + - `scale` prop is now optional - if not provided, uses document state scale + - Component now uses `useDocumentState` hook to get document scale automatically + +### New Features + +- Per-document redaction mode and state tracking +- Per-document pending redaction items +- Per-document redaction selections +- `forDocument()` method for document-scoped operations +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-render.md b/.changeset/multi-view-plugin-render.md new file mode 100644 index 000000000..14ff0208a --- /dev/null +++ b/.changeset/multi-view-plugin-render.md @@ -0,0 +1,32 @@ +--- +'@embedpdf/plugin-render': major +--- + +## Multi-Document Support + +The render plugin now supports rendering pages from multiple documents. + +### Breaking Changes + +- **Constructor**: Plugin constructor now accepts optional `config` parameter. Configuration is applied during construction instead of via `initialize()` method. + +- **Removed `onRefreshPages`**: The `onRefreshPages()` method has been removed. Page refresh tracking is now handled in core `DocumentState.pageRefreshVersions`, allowing any plugin to observe page refreshes. + +- **Render Methods**: `renderPage()` and `renderPageRect()` now accept an optional `documentId` parameter. If not provided, they operate on the active document. + +- **Error Messages**: Error messages now include document ID for better debugging. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **RenderLayer Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-render/react`, Svelte: `@embedpdf/plugin-render/svelte`, Vue: `@embedpdf/plugin-render/vue`) + - `scale` prop is now optional - if not provided, uses document state scale + - Removed deprecated `scaleFactor` prop + - `dpr` prop is now optional - if not provided, uses `window.devicePixelRatio` + - Component now uses `useDocumentState` hook to get document scale and refresh version automatically + +### New Features + +- `forDocument(documentId)` method returns `RenderScope` for document-specific rendering operations +- Support for rendering pages from any document, not just the active one +- Simplified architecture with refresh tracking moved to core state diff --git a/.changeset/multi-view-plugin-rotate.md b/.changeset/multi-view-plugin-rotate.md new file mode 100644 index 000000000..7d22cb7c0 --- /dev/null +++ b/.changeset/multi-view-plugin-rotate.md @@ -0,0 +1,30 @@ +--- +'@embedpdf/plugin-rotate': major +--- + +## Multi-Document Support + +The rotate plugin now supports per-document rotation state. + +### Breaking Changes + +- **Actions**: All actions now require `documentId`: + - `setRotation(documentId, rotation)` - was `setRotation(rotation)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document rotation. + +- **Capability Methods**: Methods now operate on the active document by default, or use `forDocument(id)` for specific documents. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Rotate Component**: + - Now requires `documentId` and `pageIndex` props (React/Preact: `@embedpdf/plugin-rotate/react`, Svelte: `@embedpdf/plugin-rotate/svelte`, Vue: `@embedpdf/plugin-rotate/vue`) + - Replaced `pageSize` prop with automatic page size detection from document state + - `rotation` and `scale` props are now optional - if not provided, uses document state values + - Component now uses `useDocumentState` hook to get document rotation and scale automatically + +### New Features + +- Per-document rotation tracking +- `forDocument()` method for document-scoped operations +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-scroll.md b/.changeset/multi-view-plugin-scroll.md new file mode 100644 index 000000000..82110aed6 --- /dev/null +++ b/.changeset/multi-view-plugin-scroll.md @@ -0,0 +1,36 @@ +--- +'@embedpdf/plugin-scroll': major +--- + +## Multi-Document Support + +The scroll plugin now supports per-document scroll state and strategies. + +### Breaking Changes + +- **Actions**: Complete action refactoring: + - Replaced `UPDATE_SCROLL_STATE` with `UPDATE_DOCUMENT_SCROLL_STATE` that requires `documentId` + - Replaced `SET_DESIRED_SCROLL_POSITION` and `UPDATE_TOTAL_PAGES` with document-scoped actions + - Replaced `SET_PAGE_CHANGE_STATE` with document-scoped state management + - Added `SET_SCROLL_STRATEGY` action for per-document scroll strategies + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document scroll state including position, page change state, and scroll strategy. + +- **Action Creators**: All action creators now require `documentId`: + - `initScrollState(documentId, state)` + - `updateDocumentScrollState(documentId, state)` + - `setScrollStrategy(documentId, strategy)` + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Scroller Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-scroll/react`, Svelte: `@embedpdf/plugin-scroll/svelte`, Vue: `@embedpdf/plugin-scroll/vue`) + - Removed `overlayElements` prop + - `renderPage` prop now receives `PageLayout` instead of `RenderPageProps` + - Component subscribes to document-specific scroller data + +### New Features + +- Per-document scroll state tracking +- Per-document scroll strategies +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-search.md b/.changeset/multi-view-plugin-search.md new file mode 100644 index 000000000..e92dec884 --- /dev/null +++ b/.changeset/multi-view-plugin-search.md @@ -0,0 +1,42 @@ +--- +'@embedpdf/plugin-search': major +--- + +## Multi-Document Support + +The search plugin now supports per-document search sessions, results, and state. + +### Breaking Changes + +- **All Actions**: Now require `documentId` parameter: + - `startSearchSession(documentId)` - was `startSearchSession()` (no params) + - `stopSearchSession(documentId)` - was `stopSearchSession()` (no params) + - `setSearchFlags(documentId, flags)` - was `setSearchFlags(flags)` + - `setShowAllResults(documentId, showAll)` - was `setShowAllResults(showAll)` + - `startSearch(documentId, query)` - was `startSearch(query)` + - `setSearchResults(documentId, results, total, activeResultIndex)` - was `setSearchResults(results, total, activeResultIndex)` + - `appendSearchResults(documentId, results)` - was `appendSearchResults(results)` + - `setActiveResultIndex(documentId, index)` - was `setActiveResultIndex(index)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document search state including active session, query, results, and flags. + +- **Capability Methods**: All search operations now require document scoping or operate on the active document. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **SearchLayer Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-search/react`, Svelte: `@embedpdf/plugin-search/svelte`, Vue: `@embedpdf/plugin-search/vue`) + - `scale` prop is now optional - if not provided, uses document state scale + - Component now uses `forDocument(documentId)` to get document-scoped search capability + - Component subscribes to document-specific search state changes + +- **Search Hooks**: + - All hooks now work with document-scoped capabilities via `forDocument()` + - Components automatically scope operations to the provided `documentId` + +### New Features + +- Per-document search sessions (each document can have its own active search) +- Per-document search results and state +- `forDocument()` method for document-scoped search operations +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-selection.md b/.changeset/multi-view-plugin-selection.md new file mode 100644 index 000000000..6ff183d17 --- /dev/null +++ b/.changeset/multi-view-plugin-selection.md @@ -0,0 +1,41 @@ +--- +'@embedpdf/plugin-selection': major +--- + +## Multi-Document Support + +The selection plugin now supports per-document text selection state and operations. + +### Breaking Changes + +- **All Actions**: Now require `documentId` parameter: + - `cachePageGeometry(documentId, page, geo)` - was `cachePageGeometry(page, geo)` + - `setSelection(documentId, selection)` - was `setSelection(selection)` + - `startSelection(documentId)` - was `startSelection()` (no params) + - `endSelection(documentId)` - was `endSelection()` (no params) + - `clearSelection(documentId)` - was `clearSelection()` (no params) + - `setRects(documentId, rects)` - was `setRects(rects)` + - `setSlices(documentId, slices)` - was `setSlices(slices)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document selection state including cached page geometry, selection ranges, rects, and slices. + +- **Action Creators**: All action creators now require `documentId`. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **SelectionLayer Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-selection/react`, Svelte: `@embedpdf/plugin-selection/svelte`, Vue: `@embedpdf/plugin-selection/vue`) + - `scale` prop is now optional - if not provided, uses document state scale + - Added optional `rotation` prop - if not provided, uses document state rotation + - Added optional `selectionMenu` prop for custom selection menu rendering + - Component subscribes to document-specific selection state and menu placement + +- **CopyToClipboard Component**: + - Updated to handle document-scoped copy events with `{ text }` payload format + +### New Features + +- Per-document text selection tracking +- Per-document page geometry caching +- Per-document selection rects and slices +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-spread.md b/.changeset/multi-view-plugin-spread.md new file mode 100644 index 000000000..7006e0020 --- /dev/null +++ b/.changeset/multi-view-plugin-spread.md @@ -0,0 +1,33 @@ +--- +'@embedpdf/plugin-spread': major +--- + +## Multi-Document Support + +The spread plugin now supports per-document spread mode and page grouping. + +### Breaking Changes + +- **Actions**: All actions now require `documentId`: + - `setSpreadMode(documentId, spreadMode)` - was `setSpreadMode(mode)` + - Added `setPageGrouping(documentId, grouping)` action for custom page grouping + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document spread mode and page grouping. + +- **Action Creators**: All action creators now require `documentId`: + - `initSpreadState(documentId, state)` + - `setSpreadMode(documentId, spreadMode)` + - `setPageGrouping(documentId, grouping)` + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **useSpread Hook**: + - Now requires `documentId` parameter: `useSpread(documentId)` (React/Preact: `@embedpdf/plugin-spread/react`, Svelte: `@embedpdf/plugin-spread/svelte`, Vue: `@embedpdf/plugin-spread/vue`) + - Returns document-scoped spread capability via `forDocument()` + - Subscribes to document-specific spread mode changes + +### New Features + +- Per-document spread mode tracking +- Per-document page grouping configuration +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-thumbnail.md b/.changeset/multi-view-plugin-thumbnail.md new file mode 100644 index 000000000..83471865a --- /dev/null +++ b/.changeset/multi-view-plugin-thumbnail.md @@ -0,0 +1,33 @@ +--- +'@embedpdf/plugin-thumbnail': major +--- + +## Multi-Document Support + +The thumbnail plugin now supports per-document thumbnail window state and viewport metrics. + +### Breaking Changes + +- **Actions**: All actions now require `documentId`: + - `setWindowState(documentId, window)` - Sets thumbnail window state for a document + - `updateViewportMetrics(documentId, scrollY, viewportH)` - Updates viewport metrics for a document + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document thumbnail state including window state and viewport metrics. + +- **Action Creators**: All action creators now require `documentId`: + - `initThumbnailState(documentId, state)` + - `setWindowState(documentId, window)` + - `updateViewportMetrics(documentId, scrollY, viewportH)` + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **ThumbImg Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-thumbnail/react`, Svelte: `@embedpdf/plugin-thumbnail/svelte`, Vue: `@embedpdf/plugin-thumbnail/vue`) + - Component now uses `forDocument(documentId)` to get document-scoped thumbnail capability + - Subscribes to document-specific page refresh events + +### New Features + +- Per-document thumbnail window state tracking +- Per-document viewport metrics for thumbnail positioning +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-tiling.md b/.changeset/multi-view-plugin-tiling.md new file mode 100644 index 000000000..d0dd817d2 --- /dev/null +++ b/.changeset/multi-view-plugin-tiling.md @@ -0,0 +1,33 @@ +--- +'@embedpdf/plugin-tiling': major +--- + +## Multi-Document Support + +The tiling plugin now supports per-document tile management and status tracking. + +### Breaking Changes + +- **Actions**: All actions now require `documentId`: + - `updateVisibleTiles(documentId, tiles)` - was `updateVisibleTiles(tiles)` + - `markTileStatus(documentId, pageIndex, tileId, status)` - was `markTileStatus(pageIndex, tileId, status)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document tile state including visible tiles and tile statuses. + +- **Action Creators**: All action creators now require `documentId`: + - `initTilingState(documentId, state)` + - `updateVisibleTiles(documentId, tiles)` + - `markTileStatus(documentId, pageIndex, tileId, status)` + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **TileImg Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-tiling/react`, Svelte: `@embedpdf/plugin-tiling/svelte`, Vue: `@embedpdf/plugin-tiling/vue`) + - Component now uses `forDocument(documentId)` to get document-scoped tiling capability + - Uses document-scoped tile rendering + +### New Features + +- Per-document tile tracking and management +- Per-document tile status tracking +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-ui.md b/.changeset/multi-view-plugin-ui.md new file mode 100644 index 000000000..525173147 --- /dev/null +++ b/.changeset/multi-view-plugin-ui.md @@ -0,0 +1,48 @@ +--- +'@embedpdf/plugin-ui': major +--- + +## Multi-Document Support + +The UI plugin now supports per-document UI state including toolbars, panels, modals, and menus. + +### Breaking Changes + +- **Complete Action Refactoring**: All UI actions have been restructured: + - Replaced `UI_INIT_COMPONENTS`, `UI_INIT_FLYOUT`, `UI_TOGGLE_FLYOUT` with new document-scoped actions + - Replaced `UI_SET_HEADER_VISIBLE`, `UI_TOGGLE_PANEL` with `SET_ACTIVE_PANEL`, `CLOSE_PANEL_SLOT` + - Replaced `UI_SHOW_COMMAND_MENU`, `UI_HIDE_COMMAND_MENU`, `UI_UPDATE_COMMAND_MENU` with `OPEN_MENU`, `CLOSE_MENU`, `CLOSE_ALL_MENUS` + - Replaced `UI_UPDATE_COMPONENT_STATE` with document-scoped state management + +- **All Actions**: Now require `documentId` parameter: + - `setActiveToolbar(documentId, placement, slot, toolbarId)` + - `closeToolbarSlot(documentId, placement, slot)` + - `setActivePanel(documentId, placement, slot, panelId)` + - `closePanelSlot(documentId, placement, slot)` + - `setPanelTab(documentId, placement, slot, tabId)` + - `openModal(documentId, modalId, props)` + - `closeModal(documentId, modalId)` + - `openMenu(documentId, menuState)` + - `closeMenu(documentId, menuId)` + - `closeAllMenus(documentId)` + - `setDisabledCategories(documentId, categories)` + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document UI state including toolbars, panels, modals, menus, and disabled categories. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **AutoMenuRenderer Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-ui/react`, Svelte: `@embedpdf/plugin-ui/svelte`, Vue: `@embedpdf/plugin-ui/vue`) + - Renders menus for a specific document + - Uses document-scoped anchor registry and menu state + +- **useUIState Hook**: + - Now requires `documentId` parameter: `useUIState(documentId)` + - Returns document-specific UI state + +### New Features + +- Per-document UI state management +- Per-document toolbar, panel, modal, and menu state +- Document lifecycle management with automatic state initialization and cleanup +- Support for multiple UI schemas per document diff --git a/.changeset/multi-view-plugin-view-manager.md b/.changeset/multi-view-plugin-view-manager.md new file mode 100644 index 000000000..dce2a3138 --- /dev/null +++ b/.changeset/multi-view-plugin-view-manager.md @@ -0,0 +1,39 @@ +--- +'@embedpdf/plugin-view-manager': major +--- + +## Multi-Document Support + +The view manager plugin enables managing multiple views, each containing multiple documents. + +### Breaking Changes + +- **Plugin Architecture**: Complete rewrite to support view-based document management. Views are containers that can hold multiple documents. + +- **Actions**: All actions now operate on views and documents: + - `createView(viewId, createdAt)` - Create a new view + - `removeView(viewId)` - Remove a view + - `addDocumentToView(viewId, documentId, index?)` - Add document to a view + - `removeDocumentFromView(viewId, documentId)` - Remove document from a view + - `moveDocumentWithinView(viewId, documentId, toIndex)` - Move document within a view + - `setViewActiveDocument(viewId, documentId)` - Set active document for a view + - `setFocusedView(viewId)` - Set the focused view + +- **State Structure**: Plugin state now tracks views, each containing multiple documents with their own active document. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **ViewContext Component**: + - New component for managing view state and operations (React/Preact: `@embedpdf/plugin-view-manager/react`, Svelte: `@embedpdf/plugin-view-manager/svelte`, Vue: `@embedpdf/plugin-view-manager/vue`) + - Requires `viewId` prop + - Supports `autoCreate` prop to automatically create view if it doesn't exist + - Provides render props with view state, document IDs, active document, focus state, and view actions + +### New Features + +- View-based document organization +- Multiple views with independent document collections +- Per-view active document tracking +- Focused view management +- Document movement and reordering within views +- View lifecycle management diff --git a/.changeset/multi-view-plugin-viewport.md b/.changeset/multi-view-plugin-viewport.md new file mode 100644 index 000000000..ccaefba43 --- /dev/null +++ b/.changeset/multi-view-plugin-viewport.md @@ -0,0 +1,45 @@ +--- +'@embedpdf/plugin-viewport': major +--- + +## Multi-Document Support + +The viewport plugin now supports per-document viewport metrics, scroll state, and viewport registration. + +### Breaking Changes + +- **All Actions**: Now require `documentId` parameter: + - `setViewportMetrics(documentId, metrics)` - was `setViewportMetrics(metrics)` + - `setViewportScrollMetrics(documentId, scrollMetrics)` - was `setViewportScrollMetrics(scrollMetrics)` + - `setViewportGap(documentId, gap)` - was `setViewportGap(gap)` + - `setScrollActivity(documentId, isActive)` - was `setScrollActivity(isActive)` + - `setSmoothScrollActivity(documentId, isActive)` - was `setSmoothScrollActivity(isActive)` + +- **Viewport Registration**: + - `registerViewport(documentId)` - Now requires document ID + - `unregisterViewport(documentId)` - Now requires document ID + +- **State Structure**: Plugin state now uses per-document viewport state tracking including metrics, scroll state, and viewport gates. + +- **New Actions**: Added viewport gate management actions: + - `addViewportGate(documentId, gateName)` + - `removeViewportGate(documentId, gateName)` + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **Viewport Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-viewport/react`, Svelte: `@embedpdf/plugin-viewport/svelte`, Vue: `@embedpdf/plugin-viewport/vue`) + - Component now uses `useViewportRef(documentId)` for document-scoped viewport reference + - Uses `useIsViewportGated(documentId)` to check if viewport is gated + - Children are only rendered when viewport is not gated + +- **useViewportRef Hook**: + - Now requires `documentId` parameter: `useViewportRef(documentId)` + - Returns document-scoped viewport reference + +### New Features + +- Per-document viewport metrics and scroll tracking +- Per-document viewport registration +- Viewport gate management for coordinating viewport operations +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-plugin-zoom.md b/.changeset/multi-view-plugin-zoom.md new file mode 100644 index 000000000..660bfe438 --- /dev/null +++ b/.changeset/multi-view-plugin-zoom.md @@ -0,0 +1,35 @@ +--- +'@embedpdf/plugin-zoom': major +--- + +## Multi-Document Support + +The zoom plugin now supports per-document zoom levels and marquee zoom state. + +### Breaking Changes + +- **Actions**: All actions now require `documentId`: + - `setZoomLevel(documentId, zoomLevel, currentZoomLevel)` - was `setZoomLevel(zoomLevel, currentZoomLevel)` + - Removed `setInitialZoomLevel` action + +- **State Structure**: Plugin state now uses `documents: Record` to track per-document zoom levels and marquee zoom state. + +- **Capability Methods**: Methods now operate on the active document by default, or use `forDocument(id)` for specific documents. + +### Framework-Specific Changes (React/Preact, Svelte, Vue) + +- **MarqueeZoom Component**: + - Now requires `documentId` prop (React/Preact: `@embedpdf/plugin-zoom/react`, Svelte: `@embedpdf/plugin-zoom/svelte`, Vue: `@embedpdf/plugin-zoom/vue`) + - `scale` prop is now optional - if not provided, uses document state scale + - Component now uses `useDocumentState` hook to get document scale automatically + +- **PinchWrapper Component**: + - Now requires `documentId` prop + - Uses document-scoped zoom operations + +### New Features + +- Per-document zoom level tracking +- Per-document marquee zoom state +- `forDocument()` method for document-scoped operations +- Document lifecycle management with automatic state initialization and cleanup diff --git a/.changeset/multi-view-utils.md b/.changeset/multi-view-utils.md new file mode 100644 index 000000000..3527aea23 --- /dev/null +++ b/.changeset/multi-view-utils.md @@ -0,0 +1,15 @@ +--- +'@embedpdf/utils': minor +--- + +## Multi-Document Support + +Added utilities for selection menu positioning and context handling. + +### New Features + +- **SelectionMenuPlacement**: New interface for placement hints when positioning selection menus (suggestTop, spaceAbove, spaceBelow). + +- **SelectionMenuContextBase**: Base context type that all layer contexts must extend, providing a discriminated union pattern for menu contexts. + +- **Selection Menu Utilities**: New selection menu utilities exported from the main utils package. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..bb4a46067 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,93 @@ +{ + "mode": "exit", + "tag": "next", + "initialVersions": { + "@embedpdf/example-react-mui": "0.0.0", + "@embedpdf/example-react-tailwind": "0.0.0", + "@embedpdf/example-svelte-tailwind": "0.0.0", + "@embedpdf/example-vue-tailwind": "0.0.0", + "@embedpdf/example-vue-vuetify": "0.0.0", + "@embedpdf/build": "1.1.0", + "@embedpdf/core": "1.5.0", + "@embedpdf/engines": "1.5.0", + "@embedpdf/example-engines-node": "1.0.0", + "@embedpdf/models": "1.5.0", + "@embedpdf/pdfium": "1.5.0", + "@embedpdf/example-pdfium-node": "1.0.0", + "@embedpdf/plugin-annotation": "1.5.0", + "@embedpdf/plugin-attachment": "1.5.0", + "@embedpdf/plugin-bookmark": "1.5.0", + "@embedpdf/plugin-capture": "1.5.0", + "@embedpdf/plugin-commands": "1.0.0", + "@embedpdf/plugin-document-manager": "1.3.14", + "@embedpdf/plugin-export": "1.5.0", + "@embedpdf/plugin-fullscreen": "1.5.0", + "@embedpdf/plugin-history": "1.5.0", + "@embedpdf/plugin-i18n": "1.0.0", + "@embedpdf/plugin-interaction-manager": "1.5.0", + "@embedpdf/plugin-pan": "1.5.0", + "@embedpdf/plugin-print": "1.5.0", + "@embedpdf/plugin-redaction": "1.5.0", + "@embedpdf/plugin-render": "1.5.0", + "@embedpdf/plugin-rotate": "1.5.0", + "@embedpdf/plugin-scroll": "1.5.0", + "@embedpdf/plugin-search": "1.5.0", + "@embedpdf/plugin-selection": "1.5.0", + "@embedpdf/plugin-spread": "1.5.0", + "@embedpdf/plugin-thumbnail": "1.5.0", + "@embedpdf/plugin-tiling": "1.5.0", + "@embedpdf/plugin-ui": "1.5.0", + "@embedpdf/plugin-view-manager": "1.5.0", + "@embedpdf/plugin-viewport": "1.5.0", + "@embedpdf/plugin-zoom": "1.5.0", + "@embedpdf/utils": "1.5.0", + "@embedpdf/snippet": "1.0.0", + "@embedpdf/react-pdf-viewer": "1.0.0", + "@embedpdf/svelte-pdf-viewer": "1.0.0", + "@embedpdf/vue-pdf-viewer": "1.0.0" + }, + "changesets": [ + "commands-svelte-use-current", + "engine-orchestrator-architecture", + "fix-document-manager-empty-array", + "fix-i18n-vue-locale-reactivity", + "hidden-items-dependency-rules", + "layout-ready-event-page-info", + "multi-view-core", + "multi-view-engines", + "multi-view-models", + "multi-view-plugin-annotation", + "multi-view-plugin-attachment", + "multi-view-plugin-bookmark", + "multi-view-plugin-capture", + "multi-view-plugin-commands", + "multi-view-plugin-document-manager", + "multi-view-plugin-export", + "multi-view-plugin-fullscreen", + "multi-view-plugin-history", + "multi-view-plugin-i18n", + "multi-view-plugin-interaction-manager", + "multi-view-plugin-pan", + "multi-view-plugin-print", + "multi-view-plugin-redaction", + "multi-view-plugin-render", + "multi-view-plugin-rotate", + "multi-view-plugin-scroll", + "multi-view-plugin-search", + "multi-view-plugin-selection", + "multi-view-plugin-spread", + "multi-view-plugin-thumbnail", + "multi-view-plugin-tiling", + "multi-view-plugin-ui", + "multi-view-plugin-view-manager", + "multi-view-plugin-viewport", + "multi-view-plugin-zoom", + "multi-view-utils", + "scroll-initial-page-removal", + "selection-menu-height-config", + "snippet-capability-scope-type-exports", + "snippet-disabled-categories-and-hierarchical-categories", + "snippet-spanish-translations-partial-configs", + "utils-svelte-action-pattern" + ] +} diff --git a/.changeset/remove-initialize-constructor-init.md b/.changeset/remove-initialize-constructor-init.md new file mode 100644 index 000000000..31cdcc02c --- /dev/null +++ b/.changeset/remove-initialize-constructor-init.md @@ -0,0 +1,92 @@ +--- +'@embedpdf/engines': major +'@embedpdf/models': major +'@embedpdf/core': minor +--- + +# Remove `initialize()` - PDFium Now Initializes in Constructor + +This release removes the `initialize()` method from all engine classes. PDFium is now automatically initialized in the constructor, simplifying the API and reducing boilerplate. + +## Breaking Changes + +### `initialize()` Method Removed + +The `initialize()` method has been removed from: + +- `PdfiumNative` (formerly `PdfiumEngine`) +- `PdfEngine` orchestrator +- `RemoteExecutor` +- `WebWorkerEngine` +- `IPdfiumExecutor` interface +- `PdfEngine` interface (in models) + +**Migration:** + +```typescript +// Before +const native = new PdfiumNative(wasmModule, { logger }); +native.initialize(); + +const engine = new PdfEngine(native, { imageConverter, logger }); +engine.initialize(); + +// After - no initialize() needed! +const native = new PdfiumNative(wasmModule, { logger }); +const engine = new PdfEngine(native, { imageConverter, logger }); + +// Ready to use immediately +const doc = await engine.openDocumentBuffer(file).toPromise(); +``` + +### Framework Hooks Simplified + +The `usePdfiumEngine` hooks (React, Vue, Svelte) no longer require calling `initialize()`: + +```typescript +// Before +const { engine, isLoading } = usePdfiumEngine(); +const [initialized, setInitialized] = useState(false); + +useEffect(() => { + if (engine && !initialized) { + engine.initialize().wait(setInitialized, ignore); + } +}, [engine, initialized]); + +// After - engine is ready when returned! +const { engine, isLoading } = usePdfiumEngine(); + +if (!isLoading && engine) { + // Ready to use immediately +} +``` + +### `PluginRegistry.ensureEngineInitialized()` Removed + +The `ensureEngineInitialized()` method and `engineInitialized` property have been removed from `PluginRegistry` since engines are now initialized in their constructors. + +## Cross-Platform Image Data + +### `ImageData` → `ImageDataLike` + +The engine now returns `ImageDataLike` (a plain object with `data`, `width`, `height`) instead of the browser-specific `ImageData` class. This enables Node.js compatibility without polyfills. + +**Affected types:** + +- `PdfImageObject.imageData` now uses `ImageDataLike` +- All raw render methods return `ImageDataLike` + +### Browser Converter Fallback + +`browserImageDataToBlobConverter` now falls back to regular `` when `OffscreenCanvas` is not available (older browsers). The hybrid converter (`createHybridImageConverter`) uses: + +1. Worker pool with `OffscreenCanvas` (preferred, non-blocking) +2. Main-thread `` fallback (blocking, but works everywhere) + +## Benefits + +- **Simpler API**: One less step to get started +- **Less boilerplate**: No more `initialize()` calls in every component +- **Node.js compatible**: `ImageDataLike` works without browser APIs +- **Broader browser support**: Canvas fallback for older browsers diff --git a/.changeset/scroll-initial-page-removal.md b/.changeset/scroll-initial-page-removal.md new file mode 100644 index 000000000..f37c95c69 --- /dev/null +++ b/.changeset/scroll-initial-page-removal.md @@ -0,0 +1,43 @@ +--- +'@embedpdf/plugin-scroll': minor +--- + +## Remove `initialPage` Config & Add `isInitial` to `LayoutReadyEvent` + +### Breaking Changes + +- **Removed `initialPage` config option**: The `initialPage` configuration option has been removed from `ScrollPluginConfig`. With multi-document support, a global initial page setting no longer makes sense. + +### Migration + +To scroll to a specific page when a document loads, use the `onLayoutReady` event instead: + +```tsx +import { useCapability } from '@embedpdf/core/react'; +import type { ScrollPlugin } from '@embedpdf/plugin-scroll'; + +const ScrollToPageOnLoad = ({ documentId, initialPage }) => { + const { provides: scrollCapability } = useCapability('scroll'); + + useEffect(() => { + if (!scrollCapability) return; + + const unsubscribe = scrollCapability.onLayoutReady((event) => { + if (event.documentId === documentId && event.isInitial) { + scrollCapability.forDocument(documentId).scrollToPage({ + pageNumber: initialPage, + behavior: 'instant', + }); + } + }); + + return unsubscribe; + }, [scrollCapability, documentId, initialPage]); + + return null; +}; +``` + +### New Features + +- **`isInitial` flag on `LayoutReadyEvent`**: The `onLayoutReady` event now includes an `isInitial` boolean that is `true` only on the first layout after document load, and `false` on subsequent layouts (e.g., when switching between tabs). This allows distinguishing between initial document load and tab reactivation. diff --git a/.changeset/selection-menu-height-config.md b/.changeset/selection-menu-height-config.md new file mode 100644 index 000000000..f427e2b85 --- /dev/null +++ b/.changeset/selection-menu-height-config.md @@ -0,0 +1,12 @@ +--- +'@embedpdf/plugin-selection': patch +--- + +Added configurable `menuHeight` option to `SelectionPluginConfig`. This allows customizing the height used to determine whether the selection menu appears above or below the selection. Default value is `40` pixels. Also fixed type imports in Svelte `SelectionLayer` component. + +```typescript +createPluginRegistration(SelectionPluginPackage, { + enabled: true, + menuHeight: 50, // Custom menu height for placement calculations +}); +``` diff --git a/.changeset/snippet-capability-scope-type-exports.md b/.changeset/snippet-capability-scope-type-exports.md new file mode 100644 index 000000000..711e47391 --- /dev/null +++ b/.changeset/snippet-capability-scope-type-exports.md @@ -0,0 +1,59 @@ +--- +'@embedpdf/snippet': minor +--- + +Added comprehensive type exports for all plugin Capabilities and Scopes, enabling proper TypeScript support when using plugin APIs. + +### New Type Exports + +All plugins now export their `*Capability` and `*Scope` types, allowing developers to properly type variables when using `plugin.provides()` and `forDocument()`: + +- **Viewport**: `ViewportCapability`, `ViewportScope`, `ViewportMetrics` +- **Scroll**: `ScrollCapability`, `ScrollScope`, `ScrollMetrics`, `PageChangeEvent`, `ScrollEvent`, `LayoutChangeEvent` +- **Spread**: `SpreadCapability`, `SpreadScope` +- **Zoom**: `ZoomCapability`, `ZoomScope`, `ZoomLevel`, `ZoomChangeEvent` +- **Rotate**: `RotateCapability`, `RotateScope` +- **Tiling**: `TilingCapability`, `TilingScope` +- **Thumbnail**: `ThumbnailCapability`, `ThumbnailScope` +- **Annotation**: `AnnotationCapability`, `AnnotationScope`, `AnnotationEvent` +- **Search**: `SearchCapability`, `SearchScope` +- **Selection**: `SelectionCapability`, `SelectionScope` +- **Capture**: `CaptureCapability`, `CaptureScope` +- **Redaction**: `RedactionCapability`, `RedactionScope`, `RedactionMode`, `RedactionItem` +- **UI**: `UIScope` (UICapability was already exported) +- **I18n**: `I18nCapability`, `I18nScope`, `Locale`, `LocaleChangeEvent` +- **Commands**: `CommandScope` (CommandsCapability was already exported) +- **DocumentManager**: `DocumentManagerCapability`, `DocumentChangeEvent`, `LoadDocumentUrlOptions`, `LoadDocumentBufferOptions` +- **Print**: `PrintCapability`, `PrintScope` +- **Fullscreen**: `FullscreenCapability` +- **Bookmark**: `BookmarkCapability`, `BookmarkScope` +- **Export**: `ExportCapability`, `ExportScope` +- **Pan**: `PanCapability`, `PanScope` +- **History**: `HistoryCapability`, `HistoryScope` +- **Attachment**: `AttachmentCapability`, `AttachmentScope` +- **Render**: `RenderCapability`, `RenderScope` +- **InteractionManager**: `InteractionManagerCapability`, `InteractionManagerScope` + +### Usage Example + +```typescript +import { + ScrollPlugin, + type ScrollCapability, + type ScrollScope, + type PageChangeEvent, +} from '@embedpdf/snippet'; + +// Type the capability returned by provides() +const scroll: ScrollCapability = registry + .getPlugin('scroll') + ?.provides(); + +// Type the scoped API for a specific document +const doc: ScrollScope = scroll.forDocument('my-document'); + +// Type event callbacks +scroll.onPageChange((event: PageChangeEvent) => { + console.log(`Page ${event.pageNumber} of ${event.totalPages}`); +}); +``` diff --git a/.changeset/snippet-disabled-categories-and-hierarchical-categories.md b/.changeset/snippet-disabled-categories-and-hierarchical-categories.md new file mode 100644 index 000000000..3bc8eed7f --- /dev/null +++ b/.changeset/snippet-disabled-categories-and-hierarchical-categories.md @@ -0,0 +1,65 @@ +--- +'@embedpdf/snippet': minor +--- + +Added global `disabledCategories` config and hierarchical categories for fine-grained feature control. + +**Global `disabledCategories` Configuration** + +Added `disabledCategories` to the root `PDFViewerConfig` that applies to both UI and Commands plugins: + +```typescript +const config: PDFViewerConfig = { + src: 'document.pdf', + // Disable all annotation and redaction features globally + disabledCategories: ['annotation', 'redaction'], +}; +``` + +Plugin-specific settings can override the global setting: + +```typescript +const config: PDFViewerConfig = { + disabledCategories: ['annotation'], // Global default + ui: { + disabledCategories: ['redaction'], // Overrides for UI only + }, + commands: { + disabledCategories: [], // Re-enables all for commands + }, +}; +``` + +**Hierarchical Categories** + +All commands and UI schema items now have hierarchical categories for granular control: + +- `annotation` - all annotation features + - `annotation-markup` - highlight, underline, strikeout, squiggly + - `annotation-highlight`, `annotation-underline`, etc. + - `annotation-shape` - rectangle, circle, line, arrow, polygon, polyline + - `annotation-rectangle`, `annotation-circle`, etc. + - `annotation-ink`, `annotation-text`, `annotation-stamp` +- `redaction` - all redaction features + - `redaction-text`, `redaction-area`, `redaction-apply`, `redaction-clear` +- `zoom` - all zoom features + - `zoom-in`, `zoom-out`, `zoom-fit-page`, `zoom-fit-width`, `zoom-marquee` + - `zoom-level` - all zoom level presets +- `document` - document operations + - `document-open`, `document-close`, `document-print`, `document-export`, `document-fullscreen` +- `panel` - sidebar panels + - `panel-sidebar`, `panel-search`, `panel-comment`, `panel-annotation-style` +- `page` - page settings + - `spread`, `scroll`, `rotate` +- `history` - undo/redo + - `history-undo`, `history-redo` +- `mode` - viewer modes + - `mode-view`, `mode-annotate`, `mode-shapes`, `mode-redact` +- `tools` - tool buttons + - `pan`, `pointer`, `capture` + +Example: Disable only print functionality while keeping export: + +```typescript +disabledCategories: ['document-print']; +``` diff --git a/.changeset/snippet-spanish-translations-partial-configs.md b/.changeset/snippet-spanish-translations-partial-configs.md new file mode 100644 index 000000000..9523db69f --- /dev/null +++ b/.changeset/snippet-spanish-translations-partial-configs.md @@ -0,0 +1,30 @@ +--- +'@embedpdf/snippet': minor +--- + +Added Spanish translations, improved i18n support, and enhanced plugin configuration API. + +### New Features + +- **Spanish Translations**: Added Spanish (`es`) locale support with complete translations for all UI elements and commands. + +- **Annotation Sidebar Translations**: Sidebar titles are now properly translated using i18n keys. Added missing translation keys (`annotation.freeText`, `annotation.square`, `annotation.styles`, `annotation.defaults`) to all 5 languages. + +### Improvements + +- **Partial Plugin Configs**: All plugin configuration options in `PDFViewerConfig` now use `Partial<>` types, making it easier to override only the settings you need without specifying all required fields. + +- **Reactive Blend Mode Labels**: Blend mode dropdown labels in the annotation sidebar now update reactively when the language changes. + +- **Search Sidebar Layout**: Changed search options checkboxes from horizontal to vertical layout for better compatibility with longer translated labels. + +```typescript +// Override just specific settings + +``` diff --git a/.changeset/utils-svelte-action-pattern.md b/.changeset/utils-svelte-action-pattern.md new file mode 100644 index 000000000..1ca0d7f8c --- /dev/null +++ b/.changeset/utils-svelte-action-pattern.md @@ -0,0 +1,16 @@ +--- +'@embedpdf/utils': patch +--- + +Refactored `CounterRotateContainer` to use a Svelte action (`action: Action`) instead of a ref callback (`ref: (el: HTMLElement | null) => void`). This is the idiomatic Svelte pattern for attaching lifecycle-managed behavior to DOM elements. Updated `MenuWrapperProps` type accordingly. + +**Migration:** + +```svelte + + +$effect(() => { menuWrapperProps.ref(el); }); + + + +``` diff --git a/.changeset/viewport-element-context.md b/.changeset/viewport-element-context.md new file mode 100644 index 000000000..8cc8ac0ed --- /dev/null +++ b/.changeset/viewport-element-context.md @@ -0,0 +1,25 @@ +--- +'@embedpdf/plugin-viewport': minor +--- + +## Viewport Element Context + +Added a React context to share the viewport DOM element reference with child components. + +### New Features + +- **ViewportElementContext**: New context that provides access to the viewport container element +- **useViewportElement hook**: Hook to consume the viewport element reference from context + +This allows child components (like `ZoomGestureWrapper`) to access the viewport container element without DOM traversal, enabling gesture events to work anywhere within the viewport area. + +### Usage + +```tsx +import { useViewportElement } from '@embedpdf/plugin-viewport/react'; + +function MyComponent() { + const viewportRef = useViewportElement(); + // viewportRef.current is the viewport container element +} +``` diff --git a/.changeset/zoom-gesture-wrapper-improvements.md b/.changeset/zoom-gesture-wrapper-improvements.md new file mode 100644 index 000000000..ee2ccba74 --- /dev/null +++ b/.changeset/zoom-gesture-wrapper-improvements.md @@ -0,0 +1,45 @@ +--- +'@embedpdf/plugin-zoom': major +--- + +## ZoomGestureWrapper (formerly PinchWrapper) + +Renamed `PinchWrapper` to `ZoomGestureWrapper` and added wheel zoom support alongside pinch-to-zoom. + +### Breaking Changes + +- **Renamed Component**: `PinchWrapper` → `ZoomGestureWrapper` +- **Renamed Hook**: `usePinch` → `useZoomGesture` +- **Removed Hammer.js dependency**: Gesture handling is now implemented natively + +### New Features + +- **Wheel zoom**: Ctrl/Cmd + scroll wheel now zooms the document +- **Configurable gestures**: New props to enable/disable individual gesture types: + - `enablePinch` (default: `true`) - Enable/disable pinch-to-zoom + - `enableWheel` (default: `true`) - Enable/disable wheel zoom +- **Improved performance**: Uses `useLayoutEffect` to prevent flashing during zoom operations +- **Simplified internals**: Uses direct DOM measurements instead of plugin metrics + +### Migration + +```diff +- import { PinchWrapper } from '@embedpdf/plugin-zoom/react'; ++ import { ZoomGestureWrapper } from '@embedpdf/plugin-zoom/react'; + +- ++ + +- ++ +``` + +To disable a specific gesture: + +```tsx + +``` diff --git a/.github/workflows/release-next.yml b/.github/workflows/release-next.yml new file mode 100644 index 000000000..a4c675332 --- /dev/null +++ b/.github/workflows/release-next.yml @@ -0,0 +1,146 @@ +name: Release Next + +on: + push: + branches: [next] + paths: + - "packages/**" + - "viewers/**" + - ".changeset/**" + - "pnpm-lock.yaml" + workflow_dispatch: {} + +concurrency: + group: release-next + cancel-in-progress: false + +permissions: + contents: write + id-token: write + pull-requests: write + +env: + CI: true + +jobs: + release: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 10.4.0 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + registry-url: "https://registry.npmjs.org" + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm run build:viewers + + - name: Version / Publish via Changesets + id: changesets + uses: changesets/action@v1 + with: + commit: "chore: version packages (next)" + title: "chore: version packages (next)" + publish: pnpm ci:publish:next + createGithubReleases: false + + - name: Detect publish summary + id: published + run: | + if [ -f "pnpm-publish-summary.json" ]; then + COUNT=$(jq '.publishedPackages | length' pnpm-publish-summary.json 2>/dev/null || echo 0) + if [ "$COUNT" -gt 0 ]; then + echo "did_publish=true" >> $GITHUB_OUTPUT + echo "published=$(jq -c '.publishedPackages' pnpm-publish-summary.json)" >> $GITHUB_OUTPUT + echo "version=$(jq -r '.publishedPackages[0].version' pnpm-publish-summary.json)" >> $GITHUB_OUTPUT + else + echo "did_publish=false" >> $GITHUB_OUTPUT + fi + else + echo "did_publish=false" >> $GITHUB_OUTPUT + fi + + - name: Get merged PR info + if: steps.published.outputs.did_publish == 'true' + id: merged_pr + uses: actions-ecosystem/action-get-merged-pull-request@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare release notes + if: steps.published.outputs.did_publish == 'true' + env: + PR_BODY: ${{ steps.merged_pr.outputs.body }} + run: | + echo "$PR_BODY" | sed -n '/# Releases/,$p' | tail -n +2 > release-notes.md || true + + if [ ! -s release-notes.md ]; then + echo "Pre-release for next branch." > release-notes.md + echo "" >> release-notes.md + echo "Packages published:" >> release-notes.md + echo "${{ steps.published.outputs.published }}" >> release-notes.md + fi + + echo "Release notes:" + cat release-notes.md + + - name: Package published dists + if: steps.published.outputs.did_publish == 'true' + env: + PUBLISHED_JSON: ${{ steps.published.outputs.published }} + run: | + mkdir -p release-assets + echo "$PUBLISHED_JSON" | jq -r '.[].name' | while read name; do + short=$(echo "$name" | sed 's!.*/!!') + # Handle viewers (snippet, react, vue, svelte) which are in viewers/ + if [ "$short" = "snippet" ] || [ "$short" = "react-pdf-viewer" ] || [ "$short" = "vue-pdf-viewer" ] || [ "$short" = "svelte-pdf-viewer" ]; then + # Map package name to directory name + case "$short" in + "react-pdf-viewer") dir="viewers/react" ;; + "vue-pdf-viewer") dir="viewers/vue" ;; + "svelte-pdf-viewer") dir="viewers/svelte" ;; + *) dir="viewers/$short" ;; + esac + else + dir="packages/$short" + fi + if [ -d "$dir/dist" ]; then + echo "Packaging $short from $dir" + (cd "$dir" && zip -r "../../release-assets/${short}-dist.zip" dist >/dev/null 2>&1 || zip -r "../release-assets/${short}-dist.zip" dist >/dev/null) + tar -czf "release-assets/${short}-dist.tar.gz" -C "$dir" dist + else + echo "No dist for $short" + fi + done + ls -lh release-assets || true + + - name: Create GitHub pre-release + if: steps.published.outputs.did_publish == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.published.outputs.version }} + name: "Release Next v${{ steps.published.outputs.version }}" + body_path: release-notes.md + files: release-assets/* + draft: false + prerelease: true + make_latest: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fe4859d4..68ebed9ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "packages/**" + - "viewers/**" - ".changeset/**" - "pnpm-lock.yaml" workflow_dispatch: {} @@ -51,7 +52,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build packages - run: pnpm run build:packages + run: pnpm run build:viewers # Changesets will either open a version PR or publish if already versioned - name: Version / Publish via Changesets @@ -107,10 +108,21 @@ jobs: mkdir -p release-assets echo "$PUBLISHED_JSON" | jq -r '.[].name' | while read name; do short=$(echo "$name" | sed 's!.*/!!') - dir="packages/$short" + # Handle viewers (snippet, react, vue, svelte) which are in viewers/ + if [ "$short" = "snippet" ] || [ "$short" = "react-pdf-viewer" ] || [ "$short" = "vue-pdf-viewer" ] || [ "$short" = "svelte-pdf-viewer" ]; then + # Map package name to directory name + case "$short" in + "react-pdf-viewer") dir="viewers/react" ;; + "vue-pdf-viewer") dir="viewers/vue" ;; + "svelte-pdf-viewer") dir="viewers/svelte" ;; + *) dir="viewers/$short" ;; + esac + else + dir="packages/$short" + fi if [ -d "$dir/dist" ]; then - echo "Packaging $short" - (cd "$dir" && zip -r "../../release-assets/${short}-dist.zip" dist >/dev/null) + echo "Packaging $short from $dir" + (cd "$dir" && zip -r "../../release-assets/${short}-dist.zip" dist >/dev/null 2>&1 || zip -r "../release-assets/${short}-dist.zip" dist >/dev/null) tar -czf "release-assets/${short}-dist.tar.gz" -C "$dir" dist else echo "No dist for $short" diff --git a/.husky/pre-commit b/.husky/pre-commit index 75d0447fd..ca520d742 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,13 @@ #!/usr/bin/env sh -./node_modules/.bin/lint-staged \ No newline at end of file +# Cross-platform pre-commit for GitHub Desktop, macOS, Windows (Git Bash), Linux. + +# Make Node visible for GUI apps (harmless elsewhere) +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +# Load nvm if present (no-op if missing) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +# Prefer Node's built-in npx so we don't rely on pnpm/yarn on PATH +# --no-install ensures it uses the local devDependency +exec npx --no-install lint-staged \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js index d12be8454..7f09c2665 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -19,10 +19,10 @@ module.exports = { }, }, { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } + files: '*.svelte', + options: { + parser: 'svelte', + }, + }, ], }; diff --git a/examples/react-mui/package.json b/examples/react-mui/package.json index 6fb9532e0..be9e9fe74 100644 --- a/examples/react-mui/package.json +++ b/examples/react-mui/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@embedpdf/core": "workspace:*", - "@embedpdf/plugin-loader": "workspace:*", + "@embedpdf/plugin-document-manager": "workspace:*", "@embedpdf/plugin-viewport": "workspace:*", "@embedpdf/plugin-scroll": "workspace:*", "@embedpdf/plugin-zoom": "workspace:*", @@ -25,8 +25,11 @@ "@embedpdf/plugin-spread": "workspace:*", "@embedpdf/plugin-fullscreen": "workspace:*", "@embedpdf/plugin-export": "workspace:*", + "@embedpdf/plugin-print": "workspace:*", "@embedpdf/plugin-thumbnail": "workspace:*", "@embedpdf/plugin-selection": "workspace:*", + "@embedpdf/plugin-capture": "workspace:*", + "@embedpdf/plugin-history": "workspace:*", "@embedpdf/plugin-annotation": "workspace:*", "@embedpdf/plugin-redaction": "workspace:*", "@embedpdf/utils": "workspace:*", diff --git a/examples/react-mui/src/application.tsx b/examples/react-mui/src/application.tsx index 48a3754f0..61e1fdf70 100644 --- a/examples/react-mui/src/application.tsx +++ b/examples/react-mui/src/application.tsx @@ -4,7 +4,12 @@ import { usePdfiumEngine } from '@embedpdf/engines/react'; import { ConsoleLogger, PdfAnnotationSubtype, PdfStampAnnoObject } from '@embedpdf/models'; import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react'; import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react'; -import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, + DocumentContext, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager/react'; import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; import { MarqueeZoom, ZoomMode, ZoomPluginPackage } from '@embedpdf/plugin-zoom/react'; @@ -16,20 +21,22 @@ import { } from '@embedpdf/plugin-interaction-manager/react'; import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; -import { SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; import { SelectionLayer } from '@embedpdf/plugin-selection/react'; +import { CapturePluginPackage, MarqueeCapture } from '@embedpdf/plugin-capture/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { AnnotationLayer, AnnotationPlugin, AnnotationPluginPackage, AnnotationTool, } from '@embedpdf/plugin-annotation/react'; - import { CircularProgress, Box, Alert } from '@mui/material'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; import { useMemo, useRef } from 'react'; @@ -43,63 +50,6 @@ import { ViewSidebarReverseIcon } from './icons'; import { AnnotationSelectionMenu } from './components/annotation-selection-menu'; import { RedactionSelectionMenu } from './components/redaction-selection-menu'; -const plugins = [ - createPluginRegistration(LoaderPluginPackage, { - loadingOptions: { - type: 'url', - pdfFile: { - id: 'pdf', - url: 'https://snippet.embedpdf.com/ebook.pdf', - }, - }, - }), - createPluginRegistration(ViewportPluginPackage, { - viewportGap: 10, - }), - createPluginRegistration(ScrollPluginPackage, { - strategy: ScrollStrategy.Vertical, - }), - createPluginRegistration(RenderPluginPackage), - createPluginRegistration(TilingPluginPackage, { - tileSize: 768, - overlapPx: 2.5, - extraRings: 0, - }), - createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: ZoomMode.FitPage, - }), - createPluginRegistration(SearchPluginPackage), - createPluginRegistration(InteractionManagerPluginPackage), - createPluginRegistration(PanPluginPackage), - createPluginRegistration(RotatePluginPackage), - createPluginRegistration(SpreadPluginPackage), - createPluginRegistration(FullscreenPluginPackage), - createPluginRegistration(ExportPluginPackage), - createPluginRegistration(ThumbnailPluginPackage, { - paddingY: 10, - }), - createPluginRegistration(SelectionPluginPackage), - createPluginRegistration(AnnotationPluginPackage), - createPluginRegistration(RedactionPluginPackage), -]; - -const drawerComponents: DrawerComponent[] = [ - { - id: 'search', - component: Search, - icon: SearchOutlinedIcon, - label: 'Search', - position: 'right', - }, - { - id: 'sidebar', - component: Sidebar, - icon: ViewSidebarReverseIcon, - label: 'Sidebar', - position: 'left', - }, -]; - const consoleLogger = new ConsoleLogger(); function App() { @@ -111,6 +61,47 @@ function App() { const { engine, isLoading, error } = usePdfiumEngine(isDev ? { logger: consoleLogger } : {}); const popperContainerRef = useRef(null); + const plugins = useMemo( + () => [ + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(DocumentManagerPluginPackage), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + ], + [], + ); + if (error) { return ( - Failed to initialize PDF viewer: + Failed to initialize PDF viewer: {error.message} ); } @@ -144,159 +135,220 @@ function App() { } return ( - - { - const annotation = registry.getPlugin('annotation')?.provides(); - annotation?.addTool>({ - id: 'stampApproved', - name: 'Stamp Approved', - interaction: { - exclusive: false, - cursor: 'crosshair', - }, - matchScore: () => 0, - defaults: { - type: PdfAnnotationSubtype.STAMP, - imageSrc: - 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Eo_circle_green_checkmark.svg/512px-Eo_circle_green_checkmark.svg.png', - imageSize: { width: 20, height: 20 }, - }, - }); - }} - > - {({ pluginsReady }) => ( - - + { + // Load default PDF URL on initialization + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }) + .toPromise(); - {/* Main content area with sidebars */} - - {/* Left Sidebar */} - + // Add custom annotation tool + const annotation = registry.getPlugin('annotation')?.provides(); + annotation?.addTool>({ + id: 'stampApproved', + name: 'Stamp Approved', + interaction: { + exclusive: false, + cursor: 'crosshair', + }, + matchScore: () => 0, + defaults: { + type: PdfAnnotationSubtype.STAMP, + imageSrc: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Eo_circle_green_checkmark.svg/512px-Eo_circle_green_checkmark.svg.png', + imageSize: { width: 20, height: 20 }, + }, + }); + }} + > + {({ pluginsReady }) => ( + <> + {pluginsReady ? ( + + {({ activeDocumentId }) => { + // Define drawer components with documentId from context + const drawerComponents: DrawerComponent[] = [ + { + id: 'search', + component: Search, + icon: SearchOutlinedIcon, + label: 'Search', + position: 'right', + props: { documentId: activeDocumentId }, + }, + { + id: 'sidebar', + component: Sidebar, + icon: ViewSidebarReverseIcon, + label: 'Sidebar', + position: 'left', + props: { documentId: activeDocumentId }, + }, + ]; - {/* Main Viewport */} - - - - {!pluginsReady && ( - - - - )} - {pluginsReady && ( - ( - - - - - - - - ( - <> - {selected ? ( - - ) : null} - - )} - /> - ( - <> - {selected ? ( - - ) : null} - - )} - /> - - - )} - /> - )} - - - - + return ( + + + {activeDocumentId && } - {/* Right Sidebar */} - + {/* Main content area with sidebars */} + + {/* Left Sidebar */} + + + {/* Main Viewport */} + + {activeDocumentId && ( + + {({ isLoading: docLoading, isLoaded }) => ( + <> + {docLoading && ( + + + + )} + {isLoaded && ( + + + ( + + + + + + + + + ( + + )} + /> + ( + + )} + /> + + + )} + /> + + + + )} + + )} + + )} + + + {/* Right Sidebar */} + + + + + ); + }} + + ) : ( + + - - )} - - + )} + + )} + ); } diff --git a/examples/react-mui/src/components/annotation-selection-menu.tsx b/examples/react-mui/src/components/annotation-selection-menu.tsx index 0cc8fb921..13ba743ac 100644 --- a/examples/react-mui/src/components/annotation-selection-menu.tsx +++ b/examples/react-mui/src/components/annotation-selection-menu.tsx @@ -1,27 +1,32 @@ import { Paper, IconButton, Popper } from '@mui/material'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; -import { TrackedAnnotation } from '@embedpdf/plugin-annotation'; -import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; -import { MenuWrapperProps } from '@embedpdf/utils/react'; +import { + AnnotationSelectionMenuProps, + useAnnotationCapability, +} from '@embedpdf/plugin-annotation/react'; import { useState } from 'react'; -interface AnnotationSelectionMenuProps { - menuWrapperProps: MenuWrapperProps; - selected: TrackedAnnotation; +interface Props extends AnnotationSelectionMenuProps { + documentId: string; container?: HTMLElement | null; } export function AnnotationSelectionMenu({ + context, + documentId, selected, container, menuWrapperProps, -}: AnnotationSelectionMenuProps) { - const { provides: annotation } = useAnnotationCapability(); +}: Props) { + const { provides: annotationCapability } = useAnnotationCapability(); const [anchorEl, setAnchorEl] = useState(null); + // Get document-scoped API + const annotation = annotationCapability?.forDocument(documentId); + const handleDelete = () => { if (!annotation) return; - const { pageIndex, id } = selected.object; + const { pageIndex, id } = context.annotation.object; annotation.deleteAnnotation(pageIndex, id); }; @@ -29,7 +34,7 @@ export function AnnotationSelectionMenu({ <> { - const { provides: viewport } = useViewportCapability(); - const { - provides: scroll, - state: { currentPage, totalPages }, - } = useScroll(); +interface PageControlsProps { + documentId: string; +} + +export const PageControls = ({ documentId }: PageControlsProps) => { + const { provides: viewportCapability } = useViewportCapability(); + const { provides: scrollCapability } = useScroll(documentId); + + // Get document-scoped APIs + const viewport = viewportCapability?.forDocument(documentId); + const currentPage = scrollCapability?.getCurrentPage() ?? 1; + const totalPages = scrollCapability?.getTotalPages() ?? 0; const [isVisible, setIsVisible] = useState(false); const [isHovering, setIsHovering] = useState(false); const hideTimeoutRef = useRef(null); @@ -67,7 +73,7 @@ export const PageControls = () => { const page = parseInt(pageStr); if (!isNaN(page) && page >= 1 && page <= totalPages) { - scroll?.scrollToPage?.({ + scrollCapability?.scrollToPage?.({ pageNumber: page, }); } @@ -77,9 +83,7 @@ export const PageControls = () => { e.preventDefault(); e.currentTarget.blur(); if (currentPage > 1) { - scroll?.scrollToPage?.({ - pageNumber: currentPage - 1, - }); + scrollCapability?.scrollToPreviousPage?.(); } }; @@ -87,9 +91,7 @@ export const PageControls = () => { e.preventDefault(); e.currentTarget.blur(); if (currentPage < totalPages) { - scroll?.scrollToPage?.({ - pageNumber: currentPage + 1, - }); + scrollCapability?.scrollToNextPage?.(); } }; diff --git a/examples/react-mui/src/components/redaction-selection-menu.tsx b/examples/react-mui/src/components/redaction-selection-menu.tsx index 594535135..92d286aa6 100644 --- a/examples/react-mui/src/components/redaction-selection-menu.tsx +++ b/examples/react-mui/src/components/redaction-selection-menu.tsx @@ -1,33 +1,39 @@ import { Paper, IconButton, Popper } from '@mui/material'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import DoneIcon from '@mui/icons-material/Done'; -import { MenuWrapperProps } from '@embedpdf/utils/react'; import { useState } from 'react'; -import { RedactionItem, useRedactionCapability } from '@embedpdf/plugin-redaction/react'; +import { + RedactionSelectionMenuProps, + useRedactionCapability, +} from '@embedpdf/plugin-redaction/react'; -interface RedactionSelectionMenuProps { - menuWrapperProps: MenuWrapperProps; - selected: RedactionItem; +interface Props extends RedactionSelectionMenuProps { + documentId: string; container?: HTMLElement | null; } export function RedactionSelectionMenu({ selected, + context, + documentId, container, menuWrapperProps, -}: RedactionSelectionMenuProps) { - const { provides: redaction } = useRedactionCapability(); +}: Props) { + const { provides: redactionCapability } = useRedactionCapability(); const [anchorEl, setAnchorEl] = useState(null); + // Get document-scoped API + const redaction = redactionCapability?.forDocument(documentId); + const handleDelete = () => { if (!redaction) return; - const { page, id } = selected; + const { page, id } = context.item; redaction.removePending(page, id); }; const handleCommit = () => { if (!redaction) return; - const { page, id } = selected; + const { page, id } = context.item; redaction.commitPending(page, id); }; @@ -35,7 +41,7 @@ export function RedactionSelectionMenu({ <> { - const { state, provides } = useSearch(); - const { provides: scroll } = useScrollCapability(); +export const Search = ({ documentId }: SearchProps) => { + const { state, provides } = useSearch(documentId); + const { provides: scrollCapability } = useScrollCapability(); const inputRef = useRef(null); const [inputValue, setInputValue] = useState(state.query || ''); + // Get document-scoped API + const scroll = scrollCapability?.forDocument(documentId); + useEffect(() => { if (inputRef.current) { inputRef.current.focus(); @@ -128,7 +135,8 @@ export const Search = () => { scroll?.scrollToPage({ pageNumber: item.pageIndex + 1, pageCoordinates: minCoordinates, - center: true, + alignX: 50, + alignY: 50, }); }; diff --git a/examples/react-mui/src/components/sidebar/index.tsx b/examples/react-mui/src/components/sidebar/index.tsx index ea7dbccc6..313e15074 100644 --- a/examples/react-mui/src/components/sidebar/index.tsx +++ b/examples/react-mui/src/components/sidebar/index.tsx @@ -3,11 +3,18 @@ import { Box, Typography } from '@mui/material'; import { useScroll } from '@embedpdf/plugin-scroll/react'; import { ThumbnailsPane, ThumbImg } from '@embedpdf/plugin-thumbnail/react'; -export const Sidebar: React.FC = () => { - const { state, provides } = useScroll(); +interface SidebarProps { + documentId: string; +} + +export const Sidebar: React.FC = ({ documentId }) => { + const { provides: scrollCapability } = useScroll(documentId); + + // Get document-scoped API + const currentPage = scrollCapability?.getCurrentPage() ?? 1; return ( - + {(m) => ( { padding: '8px', }} onClick={() => { - provides?.scrollToPage?.({ + scrollCapability?.scrollToPage?.({ pageNumber: m.pageIndex + 1, }); }} @@ -34,7 +41,7 @@ export const Sidebar: React.FC = () => { width: m.width, height: m.height, border: '2px solid', - borderColor: state.currentPage === m.pageIndex + 1 ? 'primary.main' : '#e0e0e0', + borderColor: currentPage === m.pageIndex + 1 ? 'primary.main' : '#e0e0e0', borderRadius: 1, overflow: 'hidden', '&:hover': { @@ -44,6 +51,7 @@ export const Sidebar: React.FC = () => { }} > { - const { provides: annotationProvider } = useAnnotationCapability(); +interface AnnotationToolbarProps { + documentId: string; +} + +export const AnnotationToolbar = ({ documentId }: AnnotationToolbarProps) => { + const { provides: annotationCapability } = useAnnotationCapability(); const [activeTool, setActiveTool] = useState(null); + // Get document-scoped API + const annotationProvider = annotationCapability?.forDocument(documentId); + useEffect(() => { if (!annotationProvider) return; diff --git a/examples/react-mui/src/components/toolbar/index.tsx b/examples/react-mui/src/components/toolbar/index.tsx index 5520f211b..2f31aafd6 100644 --- a/examples/react-mui/src/components/toolbar/index.tsx +++ b/examples/react-mui/src/components/toolbar/index.tsx @@ -1,5 +1,5 @@ import { usePan } from '@embedpdf/plugin-pan/react'; -import { useRotateCapability } from '@embedpdf/plugin-rotate/react'; +import { useRotate } from '@embedpdf/plugin-rotate/react'; import { useSpread } from '@embedpdf/plugin-spread/react'; import MenuIcon from '@mui/icons-material/Menu'; import BackHandOutlinedIcon from '@mui/icons-material/BackHandOutlined'; @@ -35,19 +35,26 @@ import { AnnotationToolbar } from './annotation-toolbar'; import { SpreadMode } from '@embedpdf/plugin-spread'; import { useFullscreen } from '@embedpdf/plugin-fullscreen/react'; import { useExportCapability } from '@embedpdf/plugin-export/react'; -import { useLoaderCapability } from '@embedpdf/plugin-loader/react'; +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; import { useIsMobile } from '../../hooks/use-is-mobile'; import { RedactToolbar } from './redact-toolbar'; -export const Toolbar = () => { - const { provides: panProvider, isPanning } = usePan(); - const { provides: rotateProvider } = useRotateCapability(); - const { spreadMode, provides: spreadProvider } = useSpread(); +interface ToolbarProps { + documentId: string; +} + +export const Toolbar = ({ documentId }: ToolbarProps) => { + const { provides: panProvider, isPanning } = usePan(documentId); + const { provides: rotateProvider } = useRotate(documentId); + const { spreadMode, provides: spreadProvider } = useSpread(documentId); const { provides: fullscreenProvider, state: fullscreenState } = useFullscreen(); - const { provides: exportProvider } = useExportCapability(); - const { provides: loaderProvider } = useLoaderCapability(); + const { provides: exportCapability } = useExportCapability(); + const { provides: documentManager } = useDocumentManagerCapability(); const isMobile = useIsMobile(); + // Get document-scoped API for export + const exportProvider = exportCapability?.forDocument(documentId); + // Menu state for page settings const [pageSettingsAnchorEl, setPageSettingsAnchorEl] = useState(null); const pageSettingsOpen = Boolean(pageSettingsAnchorEl); @@ -109,7 +116,7 @@ export const Toolbar = () => { }; const handleOpenFilePicker = () => { - loaderProvider?.openFileDialog(); + documentManager?.openFileDialog(); handleMenuClose(); }; @@ -243,7 +250,7 @@ export const Toolbar = () => { flexItem sx={{ backgroundColor: 'white', my: 1.2, opacity: 0.5 }} /> - + {!isMobile && ( <> { - {mode === 'annotate' && } - {mode === 'redact' && } + {mode === 'annotate' && documentId && } + {mode === 'redact' && documentId && } ); }; diff --git a/examples/react-mui/src/components/toolbar/redact-toolbar.tsx b/examples/react-mui/src/components/toolbar/redact-toolbar.tsx index 5d890fe34..bb7e854b1 100644 --- a/examples/react-mui/src/components/toolbar/redact-toolbar.tsx +++ b/examples/react-mui/src/components/toolbar/redact-toolbar.tsx @@ -6,8 +6,12 @@ import ClearIcon from '@mui/icons-material/Clear'; import { ToggleIconButton } from '../toggle-icon-button'; import { RedactIcon, RedactAreaIcon } from '../../icons'; -export const RedactToolbar = () => { - const { provides, state } = useRedaction(); +interface RedactToolbarProps { + documentId: string; +} + +export const RedactToolbar = ({ documentId }: RedactToolbarProps) => { + const { provides, state } = useRedaction(documentId); const handleTextRedact = () => { provides?.toggleRedactSelection(); diff --git a/examples/react-mui/src/components/zoom-controls/index.tsx b/examples/react-mui/src/components/zoom-controls/index.tsx index 75cdc535c..7c6409a23 100644 --- a/examples/react-mui/src/components/zoom-controls/index.tsx +++ b/examples/react-mui/src/components/zoom-controls/index.tsx @@ -25,6 +25,10 @@ import { useInteractionManager } from '@embedpdf/plugin-interaction-manager/reac import { ToggleIconButton } from '../toggle-icon-button'; import { useIsMobile } from '../../hooks/use-is-mobile'; +interface ZoomControlsProps { + documentId: string; +} + interface ZoomModeItem { value: ZoomLevel; label: string; @@ -53,9 +57,9 @@ const ZOOM_MODES: ZoomModeItem[] = [ { value: ZoomMode.FitWidth, label: 'Fit to Width', icon: WidthFullIcon }, ] as const; -export const ZoomControls = () => { - const { state, provides } = useZoom(); - const { state: interactionManagerState } = useInteractionManager(); +export const ZoomControls = ({ documentId }: ZoomControlsProps) => { + const { state, provides } = useZoom(documentId); + const { state: interactionManagerState } = useInteractionManager(documentId); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const isMobile = useIsMobile(); diff --git a/examples/react-mui/src/icons.tsx b/examples/react-mui/src/icons.tsx index e1009185d..d882b1055 100644 --- a/examples/react-mui/src/icons.tsx +++ b/examples/react-mui/src/icons.tsx @@ -8,13 +8,13 @@ export const PageSettingsIcon = (props: SvgIconProps) => ( ); -/** Same icon, rotated 180 deg so it “points” the other way. */ +/** Same icon, rotated 180 deg so it "points" the other way. */ export const ViewSidebarReverseIcon = forwardRef((props, ref) => ( (null); - const { engine, isLoading, error } = usePdfiumEngine(); +export default function App() { + const { route } = useHashRoute(); - if (error) { - return
Error: {error.message}
; + switch (route) { + case '/': + return ; + case '/about': + return ; + case '/viewer': + return ; + case '/viewer-simple': + return ; + case '/viewer-schema': + return ; + default: + return ; } - - if (isLoading || !engine) { - return
Loading...
; - } - - return ( -
-
- - {({ pluginsReady }) => ( - - - {pluginsReady ? ( - ( - - - - - - - - )} - /> - ) : ( -
Loading plugins...
- )} -
-
- )} -
-
-
- ); } diff --git a/examples/react-tailwind/src/components/annotation-selection-menu.tsx b/examples/react-tailwind/src/components/annotation-selection-menu.tsx new file mode 100644 index 000000000..2c9c9a9ee --- /dev/null +++ b/examples/react-tailwind/src/components/annotation-selection-menu.tsx @@ -0,0 +1,55 @@ +import { + useAnnotationCapability, + type AnnotationSelectionMenuProps, +} from '@embedpdf/plugin-annotation/react'; +import { TrashIcon } from './icons'; + +interface Props extends AnnotationSelectionMenuProps { + documentId: string; +} + +export function AnnotationSelectionMenu({ + selected, + context, + documentId, + menuWrapperProps, + rect, +}: Props) { + const { provides: annotationCapability } = useAnnotationCapability(); + + // Get document-scoped annotation API + const annotationScope = annotationCapability?.forDocument(documentId); + + const handleDelete = () => { + if (!annotationScope) return; + const { pageIndex, id } = context.annotation.object; + annotationScope.deleteAnnotation(pageIndex, id); + }; + + if (!selected) return null; + + // Calculate position - position below the annotation by default + const menuStyle: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'auto', + cursor: 'default', + top: rect.size.height + 8, + }; + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/annotation-toolbar.tsx b/examples/react-tailwind/src/components/annotation-toolbar.tsx new file mode 100644 index 000000000..a28db1556 --- /dev/null +++ b/examples/react-tailwind/src/components/annotation-toolbar.tsx @@ -0,0 +1,250 @@ +import { AnnotationTool, useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; +import { useHistoryCapability } from '@embedpdf/plugin-history/react'; +import { useEffect, useState, useMemo } from 'react'; +import { ToolbarButton } from './ui'; +import { + HighlightIcon, + UnderlineIcon, + StrikethroughIcon, + SquigglyIcon, + PenIcon, + TextIcon, + CircleIcon, + SquareIcon, + PolygonIcon, + PolylineIcon, + LineIcon, + ArrowIcon, + UndoIcon, + RedoIcon, +} from './icons'; + +type AnnotationToolbarProps = { + documentId: string; +}; + +// Helper type for tool colors +type ToolColors = Record; + +// Helper function to extract tool colors +function extractToolColors(tools: AnnotationTool[]): ToolColors { + const colors: ToolColors = {}; + tools.forEach((tool) => { + const defaults = tool.defaults as any; + colors[tool.id] = { + primaryColor: defaults.strokeColor || defaults.color || defaults.fontColor, + secondaryColor: defaults.color, + }; + }); + return colors; +} + +export function AnnotationToolbar({ documentId }: AnnotationToolbarProps) { + const { provides: annotationCapability } = useAnnotationCapability(); + const { provides: historyCapability } = useHistoryCapability(); + const [activeTool, setActiveTool] = useState(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + // Initialize tool colors synchronously to avoid flash + const [toolColors, setToolColors] = useState(() => + annotationCapability ? extractToolColors(annotationCapability.getTools()) : {}, + ); + + // Get scoped API for this document + const annotationProvides = useMemo( + () => (annotationCapability ? annotationCapability.forDocument(documentId) : null), + [annotationCapability, documentId], + ); + + // Get scoped history for this document + const historyProvides = useMemo( + () => (historyCapability ? historyCapability.forDocument(documentId) : null), + [historyCapability, documentId], + ); + + useEffect(() => { + if (!annotationProvides) return; + + // Initialize with current tool + setActiveTool(annotationProvides.getActiveTool()); + + // Subscribe to changes + return annotationProvides.onActiveToolChange((tool) => { + setActiveTool(tool); + }); + }, [annotationProvides]); + + // Subscribe to tool changes to get tool defaults (only fires when tools are updated) + useEffect(() => { + if (!annotationCapability) return; + + // Subscribe to tool changes (only when tool defaults are updated) + return annotationCapability.onToolsChange((event) => { + setToolColors(extractToolColors(event.tools)); + }); + }, [annotationCapability]); + + // Subscribe to history state changes for this document + useEffect(() => { + if (!historyProvides) return; + + // Initialize with current state + const state = historyProvides.getHistoryState(); + setCanUndo(state.global.canUndo); + setCanRedo(state.global.canRedo); + + // Subscribe to history changes + return historyProvides.onHistoryChange(() => { + const newState = historyProvides.getHistoryState(); + setCanUndo(newState.global.canUndo); + setCanRedo(newState.global.canRedo); + }); + }, [historyProvides]); + + if (!annotationProvides) return null; + + const toggleTool = (toolId: string) => { + const currentId = activeTool?.id ?? null; + annotationProvides.setActiveTool(currentId === toolId ? null : toolId); + }; + + const handleUndo = () => { + if (historyProvides) { + historyProvides.undo(); + } + }; + + const handleRedo = () => { + if (historyProvides) { + historyProvides.redo(); + } + }; + + return ( +
+ toggleTool('highlight')} + isActive={activeTool?.id === 'highlight'} + aria-label="Highlight text" + title="Highlight Text" + > + + + + toggleTool('underline')} + isActive={activeTool?.id === 'underline'} + aria-label="Underline text" + title="Underline" + > + + + + toggleTool('strikeout')} + isActive={activeTool?.id === 'strikeout'} + aria-label="Strikethrough text" + title="Strikethrough" + > + + + + toggleTool('squiggly')} + isActive={activeTool?.id === 'squiggly'} + aria-label="Squiggly underline" + title="Squiggly Underline" + > + + + + toggleTool('ink')} + isActive={activeTool?.id === 'ink'} + aria-label="Freehand annotation" + title="Draw Freehand" + > + + + + toggleTool('freeText')} + isActive={activeTool?.id === 'freeText'} + aria-label="Text annotation" + title="Add Text Annotation" + > + + + + toggleTool('circle')} + isActive={activeTool?.id === 'circle'} + aria-label="Circle annotation" + title="Draw Circle" + > + + + + toggleTool('square')} + isActive={activeTool?.id === 'square'} + aria-label="Square annotation" + title="Draw Rectangle" + > + + + + toggleTool('polygon')} + isActive={activeTool?.id === 'polygon'} + aria-label="Polygon annotation" + title="Draw Polygon" + > + + + + toggleTool('polyline')} + isActive={activeTool?.id === 'polyline'} + aria-label="Polyline annotation" + title="Draw Polyline" + > + + + + toggleTool('line')} + isActive={activeTool?.id === 'line'} + aria-label="Line annotation" + title="Draw Line" + > + + + + toggleTool('lineArrow')} + isActive={activeTool?.id === 'lineArrow'} + aria-label="Arrow annotation" + title="Draw Arrow" + > + + + + {/* Divider */} +
+ + {/* Undo/Redo buttons */} + + + + + + + +
+ ); +} diff --git a/examples/react-tailwind/src/components/capture-dialog.tsx b/examples/react-tailwind/src/components/capture-dialog.tsx new file mode 100644 index 000000000..ab7c01917 --- /dev/null +++ b/examples/react-tailwind/src/components/capture-dialog.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { useCapture } from '@embedpdf/plugin-capture/react'; +import { Dialog, DialogContent, DialogFooter, Button } from './ui'; + +interface CaptureData { + pageIndex: number; + rect: any; + blob: Blob; +} + +type CaptureDialogProps = { + documentId: string; +}; + +export function CaptureDialog({ documentId }: CaptureDialogProps) { + const { provides: capture } = useCapture(documentId); + const [open, setOpen] = useState(false); + const [captureData, setCaptureData] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const urlRef = useRef(null); + const downloadLinkRef = useRef(null); + + const handleClose = () => { + // Clean up object URLs + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = null; + } + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + setDownloadUrl(null); + } + setOpen(false); + setCaptureData(null); + setPreviewUrl(null); + }; + + const handleDownload = () => { + if (!captureData || !downloadLinkRef.current) return; + + // Create download URL and trigger download + const url = URL.createObjectURL(captureData.blob); + setDownloadUrl(url); + + // Use the ref to trigger download + downloadLinkRef.current.href = url; + downloadLinkRef.current.download = `pdf-capture-page-${captureData.pageIndex + 1}.png`; + downloadLinkRef.current.click(); + + handleClose(); + }; + + useEffect(() => { + if (!capture) return; + + return capture.onCaptureArea(({ pageIndex, rect, blob }) => { + setCaptureData({ pageIndex, rect, blob }); + + // Create preview URL + const objectUrl = URL.createObjectURL(blob); + urlRef.current = objectUrl; + setPreviewUrl(objectUrl); + setOpen(true); + }); + }, [capture]); + + const handleImageLoad = () => { + // Clean up the object URL after image loads + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + urlRef.current = null; + } + }; + + return ( + <> + + +
+ {previewUrl && ( + Captured PDF area + )} +
+
+ + + + +
+ + {/* Hidden download link */} + + + ); +} diff --git a/examples/react-tailwind/src/components/command-button.tsx b/examples/react-tailwind/src/components/command-button.tsx new file mode 100644 index 000000000..15bbb357f --- /dev/null +++ b/examples/react-tailwind/src/components/command-button.tsx @@ -0,0 +1,85 @@ +import { useCommand } from '@embedpdf/plugin-commands/react'; +import { useRegisterAnchor } from '@embedpdf/plugin-ui/react'; +import { twMerge } from 'tailwind-merge'; +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/examples/react-tailwind/src/components/command-tab-button.tsx b/examples/react-tailwind/src/components/command-tab-button.tsx new file mode 100644 index 000000000..920b511c0 --- /dev/null +++ b/examples/react-tailwind/src/components/command-tab-button.tsx @@ -0,0 +1,70 @@ +import { useCommand } from '@embedpdf/plugin-commands/react'; +import { useRegisterAnchor } from '@embedpdf/plugin-ui/react'; +import { twMerge } from 'tailwind-merge'; +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-white text-gray-900 shadow-sm' + : 'text-gray-600 hover:text-gray-900'; + + const sizeClasses = variant === 'icon' ? 'p-1.5' : 'px-4 py-1'; + + return ( + + ); +} diff --git a/examples/react-tailwind/src/components/custom-zoom-toolbar.tsx b/examples/react-tailwind/src/components/custom-zoom-toolbar.tsx new file mode 100644 index 000000000..13b3a42db --- /dev/null +++ b/examples/react-tailwind/src/components/custom-zoom-toolbar.tsx @@ -0,0 +1,92 @@ +import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { CommandButton } from './command-button'; +import { useState, useEffect } from 'react'; + +/** + * Custom Zoom Toolbar Component + * + * This component is designed to be registered with the UI plugin and used + * as a custom component in the UI schema. + * + * Props: + * - documentId: The document ID (passed by the UI renderer) + */ +interface CustomZoomToolbarProps { + documentId: string; +} + +/** + * Custom Zoom Toolbar + * + * A comprehensive zoom control with: + * - Zoom in/out buttons + * - Editable zoom percentage input + * - Dropdown menu with zoom presets + */ +export function CustomZoomToolbar({ documentId }: CustomZoomToolbarProps) { + const { state, provides } = useZoom(documentId); + const [inputValue, setInputValue] = useState(''); + + if (!provides) return null; + + const zoomPercentage = Math.round(state.currentZoomLevel * 100); + + // Sync input value with zoom state when it changes externally + useEffect(() => { + setInputValue(zoomPercentage.toString()); + }, [zoomPercentage]); + + const handleZoomChange = (e: React.FormEvent) => { + e.preventDefault(); + const value = parseFloat(inputValue); + + if (!isNaN(value) && value > 0) { + provides.requestZoom(value / 100); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + // Only allow numbers + const value = e.target.value.replace(/[^0-9]/g, ''); + setInputValue(value); + }; + + const handleBlur = () => { + // Reset to actual zoom if input is invalid + if (!inputValue || parseFloat(inputValue) <= 0) { + setInputValue(zoomPercentage.toString()); + } + }; + + return ( +
+
+ {/* Editable Zoom Percentage Input */} +
+ + % +
+ + {/* Zoom Out Button */} + + {/* Zoom In Button */} + +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/document-menu.tsx b/examples/react-tailwind/src/components/document-menu.tsx new file mode 100644 index 000000000..cb6e9da9c --- /dev/null +++ b/examples/react-tailwind/src/components/document-menu.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { useExport } from '@embedpdf/plugin-export/react'; +import { useCapture } from '@embedpdf/plugin-capture/react'; +import { useFullscreen } from '@embedpdf/plugin-fullscreen/react'; +import { + MenuIcon, + PrintIcon, + DownloadIcon, + ScreenshotIcon, + FullscreenIcon, + FullscreenExitIcon, +} from './icons'; +import { PrintDialog } from './print-dialog'; +import { CaptureDialog } from './capture-dialog'; +import { ToolbarButton, DropdownMenu, DropdownItem } from './ui'; + +type DocumentMenuProps = { + documentId: string; +}; + +export function DocumentMenu({ documentId }: DocumentMenuProps) { + const { provides: exportProvider } = useExport(documentId); + const { provides: captureProvider, state: captureState } = useCapture(documentId); + const { provides: fullscreenProvider, state: fullscreenState } = useFullscreen(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isPrintDialogOpen, setIsPrintDialogOpen] = useState(false); + + if (!exportProvider) return null; + + const handleDownload = () => { + exportProvider.download(); + setIsMenuOpen(false); + }; + + const handlePrint = () => { + setIsMenuOpen(false); + setIsPrintDialogOpen(true); + }; + + const handleScreenshot = () => { + if (captureProvider) { + captureProvider.toggleMarqueeCapture(); + } + setIsMenuOpen(false); + }; + + const handleFullscreen = () => { + fullscreenProvider?.toggleFullscreen(`#${documentId}`); + setIsMenuOpen(false); + }; + + return ( + <> +
+ setIsMenuOpen(!isMenuOpen)} + isActive={isMenuOpen} + aria-label="Document Menu" + title="Document Menu" + > + + + + setIsMenuOpen(false)} className="w-48"> + } + > + Capture Area + + } + > + Print + + } + > + Download + + + ) : ( + + ) + } + > + {fullscreenState.isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + +
+ + {/* Print Dialog */} + setIsPrintDialogOpen(false)} + /> + + {/* Capture Dialog */} + + + ); +} diff --git a/examples/react-tailwind/src/components/document-password-prompt.tsx b/examples/react-tailwind/src/components/document-password-prompt.tsx new file mode 100644 index 000000000..6ede58aa5 --- /dev/null +++ b/examples/react-tailwind/src/components/document-password-prompt.tsx @@ -0,0 +1,127 @@ +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/examples/react-tailwind/src/components/empty-state.tsx b/examples/react-tailwind/src/components/empty-state.tsx new file mode 100644 index 000000000..0f2e696c2 --- /dev/null +++ b/examples/react-tailwind/src/components/empty-state.tsx @@ -0,0 +1,65 @@ +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/examples/react-tailwind/src/components/icons/index.tsx b/examples/react-tailwind/src/components/icons/index.tsx new file mode 100644 index 000000000..215924d0b --- /dev/null +++ b/examples/react-tailwind/src/components/icons/index.tsx @@ -0,0 +1,1276 @@ +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 CommentIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + + + ); +} + +export function CopyIcon({ className, title }: IconProps) { + return ( + + {title ? {title} : null} + + + + + ); +} diff --git a/examples/react-tailwind/src/components/loading-spinner.tsx b/examples/react-tailwind/src/components/loading-spinner.tsx new file mode 100644 index 000000000..e1f42157d --- /dev/null +++ b/examples/react-tailwind/src/components/loading-spinner.tsx @@ -0,0 +1,34 @@ +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/examples/react-tailwind/src/components/navigation-bar.tsx b/examples/react-tailwind/src/components/navigation-bar.tsx new file mode 100644 index 000000000..789e3b890 --- /dev/null +++ b/examples/react-tailwind/src/components/navigation-bar.tsx @@ -0,0 +1,17 @@ +export function NavigationBar() { + return ( +
+
+ + ← Home + + | + PDF Viewer +
+
EmbedPDF React Example
+
+ ); +} diff --git a/examples/react-tailwind/src/components/outline-sidebar.tsx b/examples/react-tailwind/src/components/outline-sidebar.tsx new file mode 100644 index 000000000..852a18d90 --- /dev/null +++ b/examples/react-tailwind/src/components/outline-sidebar.tsx @@ -0,0 +1,25 @@ +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/examples/react-tailwind/src/components/page-controls.tsx b/examples/react-tailwind/src/components/page-controls.tsx new file mode 100644 index 000000000..0d0b00dcd --- /dev/null +++ b/examples/react-tailwind/src/components/page-controls.tsx @@ -0,0 +1,140 @@ +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 rounded border border-gray-300 bg-white px-1 text-center text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + {totalPages} +
+ + {/* Next Button */} + +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/page-settings-menu.tsx b/examples/react-tailwind/src/components/page-settings-menu.tsx new file mode 100644 index 000000000..67227a998 --- /dev/null +++ b/examples/react-tailwind/src/components/page-settings-menu.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useRotate } from '@embedpdf/plugin-rotate/react'; +import { useSpread } from '@embedpdf/plugin-spread/react'; +import { SpreadMode } from '@embedpdf/plugin-spread'; +import { + SettingsIcon, + RotateRightIcon, + RotateLeftIcon, + SinglePageIcon, + BookOpenIcon, +} from './icons'; +import { ToolbarButton, DropdownMenu, DropdownSection, DropdownItem, DropdownDivider } from './ui'; + +type PageSettingsMenuProps = { + documentId: string; +}; + +export function PageSettingsMenu({ documentId }: PageSettingsMenuProps) { + const { provides: rotate } = useRotate(documentId); + const { spreadMode, provides: spread } = useSpread(documentId); + const [isOpen, setIsOpen] = useState(false); + + if (!rotate || !spread) return null; + + return ( +
+ setIsOpen(!isOpen)} + isActive={isOpen} + aria-label="Page Settings" + title="Page Settings" + > + + + + setIsOpen(false)} className="w-56"> + + { + rotate.rotateForward(); + setIsOpen(false); + }} + icon={} + > + Rotate Clockwise + + { + rotate.rotateBackward(); + setIsOpen(false); + }} + icon={} + > + Rotate Counter-clockwise + + + + + + + { + spread.setSpreadMode(SpreadMode.None); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.None} + > + Single Page + + { + spread.setSpreadMode(SpreadMode.Odd); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.Odd} + > + Odd Pages + + { + spread.setSpreadMode(SpreadMode.Even); + setIsOpen(false); + }} + icon={} + isActive={spreadMode === SpreadMode.Even} + > + Even Pages + + + +
+ ); +} diff --git a/examples/react-tailwind/src/components/pan-toggle.tsx b/examples/react-tailwind/src/components/pan-toggle.tsx new file mode 100644 index 000000000..14c63a91c --- /dev/null +++ b/examples/react-tailwind/src/components/pan-toggle.tsx @@ -0,0 +1,24 @@ +import { usePan } from '@embedpdf/plugin-pan/react'; +import { HandIcon } from './icons'; +import { ToolbarButton } from './ui'; + +type PanToggleButtonProps = { + documentId: string; +}; + +export function PanToggleButton({ documentId }: PanToggleButtonProps) { + const { provides: pan, isPanning } = usePan(documentId); + + if (!pan) return null; + + return ( + + + + ); +} diff --git a/examples/react-tailwind/src/components/print-dialog.tsx b/examples/react-tailwind/src/components/print-dialog.tsx new file mode 100644 index 000000000..75592d113 --- /dev/null +++ b/examples/react-tailwind/src/components/print-dialog.tsx @@ -0,0 +1,163 @@ +import { useState, useEffect } from 'react'; +import { usePrint } from '@embedpdf/plugin-print/react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import type { PdfPrintOptions } from '@embedpdf/models'; +import { Dialog, DialogContent, DialogFooter, Button } from './ui'; + +type PageSelection = 'all' | 'current' | 'custom'; + +type PrintDialogProps = { + documentId: string; + isOpen: boolean; + onClose: () => void; +}; + +export function PrintDialog({ documentId, isOpen, onClose }: PrintDialogProps) { + const { provides: print } = usePrint(documentId); + const { state: scrollState } = useScroll(documentId); + + const [selection, setSelection] = useState('all'); + const [customPages, setCustomPages] = useState(''); + const [includeAnnotations, setIncludeAnnotations] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + // Reset form when dialog opens/closes + useEffect(() => { + if (!isOpen) { + setSelection('all'); + setCustomPages(''); + setIncludeAnnotations(true); + setIsLoading(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + const canSubmit = !isLoading && (selection !== 'custom' || customPages.trim().length > 0); + + const handlePrint = async () => { + if (!print || !canSubmit) return; + + setIsLoading(true); + + let pageRange: string | undefined; + + if (selection === 'current') { + pageRange = String(scrollState.currentPage); + } else if (selection === 'custom') { + pageRange = customPages.trim() || undefined; + } + + const options: PdfPrintOptions = { + includeAnnotations, + pageRange, + }; + + try { + const task = print.print(options); + + if (task) { + task.wait( + () => { + onClose(); + }, + (error) => { + console.error('Print failed:', error); + setIsLoading(false); + }, + ); + } + } catch (err) { + console.error('Print failed:', err); + setIsLoading(false); + } + }; + + return ( + + + {/* Pages to print */} +
+ +
+ + + + + +
+ + {/* Custom page range input */} +
+ setCustomPages(e.target.value)} + placeholder="e.g., 1-3, 5, 8-10" + disabled={selection !== 'custom'} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500" + /> + {customPages.trim() && scrollState.totalPages > 0 && ( +

+ Total pages in document: {scrollState.totalPages} +

+ )} +
+
+ + {/* Include annotations */} +
+ +
+
+ + + + +
+ ); +} diff --git a/examples/react-tailwind/src/components/redaction-selection-menu.tsx b/examples/react-tailwind/src/components/redaction-selection-menu.tsx new file mode 100644 index 000000000..65bfb27e3 --- /dev/null +++ b/examples/react-tailwind/src/components/redaction-selection-menu.tsx @@ -0,0 +1,69 @@ +import { + useRedactionCapability, + type RedactionSelectionMenuProps, +} from '@embedpdf/plugin-redaction/react'; +import { CheckIcon, TrashIcon } from './icons'; + +interface Props extends RedactionSelectionMenuProps { + documentId: string; +} + +export function RedactionSelectionMenu({ + selected, + context, + documentId, + menuWrapperProps, + rect, +}: Props) { + const { provides: redactionCapability } = useRedactionCapability(); + + // Get document-scoped annotation API + const redactionScope = redactionCapability?.forDocument(documentId); + + const handleDelete = () => { + if (!redactionScope) return; + const { page, id } = context.item; + redactionScope.removePending(page, id); + }; + + const handleCommit = () => { + if (!redactionScope) return; + const { page, id } = context.item; + redactionScope.commitPending(page, id); + }; + + if (!selected) return null; + + // Calculate position - position below the annotation by default + const menuStyle: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'auto', + cursor: 'default', + top: rect.size.height + 8, + }; + + return ( +
+
+
+ + +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/redaction-toolbar.tsx b/examples/react-tailwind/src/components/redaction-toolbar.tsx new file mode 100644 index 000000000..5f0e88629 --- /dev/null +++ b/examples/react-tailwind/src/components/redaction-toolbar.tsx @@ -0,0 +1,82 @@ +import { RedactionMode, useRedaction } from '@embedpdf/plugin-redaction/react'; +import { ToolbarButton } from './ui'; +import { CheckIcon, CloseIcon, RedactTextIcon, RedactAreaIcon } from './icons'; + +type RedactionToolbarProps = { + documentId: string; +}; + +export function RedactionToolbar({ documentId }: RedactionToolbarProps) { + const { provides, state } = useRedaction(documentId); + + if (!provides) return null; + + const handleTextRedact = () => { + provides.toggleRedactSelection(); + }; + + const handleAreaRedact = () => { + provides.toggleMarqueeRedact(); + }; + + const handleCommitPending = () => { + provides.commitAllPending(); + }; + + const handleClearPending = () => { + provides.clearPending(); + }; + + return ( +
+ {/* Redaction Mode Toggles */} + + + + + + + + + {/* Divider */} +
+ + {/* Action Buttons */} + + + + + {state.pendingCount > 0 && ( + + {state.pendingCount} pending redaction{state.pendingCount !== 1 ? 's' : ''} + + )} +
+ ); +} diff --git a/examples/react-tailwind/src/components/search-sidebar.tsx b/examples/react-tailwind/src/components/search-sidebar.tsx new file mode 100644 index 000000000..45bec4040 --- /dev/null +++ b/examples/react-tailwind/src/components/search-sidebar.tsx @@ -0,0 +1,255 @@ +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'; +import { useTranslations } from '@embedpdf/plugin-i18n/react'; + +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 { translate } = useTranslations(documentId); + 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, + center: true, + }); + }; + + 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 */} +
+

{translate('search.title')}

+ +
+ + {/* Search Input */} +
+
+
+ +
+ + {inputValue && ( + + )} +
+ + {/* Options */} +
+ + +
+ + {/* Results count and navigation */} + {state.active && !state.loading && state.total > 0 && ( +
+ + {translate('search.resultsFound', { params: { count: state.total } })} + + {state.total > 1 && ( +
+ + +
+ )} +
+ )} +
+ + {/* Results */} +
+ {state.loading ? ( +
+
+
+ ) : ( +
+ {Object.entries(grouped).map(([page, hits]) => ( +
+
+ {translate('search.page', { params: { number: Number(page) + 1 } })} +
+
+ {hits.map(({ hit, index }) => ( + provides.goToResult(index)} + /> + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/selection-selection-menu.tsx b/examples/react-tailwind/src/components/selection-selection-menu.tsx new file mode 100644 index 000000000..db7d3c153 --- /dev/null +++ b/examples/react-tailwind/src/components/selection-selection-menu.tsx @@ -0,0 +1,79 @@ +import { SelectionSelectionMenuProps } from '@embedpdf/plugin-selection/react'; +import { useSelectionCapability } from '@embedpdf/plugin-selection/react'; +import { useState, useEffect } from 'react'; +import { SquaresIcon, CheckIcon } from './icons'; + +export interface Props extends SelectionSelectionMenuProps { + documentId: string; +} + +export function SelectionSelectionMenu({ rect, menuWrapperProps, placement, documentId }: Props) { + const { provides: selectionCapability } = useSelectionCapability(); + const [copied, setCopied] = useState(false); + + // Reset copied state when placement changes + useEffect(() => { + setCopied(false); + }, [placement]); + + const handleCopy = () => { + if (!selectionCapability) return; + + const scope = selectionCapability.forDocument(documentId); + if (!scope) return; + + // Copy to clipboard + scope.copyToClipboard(); + + // clear selection + scope.clear(); + + // Show feedback + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1500); + }; + + // Calculate position based on suggestTop + const menuStyle: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'auto', + cursor: 'default', + }; + + if (placement.suggestTop) { + // Position above the selection + menuStyle.top = -40 - 8; + } else { + // Position below the selection (default) + menuStyle.top = rect.size.height + 8; + } + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/split-view-layout.tsx b/examples/react-tailwind/src/components/split-view-layout.tsx new file mode 100644 index 000000000..2ad2f8f36 --- /dev/null +++ b/examples/react-tailwind/src/components/split-view-layout.tsx @@ -0,0 +1,64 @@ +import { + useAllViews, + useViewManagerCapability, + ViewContextRenderProps, +} from '@embedpdf/plugin-view-manager/react'; +import { ViewContext } from '@embedpdf/plugin-view-manager/react'; +import { ReactNode, useEffect } from 'react'; + +interface SplitViewLayoutProps { + renderView: (context: ViewContextRenderProps) => ReactNode; +} + +export function SplitViewLayout({ renderView }: SplitViewLayoutProps) { + const allViews = useAllViews(); + const { provides: viewManager } = useViewManagerCapability(); + + // Auto-remove empty views (except if it's the only view) + useEffect(() => { + if (!viewManager) return; + + const emptyViews = allViews.filter((v) => v.documentIds.length === 0); + + if (emptyViews.length > 0 && allViews.length > 1) { + emptyViews.forEach((emptyView) => { + if (allViews.length > 1) { + viewManager.removeView(emptyView.id); + } + }); + } + }, [allViews, viewManager]); + + const getLayoutClass = () => { + switch (allViews.length) { + case 1: + return 'grid-cols-1'; + case 2: + return 'grid-cols-2'; + case 3: + case 4: + return 'grid-cols-2 grid-rows-2'; + default: + return 'grid-cols-3'; + } + }; + + return ( +
+ {allViews.map((view) => ( + + {(context) => ( +
+ {renderView(context)} +
+ )} +
+ ))} +
+ ); +} diff --git a/examples/react-tailwind/src/components/tab-bar-2.tsx b/examples/react-tailwind/src/components/tab-bar-2.tsx new file mode 100644 index 000000000..5b27fb905 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-bar-2.tsx @@ -0,0 +1,94 @@ +import { DocumentState } from '@embedpdf/core'; +import { CloseIcon, DocumentIcon, PlusIcon } from './icons'; +import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react'; +import { useCallback } from 'react'; + +type TabBarProps = { + documentStates: DocumentState[]; + activeDocumentId: string | null; +}; + +export function TabBar({ documentStates, activeDocumentId }: TabBarProps) { + const { provides } = useDocumentManagerCapability(); + + const onSelect = useCallback( + (id: string) => { + provides?.setActiveDocument(id); + }, + [provides], + ); + + const onClose = useCallback( + (id: string) => { + provides?.closeDocument(id); + }, + [provides], + ); + + const onOpenFile = useCallback(() => { + provides?.openFileDialog(); + }, [provides]); + + return ( +
+ {/* Document Tabs */} +
+ {documentStates.map((document) => ( +
onSelect(document.id)} + role="tab" + tabIndex={0} + aria-selected={activeDocumentId === document.id} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(document.id); + } + }} + className={`group relative flex min-w-[120px] max-w-[240px] cursor-pointer items-center gap-2 rounded-t-md px-3 py-2.5 text-sm font-medium transition-all ${ + activeDocumentId === document.id + ? 'bg-white text-gray-900 shadow-[0_2px_4px_-1px_rgba(0,0,0,0.06)]' + : 'bg-gray-200/60 text-gray-600 hover:bg-gray-200 hover:text-gray-800' + } `} + > + {/* Document Icon */} + + + {/* Document Name */} + + {document.name ?? `Document ${document.id.slice(0, 8)}`} + + + {/* Close Button */} + +
+ ))} + + {/* Add Tab (Open File) - placed directly after tabs like Chrome */} + +
+
+ ); +} diff --git a/examples/react-tailwind/src/components/tab-bar.tsx b/examples/react-tailwind/src/components/tab-bar.tsx new file mode 100644 index 000000000..847f3ef88 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-bar.tsx @@ -0,0 +1,103 @@ +import { DocumentState } from '@embedpdf/core'; +import { useState, MouseEvent } from 'react'; +import { TabContextMenu } from './tab-context-menu'; +import { View } from '@embedpdf/plugin-view-manager/react'; +import { useOpenDocuments } from '@embedpdf/plugin-document-manager/react'; +import { CloseIcon, DocumentIcon, PlusIcon } from './icons'; + +interface TabBarProps { + currentView: View | undefined; + onSelect: (documentId: string) => void; + onClose: (documentId: string) => void; + onOpenFile: () => void; +} + +export function TabBar({ currentView, onSelect, onClose, onOpenFile }: TabBarProps) { + const documentStates = useOpenDocuments(currentView?.documentIds ?? []); + const [contextMenu, setContextMenu] = useState<{ + documentState: DocumentState; + position: { x: number; y: number }; + } | null>(null); + + const handleContextMenu = (e: MouseEvent, documentState: DocumentState) => { + e.preventDefault(); + setContextMenu({ + documentState, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + return ( + <> +
+ {/* Document Tabs */} +
+ {documentStates.map((doc) => ( +
onSelect(doc.id)} + onContextMenu={(e) => handleContextMenu(e, doc)} + role="tab" + tabIndex={0} + aria-selected={doc.id === currentView?.activeDocumentId} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(doc.id); + } + }} + className={`group relative flex min-w-[120px] max-w-[240px] cursor-pointer items-center gap-2 rounded-t-md px-3 py-2.5 text-sm font-medium transition-all ${ + doc.id === currentView?.activeDocumentId + ? 'bg-white text-gray-900 shadow-[0_2px_4px_-1px_rgba(0,0,0,0.06)]' + : 'bg-gray-200/60 text-gray-600 hover:bg-gray-200 hover:text-gray-800' + } `} + > + {/* Document Icon */} + + + {/* Document Name */} + {doc.name || 'Untitled'} + + {/* Close Button */} + +
+ ))} + + {/* Add Tab (Open File) - placed directly after tabs like Chrome */} + +
+
+ + {/* Context Menu */} + {contextMenu && currentView && ( + setContextMenu(null)} + /> + )} + + ); +} diff --git a/examples/react-tailwind/src/components/tab-context-menu.tsx b/examples/react-tailwind/src/components/tab-context-menu.tsx new file mode 100644 index 000000000..880839a02 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-context-menu.tsx @@ -0,0 +1,84 @@ +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/examples/react-tailwind/src/components/thumbnails-sidebar.tsx b/examples/react-tailwind/src/components/thumbnails-sidebar.tsx new file mode 100644 index 000000000..3de0a0811 --- /dev/null +++ b/examples/react-tailwind/src/components/thumbnails-sidebar.tsx @@ -0,0 +1,77 @@ +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, onClose }: ThumbnailsSidebarProps) { + const { state, provides } = useScroll(documentId); + + return ( +
+ {/* Thumbnails */} +
+ + {(m) => ( +
{ + provides?.scrollToPage?.({ + pageNumber: m.pageIndex + 1, + }); + }} + > +
+ +
+
+ {m.pageIndex + 1} +
+
+ )} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/components/ui/button.tsx b/examples/react-tailwind/src/components/ui/button.tsx new file mode 100644 index 000000000..117911de9 --- /dev/null +++ b/examples/react-tailwind/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +type ButtonVariant = 'default' | 'primary' | 'secondary' | 'ghost'; + +type ButtonProps = ButtonHTMLAttributes & { + children: ReactNode; + variant?: ButtonVariant; + active?: boolean; + tooltip?: string; +}; + +const variantStyles: Record = { + default: 'hover:bg-gray-100 hover:ring hover:ring-[#1a466b]', + primary: + 'bg-blue-600 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50', + secondary: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50', + ghost: 'text-gray-700 hover:bg-gray-100 disabled:opacity-50', +}; + +export function Button({ + children, + onClick, + variant = 'default', + active = false, + disabled = false, + className = '', + tooltip, + ...props +}: ButtonProps) { + // For default variant with active state (toolbar buttons) + if (variant === 'default') { + return ( + + ); + } + + // For other variants (dialog buttons) + return ( + + ); +} diff --git a/examples/react-tailwind/src/components/ui/dialog-content.tsx b/examples/react-tailwind/src/components/ui/dialog-content.tsx new file mode 100644 index 000000000..c662899f3 --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dialog-content.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; + +type DialogContentProps = { + children: ReactNode; + className?: string; +}; + +export function DialogContent({ children, className = '' }: DialogContentProps) { + return
{children}
; +} diff --git a/examples/react-tailwind/src/components/ui/dialog-footer.tsx b/examples/react-tailwind/src/components/ui/dialog-footer.tsx new file mode 100644 index 000000000..13d9582ae --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dialog-footer.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; + +type DialogFooterProps = { + children: ReactNode; + className?: string; +}; + +export function DialogFooter({ children, className = '' }: DialogFooterProps) { + return ( +
+ {children} +
+ ); +} diff --git a/snippet/src/components/ui/dialog.tsx b/examples/react-tailwind/src/components/ui/dialog.tsx similarity index 77% rename from snippet/src/components/ui/dialog.tsx rename to examples/react-tailwind/src/components/ui/dialog.tsx index 7b4f30434..8af2668f2 100644 --- a/snippet/src/components/ui/dialog.tsx +++ b/examples/react-tailwind/src/components/ui/dialog.tsx @@ -1,8 +1,5 @@ -/** @jsxImportSource preact */ -import { h, ComponentChildren } from 'preact'; -import { useEffect, useRef } from 'preact/hooks'; -import { Icon } from './icon'; -import { Button } from './button'; +import { ReactNode, useEffect, useRef } from 'react'; +import { CloseIcon } from '../icons'; export interface DialogProps { /** Controlled visibility — `true` shows, `false` hides */ @@ -10,7 +7,7 @@ export interface DialogProps { /** Dialog title */ title?: string; /** Dialog content */ - children: ComponentChildren; + children: ReactNode; /** Callback when dialog should close */ onClose?: () => void; /** Optional className for the dialog content */ @@ -47,7 +44,7 @@ export function Dialog({ }, [open, onClose]); // Handle backdrop click - const handleBackdropClick = (e: MouseEvent) => { + const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === overlayRef.current) { onClose?.(); } @@ -75,7 +72,8 @@ export function Dialog({ onClick={handleBackdropClick} >
e.stopPropagation()} > {/* Header */} @@ -83,15 +81,19 @@ export function Dialog({
{title &&

{title}

} {showCloseButton && ( - + )}
)} {/* Content */} -
+
{children}
diff --git a/examples/react-tailwind/src/components/ui/dropdown-menu.tsx b/examples/react-tailwind/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..36f58629a --- /dev/null +++ b/examples/react-tailwind/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,69 @@ +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/examples/react-tailwind/src/components/ui/index.ts b/examples/react-tailwind/src/components/ui/index.ts new file mode 100644 index 000000000..f2d8aa93e --- /dev/null +++ b/examples/react-tailwind/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export * from './toolbar-button'; +export * from './dropdown-menu'; +export * from './dialog'; +export * from './dialog-content'; +export * from './dialog-footer'; +export * from './button'; +export * from './toolbar-divider'; diff --git a/examples/react-tailwind/src/components/ui/toolbar-button.tsx b/examples/react-tailwind/src/components/ui/toolbar-button.tsx new file mode 100644 index 000000000..c60e9030c --- /dev/null +++ b/examples/react-tailwind/src/components/ui/toolbar-button.tsx @@ -0,0 +1,59 @@ +import { ReactNode, forwardRef } from 'react'; +import { twMerge } from 'tailwind-merge'; + +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-blue-50 text-blue-500 shadow ring ring-blue-500' + : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 hover:ring hover:ring-[#1a466b]'; + + const disabledClasses = disabled + ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-600 hover:ring-0' + : ''; + + const mergedClasses = twMerge( + 'rounded p-1.5 transition-colors', + baseClasses, + disabledClasses, + className, + ); + + return ( + + ); + }, +); + +ToolbarButton.displayName = 'ToolbarButton'; diff --git a/examples/react-tailwind/src/components/ui/toolbar-divider.tsx b/examples/react-tailwind/src/components/ui/toolbar-divider.tsx new file mode 100644 index 000000000..7306047b9 --- /dev/null +++ b/examples/react-tailwind/src/components/ui/toolbar-divider.tsx @@ -0,0 +1,13 @@ +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-gray-300 ${className}` + : `mx-1 h-6 w-px bg-gray-300 ${className}`; + + return
; +} diff --git a/examples/react-tailwind/src/components/viewer-toolbar.tsx b/examples/react-tailwind/src/components/viewer-toolbar.tsx new file mode 100644 index 000000000..993dabdc0 --- /dev/null +++ b/examples/react-tailwind/src/components/viewer-toolbar.tsx @@ -0,0 +1,107 @@ +import { ZoomToolbar } from './zoom-toolbar'; +import { PanToggleButton } from './pan-toggle'; +import { PageSettingsMenu } from './page-settings-menu'; +import { DocumentMenu } from './document-menu'; +import { SearchIcon, ThumbnailsIcon } from './icons'; +import { ToolbarButton, ToolbarDivider } from './ui'; +import { RedactionToolbar } from './redaction-toolbar'; +import { AnnotationToolbar } from './annotation-toolbar'; + +export type ViewMode = 'view' | 'annotate' | 'redact'; + +type ViewerToolbarProps = { + documentId: string; + onToggleSearch: () => void; + onToggleThumbnails: () => void; + isSearchOpen: boolean; + isThumbnailsOpen: boolean; + mode: ViewMode; + onModeChange: (mode: ViewMode) => void; +}; + +export function ViewerToolbar({ + documentId, + onToggleSearch, + onToggleThumbnails, + isSearchOpen, + isThumbnailsOpen, + mode, + onModeChange, +}: ViewerToolbarProps) { + return ( + <> + {/* Main Toolbar */} +
+ {/* Left side - Document menu and Thumbnails toggle */} + + + + + + + + {/* Center - Zoom toolbar */} + + + + + + + {/* Mode Tabs */} +
+
+ + + +
+
+ + {/* Right side - Search toggle */} + + + +
+ + {/* Redaction Toolbar */} + {mode === 'redact' && } + {mode === 'annotate' && } + + ); +} diff --git a/examples/react-tailwind/src/components/zoom-toolbar.tsx b/examples/react-tailwind/src/components/zoom-toolbar.tsx new file mode 100644 index 000000000..f88f0bab7 --- /dev/null +++ b/examples/react-tailwind/src/components/zoom-toolbar.tsx @@ -0,0 +1,162 @@ +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/examples/react-tailwind/src/config/commands.ts b/examples/react-tailwind/src/config/commands.ts new file mode 100644 index 000000000..dea0b1fbb --- /dev/null +++ b/examples/react-tailwind/src/config/commands.ts @@ -0,0 +1,1180 @@ +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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + icon: 'Alert', + categories: ['document'], + action: () => { + console.log('Document properties clicked'); + }, + }, + + // ───────────────────────────────────────────────────────── + // Panel Commands + // ───────────────────────────────────────────────────────── + 'panel:toggle-sidebar': { + id: 'panel:toggle-sidebar', + labelKey: 'panel.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', + 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'); + }, + }, + + 'panel:toggle-comment': { + id: 'panel:toggle-comment', + labelKey: 'panel.comment', + icon: 'Comment', + categories: ['panels'], + action: ({ registry, documentId }) => { + 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', 'comment-panel'); + }, + active: ({ state, documentId }) => { + return isSidebarOpen(state.plugins, documentId, 'right', 'main', 'comment-panel'); + }, + }, + + // ───────────────────────────────────────────────────────── + // Page Settings Commands + // ───────────────────────────────────────────────────────── + 'page:settings': { + id: 'page:settings', + labelKey: '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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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/examples/react-tailwind/src/config/index.ts b/examples/react-tailwind/src/config/index.ts new file mode 100644 index 000000000..506fc5f12 --- /dev/null +++ b/examples/react-tailwind/src/config/index.ts @@ -0,0 +1,4 @@ +export * from './commands'; +export * from './types'; +export * from './ui-schema'; +export * from './translations'; diff --git a/examples/react-tailwind/src/config/translations.ts b/examples/react-tailwind/src/config/translations.ts new file mode 100644 index 000000000..95f2e4ab3 --- /dev/null +++ b/examples/react-tailwind/src/config/translations.ts @@ -0,0 +1,428 @@ +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', + comment: 'Comment', + 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', + comment: 'Comentario', + 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', + comment: 'Kommentar', + 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', + comment: 'Commentaar', + 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/examples/react-tailwind/src/config/types.ts b/examples/react-tailwind/src/config/types.ts new file mode 100644 index 000000000..bc06e7938 --- /dev/null +++ b/examples/react-tailwind/src/config/types.ts @@ -0,0 +1,41 @@ +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/examples/react-tailwind/src/config/ui-schema.ts b/examples/react-tailwind/src/config/ui-schema.ts new file mode 100644 index 000000000..c8fa608be --- /dev/null +++ b/examples/react-tailwind/src/config/ui-schema.ts @@ -0,0 +1,887 @@ +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', + }, + { + type: 'command-button', + id: 'comment-button', + commandId: 'panel:toggle-comment', + 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:open', + commandId: 'document:open', + }, + { + type: 'command', + id: 'document:close', + commandId: 'document:close', + }, + { + type: 'divider', + id: 'divider-10', + }, + { + type: 'command', + id: 'document:print', + commandId: 'document:print', + }, + { + type: 'command', + id: 'document:export', + commandId: 'document:export', + }, + { + type: 'divider', + id: 'divider-11', + }, + { + type: 'command', + id: 'document:properties', + commandId: 'document:properties', + }, + ], + }, + '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, + }, + + 'comment-panel': { + id: 'comment-panel', + position: { + placement: 'right', + slot: 'main', + order: 0, + }, + content: { + type: 'component', + componentId: 'comment-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/examples/react-tailwind/src/index.css b/examples/react-tailwind/src/index.css index d4b507858..57153ca95 100644 --- a/examples/react-tailwind/src/index.css +++ b/examples/react-tailwind/src/index.css @@ -1 +1,37 @@ @import 'tailwindcss'; + +@layer utilities { + /* Safe area padding for mobile devices */ + .pb-safe { + padding-bottom: env(safe-area-inset-bottom); + } + + /* Animations */ + @keyframes slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-slide-up { + animation: slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1); + } + + .animate-fade-in { + animation: fade-in 0.15s ease-out; + } +} diff --git a/examples/react-tailwind/src/pages/about.tsx b/examples/react-tailwind/src/pages/about.tsx new file mode 100644 index 000000000..4c30cf7e6 --- /dev/null +++ b/examples/react-tailwind/src/pages/about.tsx @@ -0,0 +1,53 @@ +export function AboutPage() { + return ( +
+
+

About This Example

+ +
+

+ This example demonstrates how to build a fully-featured PDF viewer using EmbedPDF with + React 18 and Tailwind CSS. It showcases the power and flexibility of EmbedPDF's plugin + system. +

+ +
+

Key Technologies:

+
    +
  • EmbedPDF - High-performance PDF rendering
  • +
  • React 18 - Modern component architecture
  • +
  • Tailwind CSS - Utility-first styling
  • +
  • PDFium Engine - Google's PDF rendering engine
  • +
  • TypeScript - Type-safe development
  • +
+
+ +
+

Use Cases:

+

+ This example serves as a starting point for building document management systems, + annotation tools, online document readers, and any application that needs robust PDF + viewing capabilities. +

+
+
+ + +
+
+ ); +} diff --git a/examples/react-tailwind/src/pages/home.tsx b/examples/react-tailwind/src/pages/home.tsx new file mode 100644 index 000000000..5c5b9c759 --- /dev/null +++ b/examples/react-tailwind/src/pages/home.tsx @@ -0,0 +1,65 @@ +export function HomePage() { + return ( +
+
+

EmbedPDF React + Tailwind

+ +
+

+ Welcome to the EmbedPDF example for React and Tailwind CSS. This demonstrates how to + integrate a powerful PDF viewer into your React applications with a beautiful, modern + UI. +

+ +
+

Features:

+
    +
  • High-performance PDF rendering with PDFium
  • +
  • Multiple document support with tabs
  • +
  • Zoom, pan, and page navigation
  • +
  • Responsive design with Tailwind CSS
  • +
  • Modern React 18 architecture
  • +
+
+
+ + + +
+

+ Getting Started: Choose a viewer to open and load your PDF documents. + The ViewManager version supports split views, while the simple version uses tabs. +

+
+
+
+ ); +} diff --git a/examples/react-tailwind/src/pages/viewer-schema.tsx b/examples/react-tailwind/src/pages/viewer-schema.tsx new file mode 100644 index 000000000..aaa600df8 --- /dev/null +++ b/examples/react-tailwind/src/pages/viewer-schema.tsx @@ -0,0 +1,322 @@ +import { useMemo, useRef } from 'react'; +import { EmbedPDF } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { ViewportPluginPackage, Viewport } from '@embedpdf/plugin-viewport/react'; +import { ScrollPluginPackage, ScrollStrategy, Scroller } from '@embedpdf/plugin-scroll/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, +} from '@embedpdf/plugin-document-manager/react'; +import { + InteractionManagerPluginPackage, + GlobalPointerProvider, + PagePointerProvider, +} from '@embedpdf/plugin-interaction-manager/react'; +import { + ZoomMode, + ZoomPluginPackage, + MarqueeZoom, + ZoomGestureWrapper, +} from '@embedpdf/plugin-zoom/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { MarqueeCapture, CapturePluginPackage } from '@embedpdf/plugin-capture/react'; +import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; +import { AnnotationPluginPackage, AnnotationLayer } from '@embedpdf/plugin-annotation/react'; +import { CommandsPluginPackage } from '@embedpdf/plugin-commands/react'; +import { I18nPluginPackage } from '@embedpdf/plugin-i18n/react'; +import { + UIPluginPackage, + UIProvider, + UIRenderers, + useSchemaRenderer, + useSelectionMenu, +} from '@embedpdf/plugin-ui/react'; +import { TabBar } from '../components/tab-bar-2'; +import { LoadingSpinner } from '../components/loading-spinner'; +import { DocumentPasswordPrompt } from '../components/document-password-prompt'; +import { PageControls } from '../components/page-controls'; +import { ConsoleLogger } from '@embedpdf/models'; +import { NavigationBar } from '../components/navigation-bar'; +import { EmptyState } from '../components/empty-state'; +import { commands } from '../config/commands'; +import { viewerUISchema } from '../config/ui-schema'; +import { SchemaToolbar } from '../ui/schema-toolbar'; +import { SchemaPanel } from '../ui/schema-panel'; +import { SchemaMenu } from '../ui/schema-menu'; +import { CustomZoomToolbar } from '../components/custom-zoom-toolbar'; +import { ThumbnailsSidebar } from '../components/thumbnails-sidebar'; +import { SearchSidebar } from '../components/search-sidebar'; +import { OutlineSidebar } from '../components/outline-sidebar'; +import { + dutchTranslations, + englishTranslations, + germanTranslations, + paramResolvers, + spanishTranslations, +} from '../config'; +import { SchemaSelectionMenu } from '../ui/schema-selection-menu'; + +const logger = new ConsoleLogger(); + +/** + * Schema-Driven Viewer Page + * + * This viewer demonstrates the power of the UI plugin and schema-driven architecture. + * Instead of hardcoding the toolbar components, the UI is defined declaratively + * in the UI schema and rendered dynamically. + * + * Benefits: + * - Declarative UI configuration + * - Type-safe schema + * - Easily customizable and extensible + * - Consistent UI patterns + * - Separation of concerns + */ +export function ViewerSchemaPage() { + const containerRef = useRef(null); + const { engine, isLoading, error } = usePdfiumEngine({ + logger, + }); + + // Memoize UIProvider props to prevent unnecessary remounts + const uiComponents = useMemo( + () => ({ + 'zoom-toolbar': CustomZoomToolbar, + 'thumbnails-sidebar': ThumbnailsSidebar, + 'search-sidebar': SearchSidebar, + 'outline-sidebar': OutlineSidebar, + }), + [], + ); + + const uiRenderers: UIRenderers = useMemo( + () => ({ + toolbar: SchemaToolbar, + sidebar: SchemaPanel, + menu: SchemaMenu, + selectionMenu: SchemaSelectionMenu, + }), + [], + ); + + const plugins = useMemo( + () => [ + createPluginRegistration(DocumentManagerPluginPackage, { + initialDocuments: [{ url: 'https://snippet.embedpdf.com/ebook.pdf' }], + }), + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + // Commands plugin - provides command execution and state management + createPluginRegistration(CommandsPluginPackage, { + commands, + }), + createPluginRegistration(I18nPluginPackage, { + defaultLocale: 'en', + locales: [englishTranslations, germanTranslations, spanishTranslations, dutchTranslations], + paramResolvers, + }), + // UI plugin - provides schema-driven UI rendering + createPluginRegistration(UIPluginPackage, { + schema: viewerUISchema, + }), + ], + [], + ); + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !engine) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ + {({ pluginsReady, activeDocumentId, documentStates }) => ( + <> + {pluginsReady ? ( +
+ + + {/* Schema-driven UI with UIProvider */} + {activeDocumentId ? ( + + + + ) : ( + + )} +
+ ) : ( +
+ +
+ )} + + )} +
+
+
+ ); +} + +/** + * Viewer Layout + * + * Main layout component that uses useSchemaRenderer to render toolbars and panels. + * This component replaces the old SchemaToolbarRenderer and SchemaPanelRenderer. + */ +function ViewerLayout({ documentId }: { documentId: string }) { + const { renderToolbar, renderSidebar } = useSchemaRenderer(documentId); + + const annotationMenu = useSelectionMenu('annotation', documentId); + const redactionMenu = useSelectionMenu('redaction', documentId); + const selectionMenu = useSelectionMenu('selection', documentId); + + return ( + <> + {/* Main Toolbar */} + {renderToolbar('top', 'main')} + + {/* Secondary Toolbar (annotation/redaction/shapes) */} + {renderToolbar('top', 'secondary')} + + {/* Document Content Area */} +
+ {/* Left Panels */} + {renderSidebar('left', 'main')} + + {/* Main Viewer */} +
+ + {({ documentState, isLoading, isError, isLoaded }) => ( + <> + {isLoading && ( +
+ +
+ )} + {isError && } + {isLoaded && ( +
+ + + + ( + + + + + + + + + + + + + )} + /> + + {/* Page Controls */} + + + +
+ )} + + )} +
+
+ + {/* Right Panels */} + {renderSidebar('right', 'main')} +
+ + ); +} diff --git a/examples/react-tailwind/src/pages/viewer-simple.tsx b/examples/react-tailwind/src/pages/viewer-simple.tsx new file mode 100644 index 000000000..3ea2d0a85 --- /dev/null +++ b/examples/react-tailwind/src/pages/viewer-simple.tsx @@ -0,0 +1,310 @@ +import { useMemo, useRef, useState } from 'react'; +import { EmbedPDF, PluginBatchRegistrations } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { ViewportPluginPackage, Viewport } from '@embedpdf/plugin-viewport/react'; +import { ScrollPluginPackage, ScrollStrategy, Scroller } from '@embedpdf/plugin-scroll/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, +} from '@embedpdf/plugin-document-manager/react'; +import { + InteractionManagerPluginPackage, + GlobalPointerProvider, + PagePointerProvider, +} from '@embedpdf/plugin-interaction-manager/react'; +import { ZoomMode, ZoomPluginPackage, MarqueeZoom } from '@embedpdf/plugin-zoom/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { MarqueeCapture, CapturePluginPackage } from '@embedpdf/plugin-capture/react'; +import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; +import { AnnotationPluginPackage, AnnotationLayer } from '@embedpdf/plugin-annotation/react'; +import { CommandsPluginPackage } from '@embedpdf/plugin-commands/react'; +import { I18nPluginPackage } from '@embedpdf/plugin-i18n/react'; +import { TabBar } from '../components/tab-bar-2'; +import { ViewerToolbar, ViewMode } from '../components/viewer-toolbar'; +import { LoadingSpinner } from '../components/loading-spinner'; +import { DocumentPasswordPrompt } from '../components/document-password-prompt'; +import { SearchSidebar } from '../components/search-sidebar'; +import { ThumbnailsSidebar } from '../components/thumbnails-sidebar'; +import { PageControls } from '../components/page-controls'; +import { ConsoleLogger } from '@embedpdf/models'; +import { NavigationBar } from '../components/navigation-bar'; +import { EmptyState } from '../components/empty-state'; +import { commands } from '../config/commands'; +import { SidebarState } from '../config/types'; +import { SelectionSelectionMenu } from '../components/selection-selection-menu'; +import { RedactionSelectionMenu } from '../components/redaction-selection-menu'; +import { AnnotationSelectionMenu } from '../components/annotation-selection-menu'; + +const logger = new ConsoleLogger(); + +export function ViewerSimplePage() { + const containerRef = useRef(null); + const { engine, isLoading, error } = usePdfiumEngine({ + logger, + }); + + // Track sidebar state per document + const [sidebarStates, setSidebarStates] = useState>({}); + + // Track toolbar mode per document + const [toolbarModes, setToolbarModes] = useState>({}); + + const plugins: PluginBatchRegistrations = useMemo( + () => [ + createPluginRegistration(DocumentManagerPluginPackage, { + initialDocuments: [{ url: 'https://snippet.embedpdf.com/ebook.pdf' }], + }), + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + createPluginRegistration(CommandsPluginPackage, { + commands, + }), + createPluginRegistration(I18nPluginPackage), + ], + [], + ); + + const toggleSidebar = (documentId: string, sidebar: keyof SidebarState) => { + setSidebarStates((prev) => ({ + ...prev, + [documentId]: { + ...(prev[documentId] || { search: false, thumbnails: false }), + [sidebar]: !prev[documentId]?.[sidebar], + }, + })); + }; + + const getSidebarState = (documentId: string): SidebarState => { + return sidebarStates[documentId] || { search: false, thumbnails: false }; + }; + + const getToolbarMode = (documentId: string): ViewMode => { + return toolbarModes[documentId] || 'view'; + }; + + const setToolbarMode = (documentId: string, mode: ViewMode) => { + setToolbarModes((prev) => ({ + ...prev, + [documentId]: mode, + })); + }; + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !engine) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ + {({ pluginsReady, activeDocumentId, documentStates }) => ( + <> + {pluginsReady ? ( + <> +
+ + + {activeDocumentId && ( + <> + toggleSidebar(activeDocumentId, 'search')} + onToggleThumbnails={() => toggleSidebar(activeDocumentId, 'thumbnails')} + isSearchOpen={getSidebarState(activeDocumentId).search} + isThumbnailsOpen={getSidebarState(activeDocumentId).thumbnails} + mode={getToolbarMode(activeDocumentId)} + onModeChange={(mode) => setToolbarMode(activeDocumentId, mode)} + /> + + )} + + {!activeDocumentId && } + + {/* Document Content Area */} + {activeDocumentId && ( +
+ {/* Thumbnails Sidebar - Left */} + {getSidebarState(activeDocumentId).thumbnails && ( + toggleSidebar(activeDocumentId, 'thumbnails')} + /> + )} + + {/* Main Viewer */} +
+ + {({ documentState, isLoading, isError, isLoaded }) => ( + <> + {isLoading && ( +
+ +
+ )} + {isError && ( + + )} + {isLoaded && ( +
+ + + ( + + + + + + + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + )} + /> + {/* Page Controls */} + + + +
+ )} + + )} +
+
+ + {/* Search Sidebar - Right */} + {getSidebarState(activeDocumentId).search && ( + toggleSidebar(activeDocumentId, 'search')} + /> + )} +
+ )} +
+ + ) : ( +
+ +
+ )} + + )} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/pages/viewer.tsx b/examples/react-tailwind/src/pages/viewer.tsx new file mode 100644 index 000000000..11d6048bc --- /dev/null +++ b/examples/react-tailwind/src/pages/viewer.tsx @@ -0,0 +1,376 @@ +import { useMemo, useRef, useState } from 'react'; +import { EmbedPDF } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { ViewportPluginPackage, Viewport } from '@embedpdf/plugin-viewport/react'; +import { ScrollPluginPackage, ScrollStrategy, Scroller } from '@embedpdf/plugin-scroll/react'; +import { + DocumentManagerPluginPackage, + DocumentContent, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager/react'; +import { + InteractionManagerPluginPackage, + GlobalPointerProvider, + PagePointerProvider, +} from '@embedpdf/plugin-interaction-manager/react'; +import { ZoomMode, ZoomPluginPackage, MarqueeZoom } from '@embedpdf/plugin-zoom/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadMode, SpreadPluginPackage } from '@embedpdf/plugin-spread/react'; +import { Rotate, RotatePluginPackage } from '@embedpdf/plugin-rotate/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { ViewManagerPlugin, ViewManagerPluginPackage } from '@embedpdf/plugin-view-manager/react'; +import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react'; +import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { PrintPluginPackage } from '@embedpdf/plugin-print/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { CapturePluginPackage, MarqueeCapture } from '@embedpdf/plugin-capture/react'; +import { FullscreenPluginPackage } from '@embedpdf/plugin-fullscreen/react'; +import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; +import { AnnotationPluginPackage, AnnotationLayer } from '@embedpdf/plugin-annotation/react'; +import { TabBar } from '../components/tab-bar'; +import { ViewerToolbar, ViewMode } from '../components/viewer-toolbar'; +import { LoadingSpinner } from '../components/loading-spinner'; +import { DocumentPasswordPrompt } from '../components/document-password-prompt'; +import { SearchSidebar } from '../components/search-sidebar'; +import { ThumbnailsSidebar } from '../components/thumbnails-sidebar'; +import { PageControls } from '../components/page-controls'; +import { ConsoleLogger } from '@embedpdf/models'; +import { SplitViewLayout } from '../components/split-view-layout'; +import { AnnotationSelectionMenu } from '../components/annotation-selection-menu'; +import { SelectionSelectionMenu } from '../components/selection-selection-menu'; +import { NavigationBar } from '../components/navigation-bar'; +import { EmptyState } from '../components/empty-state'; +import { I18nPluginPackage } from '@embedpdf/plugin-i18n/react'; +import { RedactionSelectionMenu } from '../components/redaction-selection-menu'; + +const logger = new ConsoleLogger(); + +// Type for tracking sidebar state per document +type SidebarState = { + search: boolean; + thumbnails: boolean; +}; + +export function ViewerPage() { + const containerRef = useRef(null); + const { engine, isLoading, error } = usePdfiumEngine({ + logger, + }); + + // Track sidebar state per document + const [sidebarStates, setSidebarStates] = useState>({}); + + // Track toolbar mode per document + const [toolbarModes, setToolbarModes] = useState>({}); + + const plugins = useMemo( + () => [ + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + defaultStrategy: ScrollStrategy.Vertical, + }), + createPluginRegistration(DocumentManagerPluginPackage), + createPluginRegistration(InteractionManagerPluginPackage), + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + }), + createPluginRegistration(PanPluginPackage), + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, + }), + createPluginRegistration(RotatePluginPackage), + createPluginRegistration(ExportPluginPackage), + createPluginRegistration(PrintPluginPackage), + createPluginRegistration(RenderPluginPackage), + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 2.5, + extraRings: 0, + }), + createPluginRegistration(SelectionPluginPackage), + createPluginRegistration(SearchPluginPackage), + createPluginRegistration(RedactionPluginPackage), + createPluginRegistration(CapturePluginPackage), + createPluginRegistration(HistoryPluginPackage), + createPluginRegistration(AnnotationPluginPackage), + createPluginRegistration(FullscreenPluginPackage, { + targetElement: '#document-content', + }), + createPluginRegistration(ThumbnailPluginPackage, { + width: 120, + paddingY: 10, + }), + createPluginRegistration(ViewManagerPluginPackage, { + defaultViewCount: 1, + }), + createPluginRegistration(I18nPluginPackage), + ], + [], // Empty dependency array since these never change + ); + + const toggleSidebar = (documentId: string, sidebar: keyof SidebarState) => { + setSidebarStates((prev) => ({ + ...prev, + [documentId]: { + ...(prev[documentId] || { search: false, thumbnails: false }), + [sidebar]: !prev[documentId]?.[sidebar], + }, + })); + }; + + const getSidebarState = (documentId: string): SidebarState => { + return sidebarStates[documentId] || { search: false, thumbnails: false }; + }; + + const getToolbarMode = (documentId: string): ViewMode => { + return toolbarModes[documentId] || 'view'; + }; + + const setToolbarMode = (documentId: string, mode: ViewMode) => { + setToolbarModes((prev) => ({ + ...prev, + [documentId]: mode, + })); + }; + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !engine) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ { + // Load default PDF URL on initialization + const document = await registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openDocumentUrl({ url: 'https://snippet.embedpdf.com/ebook.pdf' }) + .toPromise(); + + if (!document) return; + + const viewManager = registry + ?.getPlugin(ViewManagerPlugin.id) + ?.provides(); + if (!viewManager) return; + + const views = viewManager.getAllViews(); + if (views.length > 0 && views[0]) { + const firstViewId = views[0].id; + viewManager.addDocumentToView(firstViewId, document.documentId); + viewManager.setViewActiveDocument(firstViewId, document.documentId); + } + }} + > + {({ pluginsReady, registry }) => ( + <> + {pluginsReady ? ( + ( +
+ setActiveDocument(documentId)} + onClose={(docId) => + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.closeDocument(docId) + } + onOpenFile={() => { + const openTask = registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openFileDialog(); + openTask?.wait( + (result) => { + addDocument(result.documentId); + setActiveDocument(result.documentId); + }, + (error) => { + console.error('Open file failed:', error); + }, + ); + }} + /> + + {documentId && ( + toggleSidebar(documentId, 'search')} + onToggleThumbnails={() => toggleSidebar(documentId, 'thumbnails')} + isSearchOpen={getSidebarState(documentId).search} + isThumbnailsOpen={getSidebarState(documentId).thumbnails} + mode={getToolbarMode(documentId)} + onModeChange={(mode) => setToolbarMode(documentId, mode)} + /> + )} + + {/* Empty State - No Documents */} + {!documentId && ( + { + addDocument(documentId); + setActiveDocument(documentId); + }} + /> + )} + + {/* Document Content Area */} + {documentId && ( +
+ {/* Thumbnails Sidebar - Left */} + {getSidebarState(documentId).thumbnails && ( + toggleSidebar(documentId, 'thumbnails')} + /> + )} + + {/* Main Viewer */} +
+ + {({ documentState, isLoading, isError, isLoaded }) => ( + <> + {isLoading && ( +
+ +
+ )} + {isError && ( + + )} + {isLoaded && ( +
+ + + ( + + + + + + + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + )} + /> + {/* Page Controls */} + + + +
+ )} + + )} +
+
+ + {/* Search Sidebar - Right */} + {getSidebarState(documentId).search && ( + toggleSidebar(documentId, 'search')} + /> + )} +
+ )} +
+ )} + /> + ) : ( +
+ +
+ )} + + )} +
+
+
+ ); +} diff --git a/examples/react-tailwind/src/router.tsx b/examples/react-tailwind/src/router.tsx new file mode 100644 index 000000000..ee0b5ad4a --- /dev/null +++ b/examples/react-tailwind/src/router.tsx @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +export function useHashRoute() { + const [route, setRoute] = useState(() => { + return window.location.hash.slice(1) || '/'; + }); + + useEffect(() => { + const handleHashChange = () => { + setRoute(window.location.hash.slice(1) || '/'); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + const navigate = (path: string) => { + window.location.hash = path; + }; + + return { route, navigate }; +} diff --git a/examples/react-tailwind/src/ui/index.ts b/examples/react-tailwind/src/ui/index.ts new file mode 100644 index 000000000..e471d6f40 --- /dev/null +++ b/examples/react-tailwind/src/ui/index.ts @@ -0,0 +1,11 @@ +/** + * 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/examples/react-tailwind/src/ui/schema-menu.tsx b/examples/react-tailwind/src/ui/schema-menu.tsx new file mode 100644 index 000000000..39adc61d5 --- /dev/null +++ b/examples/react-tailwind/src/ui/schema-menu.tsx @@ -0,0 +1,457 @@ +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 { twMerge } from 'tailwind-merge'; +import { useTranslations } from '@embedpdf/plugin-i18n/react'; + +/** + * 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-gray-100' + : 'flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-100'; + + const disabledClasses = command.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'; + const activeClasses = command.active ? 'bg-blue-50 text-blue-600' : 'text-gray-700'; + + const handleClick = () => { + if (!command.disabled) { + command.execute(); + onClose(); + } + }; + + const iconProps = command.iconProps || {}; + + return ( + + ); +} + +// ───────────────────────────────────────────────────────── +// Submenu Item +// ───────────────────────────────────────────────────────── + +function SubmenuItem({ + item, + documentId, + isMobile, + onNavigateToSubmenu, +}: { + item: Extract; + documentId: string; + isMobile: boolean; + onNavigateToSubmenu?: (submenuId: string, title: string) => void; +}) { + const { translate } = useTranslations(documentId); + 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-gray-100' + : 'flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-100'; + + const handleClick = () => { + if (onNavigateToSubmenu) { + onNavigateToSubmenu( + item.menuId, + translate(item.labelKey || item.id, { fallback: 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; +}) { + const { translate } = useTranslations(documentId); + + return ( +
+ {(item.labelKey || item.label) && ( +
+ {translate(item.labelKey || item.id, { fallback: item.label || item.id })} +
+ )} + {item.items.map((subItem, index) => ( + + ))} +
+ ); +} diff --git a/examples/react-tailwind/src/ui/schema-panel.tsx b/examples/react-tailwind/src/ui/schema-panel.tsx new file mode 100644 index 000000000..fbd44d350 --- /dev/null +++ b/examples/react-tailwind/src/ui/schema-panel.tsx @@ -0,0 +1,422 @@ +import { + SidebarRendererProps, + useUICapability, + useUIState, + useItemRenderer, +} from '@embedpdf/plugin-ui/react'; +import { useEffect, useMemo, useState, useRef } from 'react'; +import * as Icons from '../components/icons'; +import { useTranslations } from '@embedpdf/plugin-i18n/react'; + +/** + * 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; + }); + + const { translate } = useTranslations(documentId); + + 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); + }; + } + }, [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-gray-300 bg-white'; + case 'right': + return 'h-full border-l border-gray-300 bg-white'; + case 'top': + return 'w-full border-b border-gray-300 bg-white'; + case 'bottom': + return 'w-full border-t border-gray-300 bg-white'; + default: + return 'h-full bg-white'; + } +} diff --git a/examples/react-tailwind/src/ui/schema-selection-menu.tsx b/examples/react-tailwind/src/ui/schema-selection-menu.tsx new file mode 100644 index 000000000..5ec3fe55f --- /dev/null +++ b/examples/react-tailwind/src/ui/schema-selection-menu.tsx @@ -0,0 +1,90 @@ +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/examples/react-tailwind/src/ui/schema-toolbar.tsx b/examples/react-tailwind/src/ui/schema-toolbar.tsx new file mode 100644 index 000000000..a8876d373 --- /dev/null +++ b/examples/react-tailwind/src/ui/schema-toolbar.tsx @@ -0,0 +1,245 @@ +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 { twMerge } from 'tailwind-merge'; + +/** + * 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 ? 'bg-[#f1f3f5]' : ''; + + 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 ( +