From ab5054f7534db7e0c7604756faca342692b78d66 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Mon, 13 Oct 2025 20:40:08 +0300 Subject: [PATCH 001/225] Add document manager plugin and multi-document support to viewport and core Introduces the new plugin-document-manager package, enabling multi-document management with tab ordering, open/close/retry actions, and event hooks. Refactors core store to support multiple documents, updates actions, reducer, selectors, and plugin base to handle document lifecycle events. Adds support for custom fetch headers in Pdfium engine and updates related types and interfaces. --- packages/core/src/lib/base/base-plugin.ts | 89 ++- packages/core/src/lib/store/actions.ts | 254 +++++-- packages/core/src/lib/store/initial-state.ts | 48 +- packages/core/src/lib/store/reducer.ts | 195 +++++- packages/core/src/lib/store/selectors.ts | 60 +- packages/core/src/lib/types/plugin.ts | 4 +- packages/engines/src/lib/pdfium/engine.ts | 11 +- packages/models/src/pdf.ts | 4 + packages/plugin-document-manager/package.json | 73 ++ packages/plugin-document-manager/src/index.ts | 1 + .../src/lib/actions.ts | 40 ++ .../src/lib/document-manager-plugin.ts | 660 ++++++++++++++++++ .../plugin-document-manager/src/lib/index.ts | 24 + .../src/lib/manifest.ts | 17 + .../src/lib/reducer.ts | 58 ++ .../plugin-document-manager/src/lib/types.ts | 89 +++ .../src/preact/adapter.ts | 10 + .../src/preact/core.ts | 1 + .../src/preact/index.ts | 1 + .../src/preact/tsconfig.preact.json | 14 + .../src/react/adapter.ts | 2 + .../plugin-document-manager/src/react/core.ts | 1 + .../src/react/index.ts | 1 + .../src/react/tsconfig.react.json | 14 + .../shared/components/document-content.tsx | 40 ++ .../src/shared/components/document-tabs.tsx | 79 +++ .../src/shared/components/file-picker.tsx | 36 + .../src/shared/components/index.ts | 3 + .../src/shared/hooks/index.ts | 1 + .../src/shared/hooks/use-document-manager.ts | 105 +++ .../src/shared/index.ts | 12 + .../src/vue/components/document-content.vue | 25 + .../src/vue/components/document-tabs.vue | 23 + .../src/vue/components/file-picker.vue | 36 + .../src/vue/components/index.ts | 3 + .../src/vue/hooks/index.ts | 1 + .../src/vue/hooks/use-document-manager.ts | 110 +++ .../plugin-document-manager/src/vue/index.ts | 12 + .../src/vue/tsconfig.vue.json | 12 + .../plugin-document-manager/tsconfig.json | 22 + .../plugin-document-manager/vite.config.ts | 2 + packages/plugin-viewport/src/lib/actions.ts | 128 +++- packages/plugin-viewport/src/lib/reducer.ts | 218 ++++-- packages/plugin-viewport/src/lib/types.ts | 69 +- .../src/lib/viewport-plugin.ts | 368 +++++++--- .../src/shared/components/viewport.tsx | 9 +- .../src/shared/hooks/use-viewport-ref.ts | 41 +- .../src/shared/hooks/use-viewport.ts | 16 +- .../src/vue/components/viewport.vue | 13 +- .../src/vue/hooks/use-viewport-ref.ts | 55 +- .../src/vue/hooks/use-viewport.ts | 13 +- pnpm-lock.yaml | 31 + 52 files changed, 2854 insertions(+), 300 deletions(-) create mode 100644 packages/plugin-document-manager/package.json create mode 100644 packages/plugin-document-manager/src/index.ts create mode 100644 packages/plugin-document-manager/src/lib/actions.ts create mode 100644 packages/plugin-document-manager/src/lib/document-manager-plugin.ts create mode 100644 packages/plugin-document-manager/src/lib/index.ts create mode 100644 packages/plugin-document-manager/src/lib/manifest.ts create mode 100644 packages/plugin-document-manager/src/lib/reducer.ts create mode 100644 packages/plugin-document-manager/src/lib/types.ts create mode 100644 packages/plugin-document-manager/src/preact/adapter.ts create mode 100644 packages/plugin-document-manager/src/preact/core.ts create mode 100644 packages/plugin-document-manager/src/preact/index.ts create mode 100644 packages/plugin-document-manager/src/preact/tsconfig.preact.json create mode 100644 packages/plugin-document-manager/src/react/adapter.ts create mode 100644 packages/plugin-document-manager/src/react/core.ts create mode 100644 packages/plugin-document-manager/src/react/index.ts create mode 100644 packages/plugin-document-manager/src/react/tsconfig.react.json create mode 100644 packages/plugin-document-manager/src/shared/components/document-content.tsx create mode 100644 packages/plugin-document-manager/src/shared/components/document-tabs.tsx create mode 100644 packages/plugin-document-manager/src/shared/components/file-picker.tsx create mode 100644 packages/plugin-document-manager/src/shared/components/index.ts create mode 100644 packages/plugin-document-manager/src/shared/hooks/index.ts create mode 100644 packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts create mode 100644 packages/plugin-document-manager/src/shared/index.ts create mode 100644 packages/plugin-document-manager/src/vue/components/document-content.vue create mode 100644 packages/plugin-document-manager/src/vue/components/document-tabs.vue create mode 100644 packages/plugin-document-manager/src/vue/components/file-picker.vue create mode 100644 packages/plugin-document-manager/src/vue/components/index.ts create mode 100644 packages/plugin-document-manager/src/vue/hooks/index.ts create mode 100644 packages/plugin-document-manager/src/vue/hooks/use-document-manager.ts create mode 100644 packages/plugin-document-manager/src/vue/index.ts create mode 100644 packages/plugin-document-manager/src/vue/tsconfig.vue.json create mode 100644 packages/plugin-document-manager/tsconfig.json create mode 100644 packages/plugin-document-manager/vite.config.ts diff --git a/packages/core/src/lib/base/base-plugin.ts b/packages/core/src/lib/base/base-plugin.ts index 86d76b65a..dc87504ba 100644 --- a/packages/core/src/lib/base/base-plugin.ts +++ b/packages/core/src/lib/base/base-plugin.ts @@ -1,6 +1,17 @@ import { IPlugin } from '../types/plugin'; import { PluginRegistry } from '../registry/plugin-registry'; -import { Action, CoreAction, CoreState, PluginStore, Store, StoreState } from '../store'; +import { + Action, + CLOSE_DOCUMENT, + CoreAction, + CoreState, + PluginStore, + SET_ACTIVE_DOCUMENT, + SET_DOCUMENT_LOADED, + START_LOADING_DOCUMENT, + Store, + StoreState, +} from '../store'; import { Logger, PdfEngine } from '@embedpdf/models'; export interface StateChangeHandler { @@ -26,6 +37,10 @@ export abstract class BasePlugin< private debouncedTimeouts: Record = {}; private unsubscribeFromState: (() => void) | null = null; private unsubscribeFromCoreStore: (() => void) | null = null; + private unsubscribeFromStartLoadingDocument: (() => void) | null = null; + private unsubscribeFromSetDocumentLoaded: (() => void) | null = null; + private unsubscribeFromCloseDocument: (() => void) | null = null; + private unsubscribeFromSetActiveDocument: (() => void) | null = null; private _capability?: Readonly; @@ -51,6 +66,27 @@ export abstract class BasePlugin< this.unsubscribeFromCoreStore = this.coreStore.subscribe((action, newState, oldState) => { this.onCoreStoreUpdated(oldState, newState); }); + this.unsubscribeFromStartLoadingDocument = this.coreStore.onAction( + START_LOADING_DOCUMENT, + (action) => { + this.onDocumentLoadingStarted(action.payload.documentId); + }, + ); + this.unsubscribeFromSetDocumentLoaded = this.coreStore.onAction( + SET_DOCUMENT_LOADED, + (action) => { + this.onDocumentLoaded(action.payload.documentId); + }, + ); + this.unsubscribeFromCloseDocument = this.coreStore.onAction(CLOSE_DOCUMENT, (action) => { + this.onDocumentClosed(action.payload.documentId); + }); + this.unsubscribeFromSetActiveDocument = this.coreStore.onAction( + SET_ACTIVE_DOCUMENT, + (action, _state, oldState) => { + this.onActiveDocumentChanged(oldState.core.activeDocumentId, action.payload); + }, + ); // Initialize ready state this.readyPromise = new Promise((resolve) => { @@ -215,6 +251,41 @@ export abstract class BasePlugin< // Default implementation does nothing - can be overridden by plugins } + /** + * Called when a document is opened + * Override to initialize per-document state + * @param documentId The ID of the document that was opened + */ + protected onDocumentLoadingStarted(documentId: string): void { + // Default: no-op + } + + /** + * Called when a document is loaded + * @param documentId The ID of the document that is loaded + */ + protected onDocumentLoaded(documentId: string): void { + // Default: no-op + } + + /** + * Called when a document is closed + * Override to cleanup per-document state + * @param documentId The ID of the document that was closed + */ + protected onDocumentClosed(documentId: string): void { + // Default: no-op + } + + /** + * Called when the active document changes + * @param previousId The ID of the previous active document + * @param currentId The ID of the new active document + */ + protected onActiveDocumentChanged(previousId: string | null, currentId: string | null): void { + // Default: no-op + } + /** * Cleanup method to be called when plugin is being destroyed */ @@ -233,6 +304,22 @@ export abstract class BasePlugin< this.unsubscribeFromCoreStore(); this.unsubscribeFromCoreStore = null; } + if (this.unsubscribeFromStartLoadingDocument) { + this.unsubscribeFromStartLoadingDocument(); + this.unsubscribeFromStartLoadingDocument = null; + } + if (this.unsubscribeFromSetDocumentLoaded) { + this.unsubscribeFromSetDocumentLoaded(); + this.unsubscribeFromSetDocumentLoaded = null; + } + if (this.unsubscribeFromCloseDocument) { + this.unsubscribeFromCloseDocument(); + this.unsubscribeFromCloseDocument = null; + } + if (this.unsubscribeFromSetActiveDocument) { + this.unsubscribeFromSetActiveDocument(); + this.unsubscribeFromSetActiveDocument = null; + } } /** diff --git a/packages/core/src/lib/store/actions.ts b/packages/core/src/lib/store/actions.ts index 6c7774e79..36240b9a6 100644 --- a/packages/core/src/lib/store/actions.ts +++ b/packages/core/src/lib/store/actions.ts @@ -1,100 +1,258 @@ -import { PdfDocumentObject, PdfPageObject, Rotation } from '@embedpdf/models'; +import { PdfDocumentObject, PdfErrorCode, PdfPageObject, Rotation } from '@embedpdf/models'; -export const LOAD_DOCUMENT = 'LOAD_DOCUMENT'; +// Document lifecycle actions +export const START_LOADING_DOCUMENT = 'START_LOADING_DOCUMENT'; +export const UPDATE_DOCUMENT_LOADING_PROGRESS = 'UPDATE_DOCUMENT_LOADING_PROGRESS'; +export const SET_DOCUMENT_LOADED = 'SET_DOCUMENT_LOADED'; +export const SET_DOCUMENT_ERROR = 'SET_DOCUMENT_ERROR'; +export const RETRY_LOADING_DOCUMENT = 'RETRY_LOADING_DOCUMENT'; +export const CLOSE_DOCUMENT = 'CLOSE_DOCUMENT'; +export const SET_ACTIVE_DOCUMENT = 'SET_ACTIVE_DOCUMENT'; + +// Document-specific actions export const REFRESH_DOCUMENT = 'REFRESH_DOCUMENT'; export const REFRESH_PAGES = 'REFRESH_PAGES'; -export const SET_DOCUMENT = 'SET_DOCUMENT'; -export const SET_DOCUMENT_ERROR = 'SET_DOCUMENT_ERROR'; +export const SET_PAGES = 'SET_PAGES'; export const SET_SCALE = 'SET_SCALE'; export const SET_ROTATION = 'SET_ROTATION'; -export const SET_PAGES = 'SET_PAGES'; + +// Global default actions +export const SET_DEFAULT_SCALE = 'SET_DEFAULT_SCALE'; +export const SET_DEFAULT_ROTATION = 'SET_DEFAULT_ROTATION'; export const CORE_ACTION_TYPES = [ - LOAD_DOCUMENT, - REFRESH_DOCUMENT, - SET_DOCUMENT, + START_LOADING_DOCUMENT, + UPDATE_DOCUMENT_LOADING_PROGRESS, + SET_DOCUMENT_LOADED, + CLOSE_DOCUMENT, + SET_ACTIVE_DOCUMENT, SET_DOCUMENT_ERROR, + RETRY_LOADING_DOCUMENT, + REFRESH_DOCUMENT, + REFRESH_PAGES, + SET_PAGES, SET_SCALE, SET_ROTATION, - SET_PAGES, + SET_DEFAULT_SCALE, + SET_DEFAULT_ROTATION, ] as const; -// Action Type Interfaces -export interface LoadDocumentAction { - type: typeof LOAD_DOCUMENT; +// ───────────────────────────────────────────────────────── +// Document Lifecycle Actions +// ───────────────────────────────────────────────────────── + +export interface StartLoadingDocumentAction { + type: typeof START_LOADING_DOCUMENT; + payload: { + documentId: string; + scale?: number; + rotation?: Rotation; + }; } +export interface UpdateDocumentLoadingProgressAction { + type: typeof UPDATE_DOCUMENT_LOADING_PROGRESS; + payload: { + documentId: string; + progress: number; + }; +} + +export interface SetDocumentLoadedAction { + type: typeof SET_DOCUMENT_LOADED; + payload: { + documentId: string; + document: PdfDocumentObject; + }; +} + +export interface SetDocumentErrorAction { + type: typeof SET_DOCUMENT_ERROR; + payload: { + documentId: string; + error: string; + errorCode?: PdfErrorCode; + errorDetails?: any; + }; +} + +export interface RetryLoadingDocumentAction { + type: typeof RETRY_LOADING_DOCUMENT; + payload: { + documentId: string; + }; +} + +export interface CloseDocumentAction { + type: typeof CLOSE_DOCUMENT; + payload: { + documentId: string; + }; +} + +export interface SetActiveDocumentAction { + type: typeof SET_ACTIVE_DOCUMENT; + payload: string | null; // documentId or null +} + +// ───────────────────────────────────────────────────────── +// Document-Specific Actions +// ───────────────────────────────────────────────────────── + export interface RefreshDocumentAction { type: typeof REFRESH_DOCUMENT; - payload: PdfDocumentObject; + payload: { + documentId: string; + document: PdfDocumentObject; + }; } export interface RefreshPagesAction { type: typeof REFRESH_PAGES; - payload: number[]; + payload: { + documentId: string; + pageNumbers: number[]; + }; } -export interface SetDocumentAction { - type: typeof SET_DOCUMENT; - payload: PdfDocumentObject; -} - -export interface SetDocumentErrorAction { - type: typeof SET_DOCUMENT_ERROR; - payload: string; +export interface SetPagesAction { + type: typeof SET_PAGES; + payload: { + documentId: string; + pages: PdfPageObject[][]; + }; } export interface SetScaleAction { type: typeof SET_SCALE; - payload: number; + payload: { + documentId?: string; // If not provided, applies to active document + scale: number; + }; } export interface SetRotationAction { type: typeof SET_ROTATION; - payload: Rotation; + payload: { + documentId?: string; // If not provided, applies to active document + rotation: Rotation; + }; } -export interface SetPagesAction { - type: typeof SET_PAGES; - payload: PdfPageObject[][]; +// ───────────────────────────────────────────────────────── +// Global Default Actions +// ───────────────────────────────────────────────────────── + +export interface SetDefaultScaleAction { + type: typeof SET_DEFAULT_SCALE; + payload: number; +} + +export interface SetDefaultRotationAction { + type: typeof SET_DEFAULT_ROTATION; + payload: Rotation; } export type DocumentAction = - | LoadDocumentAction + | StartLoadingDocumentAction + | UpdateDocumentLoadingProgressAction + | SetDocumentLoadedAction + | SetDocumentErrorAction + | RetryLoadingDocumentAction + | CloseDocumentAction + | SetActiveDocumentAction | RefreshDocumentAction | RefreshPagesAction - | SetDocumentAction - | SetDocumentErrorAction + | SetPagesAction | SetScaleAction | SetRotationAction - | SetPagesAction; + | SetDefaultScaleAction + | SetDefaultRotationAction; // Core actions export type CoreAction = DocumentAction; -export const loadDocument = (): CoreAction => ({ type: LOAD_DOCUMENT }); -export const refreshDocument = (document: PdfDocumentObject): CoreAction => ({ +// ───────────────────────────────────────────────────────── +// Action Creators +// ───────────────────────────────────────────────────────── +export const startLoadingDocument = ( + documentId: string, + scale?: number, + rotation?: Rotation, +): CoreAction => ({ + type: START_LOADING_DOCUMENT, + payload: { documentId, scale, rotation }, +}); + +export const updateDocumentLoadingProgress = ( + documentId: string, + progress: number, +): CoreAction => ({ + type: UPDATE_DOCUMENT_LOADING_PROGRESS, + payload: { documentId, progress }, +}); + +export const setDocumentLoaded = (documentId: string, document: PdfDocumentObject): CoreAction => ({ + type: SET_DOCUMENT_LOADED, + payload: { documentId, document }, +}); + +export const setDocumentError = ( + documentId: string, + error: string, + errorCode?: PdfErrorCode, + errorDetails?: any, +): CoreAction => ({ + type: SET_DOCUMENT_ERROR, + payload: { documentId, error, errorCode, errorDetails }, +}); + +export const retryLoadingDocument = (documentId: string): CoreAction => ({ + type: RETRY_LOADING_DOCUMENT, + payload: { documentId }, +}); + +export const closeDocument = (documentId: string): CoreAction => ({ + type: CLOSE_DOCUMENT, + payload: { documentId }, +}); + +export const setActiveDocument = (documentId: string | null): CoreAction => ({ + type: SET_ACTIVE_DOCUMENT, + payload: documentId, +}); + +export const refreshDocument = (documentId: string, document: PdfDocumentObject): CoreAction => ({ type: REFRESH_DOCUMENT, - payload: document, + payload: { documentId, document }, }); -export const refreshPages = (pages: number[]): CoreAction => ({ + +export const refreshPages = (documentId: string, pageNumbers: number[]): CoreAction => ({ type: REFRESH_PAGES, - payload: pages, + payload: { documentId, pageNumbers }, }); -export const setDocument = (document: PdfDocumentObject): CoreAction => ({ - type: SET_DOCUMENT, - payload: document, + +export const setPages = (documentId: string, pages: PdfPageObject[][]): CoreAction => ({ + type: SET_PAGES, + payload: { documentId, pages }, }); -export const setDocumentError = (error: string): CoreAction => ({ - type: SET_DOCUMENT_ERROR, - payload: error, + +export const setScale = (scale: number, documentId?: string): CoreAction => ({ + type: SET_SCALE, + payload: { scale, documentId }, }); -export const setScale = (scale: number): CoreAction => ({ type: SET_SCALE, payload: scale }); -export const setRotation = (rotation: Rotation): CoreAction => ({ + +export const setRotation = (rotation: Rotation, documentId?: string): CoreAction => ({ type: SET_ROTATION, - payload: rotation, + payload: { rotation, documentId }, }); -export const setPages = (pages: PdfPageObject[][]): CoreAction => ({ - type: SET_PAGES, - payload: pages, + +export const setDefaultScale = (scale: number): CoreAction => ({ + type: SET_DEFAULT_SCALE, + payload: scale, +}); + +export const setDefaultRotation = (rotation: Rotation): CoreAction => ({ + type: SET_DEFAULT_ROTATION, + payload: rotation, }); diff --git a/packages/core/src/lib/store/initial-state.ts b/packages/core/src/lib/store/initial-state.ts index 59437e4b7..3378e2ec6 100644 --- a/packages/core/src/lib/store/initial-state.ts +++ b/packages/core/src/lib/store/initial-state.ts @@ -1,20 +1,44 @@ -import { PdfDocumentObject, PdfPageObject, Rotation } from '@embedpdf/models'; +import { PdfDocumentObject, PdfErrorCode, PdfPageObject, Rotation } from '@embedpdf/models'; import { PluginRegistryConfig } from '../types/plugin'; -export interface CoreState { - scale: number; - rotation: Rotation; +export type DocumentStatus = 'loading' | 'loaded' | 'error'; + +export interface DocumentState { + id: string; + // Lifecycle status + status: DocumentStatus; + + // Loading progress (0-100) + loadingProgress?: number; + + // Error information (when status is 'error') + error: string | null; + errorCode?: PdfErrorCode; + errorDetails?: any; + + // Document data (null when loading or error) document: PdfDocumentObject | null; pages: PdfPageObject[][]; - loading: boolean; - error: string | null; + + // View settings (set even during loading for when it succeeds) + scale: number; + rotation: Rotation; + + // Metadata + loadStartedAt: number; + loadedAt?: number; +} + +export interface CoreState { + documents: Record; + activeDocumentId: string | null; + defaultScale: number; + defaultRotation: Rotation; } export const initialCoreState: (config?: PluginRegistryConfig) => CoreState = (config) => ({ - scale: config?.scale ?? 1, - rotation: config?.rotation ?? Rotation.Degree0, - document: null, - pages: [], - loading: false, - error: null, + documents: {}, + activeDocumentId: null, + defaultScale: config?.defaultScale ?? 1, + defaultRotation: config?.defaultRotation ?? Rotation.Degree0, }); diff --git a/packages/core/src/lib/store/reducer.ts b/packages/core/src/lib/store/reducer.ts index edc4fa9af..8802037b9 100644 --- a/packages/core/src/lib/store/reducer.ts +++ b/packages/core/src/lib/store/reducer.ts @@ -1,11 +1,14 @@ import { Reducer } from './types'; -import { CoreState } from './initial-state'; +import { CoreState, DocumentState } from './initial-state'; import { CoreAction, - LOAD_DOCUMENT, - REFRESH_DOCUMENT, - SET_DOCUMENT, + START_LOADING_DOCUMENT, + UPDATE_DOCUMENT_LOADING_PROGRESS, + SET_DOCUMENT_LOADED, SET_DOCUMENT_ERROR, + RETRY_LOADING_DOCUMENT, + CLOSE_DOCUMENT, + SET_ACTIVE_DOCUMENT, SET_PAGES, SET_ROTATION, SET_SCALE, @@ -13,55 +16,195 @@ import { export const coreReducer: Reducer = (state, action): CoreState => { switch (action.type) { - case LOAD_DOCUMENT: + case START_LOADING_DOCUMENT: { + const { documentId, scale, rotation } = action.payload; + + const newDocState: DocumentState = { + id: documentId, + status: 'loading', + loadingProgress: 0, + error: null, + document: null, + pages: [], + scale: scale ?? state.defaultScale, + rotation: rotation ?? state.defaultRotation, + loadStartedAt: Date.now(), + }; + return { ...state, - loading: true, - error: null, + documents: { + ...state.documents, + [documentId]: newDocState, + }, + // Set as active if no active document + activeDocumentId: state.activeDocumentId ?? documentId, }; + } + + case UPDATE_DOCUMENT_LOADING_PROGRESS: { + const { documentId, progress } = action.payload; + const docState = state.documents[documentId]; + + if (!docState || docState.status !== 'loading') return state; - case SET_DOCUMENT: return { ...state, - document: action.payload, - pages: action.payload.pages.map((page) => [page]), - loading: false, - error: null, + documents: { + ...state.documents, + [documentId]: { + ...docState, + loadingProgress: progress, + }, + }, + }; + } + + case SET_DOCUMENT_LOADED: { + const { documentId, document } = action.payload; + const docState = state.documents[documentId]; + + if (!docState) return state; + + return { + ...state, + documents: { + ...state.documents, + [documentId]: { + ...docState, + status: 'loaded', + document, + pages: document.pages.map((page) => [page]), + error: null, + errorCode: undefined, + errorDetails: undefined, + loadedAt: Date.now(), + }, + }, }; + } + + case SET_DOCUMENT_ERROR: { + const { documentId, error, errorCode, errorDetails } = action.payload; + const docState = state.documents[documentId]; + + if (!docState) return state; - case REFRESH_DOCUMENT: return { ...state, - document: action.payload, - pages: action.payload.pages.map((page) => [page]), - loading: false, - error: null, + documents: { + ...state.documents, + [documentId]: { + ...docState, + status: 'error', + error, + errorCode, + errorDetails, + }, + }, + }; + } + + case RETRY_LOADING_DOCUMENT: { + const { documentId } = action.payload; + const docState = state.documents[documentId]; + + if (!docState || docState.status !== 'error') return state; + + return { + ...state, + documents: { + ...state.documents, + [documentId]: { + ...docState, + status: 'loading', + loadingProgress: 0, + error: null, + errorCode: undefined, + errorDetails: undefined, + loadStartedAt: Date.now(), + }, + }, + }; + } + + case CLOSE_DOCUMENT: { + const { documentId } = action.payload; + const { [documentId]: removed, ...remainingDocs } = state.documents; + + return { + ...state, + documents: remainingDocs, + activeDocumentId: state.activeDocumentId === documentId ? null : state.activeDocumentId, }; + } - case SET_ROTATION: + case SET_ACTIVE_DOCUMENT: { return { ...state, - rotation: action.payload, + activeDocumentId: action.payload, }; + } + + case SET_PAGES: { + const { documentId, pages } = action.payload; + const docState = state.documents[documentId]; + + if (!docState) return state; - case SET_PAGES: return { ...state, - pages: action.payload, + documents: { + ...state.documents, + [documentId]: { + ...docState, + pages, + }, + }, }; + } + + case SET_SCALE: { + const { scale, documentId } = action.payload; + const targetId = documentId ?? state.activeDocumentId; + + if (!targetId) return state; + + const docState = state.documents[targetId]; + if (!docState) return state; - case SET_DOCUMENT_ERROR: return { ...state, - loading: false, - error: action.payload, + documents: { + ...state.documents, + [targetId]: { + ...docState, + scale, + }, + }, }; + } + + case SET_ROTATION: { + const { rotation, documentId } = action.payload; + const targetId = documentId ?? state.activeDocumentId; + + if (!targetId) return state; + + const docState = state.documents[targetId]; + if (!docState) return state; - case SET_SCALE: return { ...state, - scale: action.payload, + documents: { + ...state.documents, + [targetId]: { + ...docState, + rotation, + }, + }, }; + } default: return state; diff --git a/packages/core/src/lib/store/selectors.ts b/packages/core/src/lib/store/selectors.ts index d62206fc6..85e0b9cee 100644 --- a/packages/core/src/lib/store/selectors.ts +++ b/packages/core/src/lib/store/selectors.ts @@ -1,11 +1,63 @@ -import { CoreState } from './initial-state'; +import { CoreState, DocumentState } from './initial-state'; import { transformSize, PdfPageObjectWithRotatedSize } from '@embedpdf/models'; -export const getPagesWithRotatedSize = (state: CoreState): PdfPageObjectWithRotatedSize[][] => { - return state.pages.map((page) => +/** + * Get pages with rotated size for a specific document + */ +export const getPagesWithRotatedSize = ( + documentState: DocumentState, +): PdfPageObjectWithRotatedSize[][] => { + return documentState.pages.map((page) => page.map((p) => ({ ...p, - rotatedSize: transformSize(p.size, state.rotation, 1), + rotatedSize: transformSize(p.size, documentState.rotation, 1), })), ); }; + +/** + * Get the active document state + */ +export const getActiveDocumentState = (state: CoreState): DocumentState | null => { + if (!state.activeDocumentId) return null; + return state.documents[state.activeDocumentId] ?? null; +}; + +/** + * Get pages with rotated size for the active document + */ +export const getActivePagesWithRotatedSize = ( + state: CoreState, +): PdfPageObjectWithRotatedSize[][] | null => { + const activeDoc = getActiveDocumentState(state); + if (!activeDoc) return null; + return getPagesWithRotatedSize(activeDoc); +}; + +/** + * Get document state by ID + */ +export const getDocumentState = (state: CoreState, documentId: string): DocumentState | null => { + return state.documents[documentId] ?? null; +}; + +/** + * Get all document IDs + */ +export const getDocumentIds = (state: CoreState): string[] => { + return Object.keys(state.documents); +}; + +/** + * Check if a document is loaded + */ +export const isDocumentLoaded = (state: CoreState, documentId: string): boolean => { + return !!state.documents[documentId]; +}; + +/** + * Get the number of open documents + */ +export const getDocumentCount = (state: CoreState): number => { + return Object.keys(state.documents).length; +}; diff --git a/packages/core/src/lib/types/plugin.ts b/packages/core/src/lib/types/plugin.ts index 0f6fae6f1..a302eb8a1 100644 --- a/packages/core/src/lib/types/plugin.ts +++ b/packages/core/src/lib/types/plugin.ts @@ -18,8 +18,8 @@ export interface BasePluginConfig { } export interface PluginRegistryConfig { - rotation?: Rotation; - scale?: number; + defaultRotation?: Rotation; + defaultScale?: number; logger?: Logger; } diff --git a/packages/engines/src/lib/pdfium/engine.ts b/packages/engines/src/lib/pdfium/engine.ts index a9c4cee17..585ab1d18 100644 --- a/packages/engines/src/lib/pdfium/engine.ts +++ b/packages/engines/src/lib/pdfium/engine.ts @@ -351,6 +351,7 @@ export class PdfiumEngine implements PdfEngine { public openDocumentUrl(file: PdfFileUrl, options?: PdfOpenDocumentUrlOptions) { const mode = options?.mode ?? 'auto'; const password = options?.password ?? ''; + const headers = options?.headers ?? {}; this.logger.debug(LOG_SOURCE, LOG_CATEGORY, 'openDocumentUrl called', file.url, mode); @@ -360,7 +361,7 @@ export class PdfiumEngine implements PdfEngine { // Start an async procedure (async () => { try { - const fetchFullTask = await this.fetchFullAndOpen(file, password); + const fetchFullTask = await this.fetchFullAndOpen(file, password, headers); fetchFullTask.wait( (doc) => task.resolve(doc), (err) => task.reject(err.reason), @@ -433,11 +434,15 @@ export class PdfiumEngine implements PdfEngine { * Fully fetch the file (using fetch) into an ArrayBuffer, * then call openDocumentFromBuffer. */ - private async fetchFullAndOpen(file: PdfFileUrl, password: string) { + private async fetchFullAndOpen( + file: PdfFileUrl, + password: string, + headers: Record = {}, + ) { this.logger.debug(LOG_SOURCE, LOG_CATEGORY, 'fetchFullAndOpen', file.url); // 1. fetch entire PDF as array buffer - const response = await fetch(file.url); + const response = await fetch(file.url, { headers }); if (!response.ok) { throw new Error(`Could not fetch PDF: ${response.statusText}`); } diff --git a/packages/models/src/pdf.ts b/packages/models/src/pdf.ts index 7dec7988e..a1886abdb 100644 --- a/packages/models/src/pdf.ts +++ b/packages/models/src/pdf.ts @@ -2471,6 +2471,10 @@ export interface PdfOpenDocumentUrlOptions { * Loading mode */ mode?: 'auto' | 'range-request' | 'full-fetch'; + /** + * Optional custom headers to send with fetch + */ + headers?: Record; } export interface PdfRenderOptions { diff --git a/packages/plugin-document-manager/package.json b/packages/plugin-document-manager/package.json new file mode 100644 index 000000000..a534d714d --- /dev/null +++ b/packages/plugin-document-manager/package.json @@ -0,0 +1,73 @@ +{ + "name": "@embedpdf/plugin-document-manager", + "version": "1.3.14", + "type": "module", + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./preact": { + "types": "./dist/preact/index.d.ts", + "import": "./dist/preact/index.js", + "require": "./dist/preact/index.cjs" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js", + "require": "./dist/react/index.cjs" + }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/vue/index.js", + "require": "./dist/vue/index.cjs" + } + }, + "scripts": { + "build:base": "vite build --mode base", + "build:react": "vite build --mode react", + "build:preact": "vite build --mode preact", + "build:vue": "vite build --mode vue", + "build": "pnpm run clean && concurrently -c auto -n base,react,preact,vue \"vite build --mode base\" \"vite build --mode react\" \"vite build --mode preact\" \"vite build --mode vue\"", + "clean": "rimraf dist", + "lint": "eslint src --color", + "lint:fix": "eslint src --color --fix" + }, + "dependencies": { + "@embedpdf/models": "workspace:*" + }, + "devDependencies": { + "@embedpdf/core": "workspace:*", + "@embedpdf/build": "workspace:*", + "@types/react": "^18.2.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "@embedpdf/core": "workspace:*", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "preact": "^10.26.4", + "vue": ">=3.2.0" + }, + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/embedpdf/embed-pdf-viewer", + "directory": "packages/plugin-document-manager" + }, + "homepage": "https://www.embedpdf.com/docs", + "bugs": { + "url": "https://github.com/embedpdf/embed-pdf-viewer/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugin-document-manager/src/index.ts b/packages/plugin-document-manager/src/index.ts new file mode 100644 index 000000000..f41a696fd --- /dev/null +++ b/packages/plugin-document-manager/src/index.ts @@ -0,0 +1 @@ +export * from './lib'; diff --git a/packages/plugin-document-manager/src/lib/actions.ts b/packages/plugin-document-manager/src/lib/actions.ts new file mode 100644 index 000000000..e5570cc60 --- /dev/null +++ b/packages/plugin-document-manager/src/lib/actions.ts @@ -0,0 +1,40 @@ +import { Action } from '@embedpdf/core'; + +export const SET_DOCUMENT_ORDER = 'SET_DOCUMENT_ORDER'; +export const ADD_TO_DOCUMENT_ORDER = 'ADD_TO_DOCUMENT_ORDER'; +export const REMOVE_FROM_DOCUMENT_ORDER = 'REMOVE_FROM_DOCUMENT_ORDER'; + +export interface SetDocumentOrderAction extends Action { + type: typeof SET_DOCUMENT_ORDER; + payload: string[]; +} + +export interface AddToDocumentOrderAction extends Action { + type: typeof ADD_TO_DOCUMENT_ORDER; + payload: { + documentId: string; + index?: number; // If not provided, add to end + }; +} + +export interface RemoveFromDocumentOrderAction extends Action { + type: typeof REMOVE_FROM_DOCUMENT_ORDER; + payload: string; +} + +export type DocumentManagerAction = + | SetDocumentOrderAction + | AddToDocumentOrderAction + | RemoveFromDocumentOrderAction; + +export function setDocumentOrder(order: string[]): SetDocumentOrderAction { + return { type: SET_DOCUMENT_ORDER, payload: order }; +} + +export function addToDocumentOrder(documentId: string, index?: number): AddToDocumentOrderAction { + return { type: ADD_TO_DOCUMENT_ORDER, payload: { documentId, index } }; +} + +export function removeFromDocumentOrder(documentId: string): RemoveFromDocumentOrderAction { + return { type: REMOVE_FROM_DOCUMENT_ORDER, payload: documentId }; +} diff --git a/packages/plugin-document-manager/src/lib/document-manager-plugin.ts b/packages/plugin-document-manager/src/lib/document-manager-plugin.ts new file mode 100644 index 000000000..e2ec7a7cd --- /dev/null +++ b/packages/plugin-document-manager/src/lib/document-manager-plugin.ts @@ -0,0 +1,660 @@ +import { + BasePlugin, + PluginRegistry, + createBehaviorEmitter, + startLoadingDocument, + setDocumentLoaded, + setDocumentError, + retryLoadingDocument, + closeDocument as closeDocumentAction, + setActiveDocument as setActiveDocumentAction, + DocumentState, + Unsubscribe, + Listener, +} from '@embedpdf/core'; +import { + PdfDocumentObject, + Task, + PdfFile, + PdfFileUrl, + PdfErrorReason, + PdfErrorCode, +} from '@embedpdf/models'; + +import { + DocumentManagerPluginConfig, + DocumentManagerState, + DocumentManagerCapability, + DocumentChangeEvent, + DocumentOrderChangeEvent, + LoadDocumentUrlOptions, + LoadDocumentBufferOptions, + RetryOptions, + DocumentErrorEvent, +} from './types'; +import { + DocumentManagerAction, + setDocumentOrder, + addToDocumentOrder, + removeFromDocumentOrder, +} from './actions'; + +export class DocumentManagerPlugin extends BasePlugin< + DocumentManagerPluginConfig, + DocumentManagerCapability, + DocumentManagerState, + DocumentManagerAction +> { + static readonly id = 'document-manager' as const; + + private readonly documentOpened$ = createBehaviorEmitter(); + private readonly documentClosed$ = createBehaviorEmitter(); + private readonly activeDocumentChanged$ = createBehaviorEmitter(); + private readonly documentError$ = createBehaviorEmitter(); + private readonly documentOrderChanged$ = createBehaviorEmitter(); + private readonly openFileRequest$ = createBehaviorEmitter<'open'>(); + + private maxDocuments?: number; + + // Store original load options ONLY for documents in error state (for retry) + private loadOptions = new Map(); + + constructor( + public readonly id: string, + registry: PluginRegistry, + config?: DocumentManagerPluginConfig, + ) { + super(id, registry); + this.maxDocuments = config?.maxDocuments; + } + + protected buildCapability(): DocumentManagerCapability { + return { + // Document lifecycle + openFileDialog: () => this.openFileRequest$.emit('open'), + openDocumentUrl: (options) => this.openDocumentUrl(options), + openDocumentBuffer: (options) => this.openDocumentBuffer(options), + retryDocument: (documentId, options) => this.retryDocument(documentId, options), + closeDocument: (documentId) => this.closeDocument(documentId), + closeAllDocuments: () => this.closeAllDocuments(), + + // Active document control + setActiveDocument: (documentId) => this.setActiveDocument(documentId), + getActiveDocumentId: () => this.getActiveDocumentId(), + getActiveDocument: () => this.getActiveDocument(), + + // Tab order management + getDocumentOrder: () => this.state.documentOrder, + moveDocument: (documentId, toIndex) => this.moveDocument(documentId, toIndex), + swapDocuments: (id1, id2) => this.swapDocuments(id1, id2), + + // Queries + getDocument: (documentId) => this.getDocument(documentId), + getDocumentState: (documentId) => this.getDocumentState(documentId), + getOpenDocuments: () => this.getOpenDocuments(), + isDocumentOpen: (documentId) => this.isDocumentOpen(documentId), + getDocumentCount: () => this.getDocumentCount(), + getDocumentIndex: (documentId) => this.getDocumentIndex(documentId), + + // Events + onDocumentOpened: this.documentOpened$.on, + onDocumentClosed: this.documentClosed$.on, + onDocumentError: this.documentError$.on, + onActiveDocumentChanged: this.activeDocumentChanged$.on, + onDocumentOrderChanged: this.documentOrderChanged$.on, + }; + } + + // ───────────────────────────────────────────────────────── + // Document Lifecycle Hooks (from BasePlugin) + // ───────────────────────────────────────────────────────── + + protected override onDocumentLoaded(documentId: string): void { + const docState = this.coreState.core.documents[documentId]; + if (!docState) return; + + // Only emit event when document is successfully loaded + if (docState.status === 'loaded') { + // Add to document order + this.dispatch(addToDocumentOrder(documentId)); + + // Clean up load options to free memory + this.loadOptions.delete(documentId); + + // Emit opened event with DocumentState directly + this.documentOpened$.emit(docState); + + this.logger.info( + 'DocumentManagerPlugin', + 'DocumentOpened', + `Document ${documentId} opened successfully`, + { name: docState.document?.name }, + ); + } + } + + protected override onDocumentClosed(documentId: string): void { + // Remove from order + this.dispatch(removeFromDocumentOrder(documentId)); + + // Clean up load options + this.loadOptions.delete(documentId); + + this.documentClosed$.emit(documentId); + + this.logger.info('DocumentManagerPlugin', 'DocumentClosed', `Document ${documentId} closed`); + } + + protected override onActiveDocumentChanged( + previousId: string | null, + currentId: string | null, + ): void { + const event: DocumentChangeEvent = { + previousDocumentId: previousId, + currentDocumentId: currentId, + }; + + this.activeDocumentChanged$.emit(event); + + this.logger.info( + 'DocumentManagerPlugin', + 'ActiveDocumentChanged', + `Active document changed from ${previousId} to ${currentId}`, + ); + } + + public onOpenFileRequest(handler: Listener<'open'>): Unsubscribe { + return this.openFileRequest$.on(handler); + } + + // ───────────────────────────────────────────────────────── + // Document Loading + // ───────────────────────────────────────────────────────── + + private openDocumentUrl(options: LoadDocumentUrlOptions): Task { + const task = new Task(); + + // Check document limit + if (this.maxDocuments && this.getDocumentCount() >= this.maxDocuments) { + task.reject({ + code: PdfErrorCode.Unknown, + message: `Maximum number of documents (${this.maxDocuments}) reached`, + }); + return task; + } + + const documentId = options.documentId || this.generateDocumentId(); + + // Store options for potential retry (will be cleared on success) + this.loadOptions.set(documentId, options); + + // Immediately create loading state + this.dispatchCoreAction(startLoadingDocument(documentId, options.scale, options.rotation)); + + this.logger.info( + 'DocumentManagerPlugin', + 'OpenDocumentUrl', + `Starting to load document from URL: ${options.url}`, + { documentId }, + ); + + // Create file object for engine + const file: PdfFileUrl = { + id: documentId, + name: this.extractNameFromUrl(options.url), + url: options.url, + }; + + // Call engine to load document + const engineTask = this.engine.openDocumentUrl(file, { + password: options.password, + mode: options.mode, + headers: options.headers, + }); + + // Handle result + engineTask.wait( + (pdfDocument) => { + // Update to loaded state + this.dispatchCoreAction(setDocumentLoaded(documentId, pdfDocument)); + + task.resolve(documentId); + }, + (error) => { + this.logger.error( + 'DocumentManagerPlugin', + 'OpenDocumentUrl', + 'Failed to load document', + error, + ); + + // Update to error state (keep loadOptions for retry) + this.dispatchCoreAction( + setDocumentError( + documentId, + error.reason?.message || 'Failed to load document', + error.reason?.code, + error.reason, + ), + ); + + this.documentError$.emit({ + documentId, + message: error.reason?.message || 'Failed to load document', + code: error.reason?.code, + reason: error.reason, + }); + + task.fail(error); + }, + ); + + return task; + } + + private openDocumentBuffer(options: LoadDocumentBufferOptions): Task { + const task = new Task(); + + if (this.maxDocuments && this.getDocumentCount() >= this.maxDocuments) { + task.reject({ + code: PdfErrorCode.Unknown, + message: `Maximum number of documents (${this.maxDocuments}) reached`, + }); + return task; + } + + const documentId = options.documentId || this.generateDocumentId(); + + // Store options for potential retry (will be cleared on success) + this.loadOptions.set(documentId, options); + + // Immediately create loading state + this.dispatchCoreAction(startLoadingDocument(documentId, options.scale, options.rotation)); + + this.logger.info( + 'DocumentManagerPlugin', + 'OpenDocumentBuffer', + `Starting to load document from buffer: ${options.name}`, + { documentId }, + ); + + const file: PdfFile = { + id: documentId, + name: options.name, + content: options.buffer, + }; + + const engineTask = this.engine.openDocumentBuffer(file, { + password: options.password, + }); + + engineTask.wait( + (pdfDocument) => { + this.dispatchCoreAction(setDocumentLoaded(documentId, pdfDocument)); + task.resolve(documentId); + }, + (error) => { + this.logger.error( + 'DocumentManagerPlugin', + 'OpenDocumentBuffer', + 'Failed to load document', + error, + ); + + this.dispatchCoreAction( + setDocumentError( + documentId, + error.reason?.message || 'Failed to load document', + error.reason?.code, + error.reason, + ), + ); + + this.documentError$.emit({ + documentId, + message: error.reason?.message || 'Failed to load document', + code: error.reason?.code, + reason: error.reason, + }); + + task.fail(error); + }, + ); + + return task; + } + + private retryDocument( + documentId: string, + retryOptions?: RetryOptions, + ): Task { + const task = new Task(); + + const docState = this.coreState.core.documents[documentId]; + if (!docState) { + task.reject({ + code: PdfErrorCode.NotFound, + message: `Document ${documentId} not found`, + }); + return task; + } + + // Check if document is already loaded successfully + if (docState.status === 'loaded') { + task.reject({ + code: PdfErrorCode.Unknown, + message: `Document ${documentId} is already loaded successfully`, + }); + return task; + } + + if (docState.status !== 'error') { + task.reject({ + code: PdfErrorCode.Unknown, + message: `Document ${documentId} is not in error state (current state: ${docState.status})`, + }); + return task; + } + + const originalOptions = this.loadOptions.get(documentId); + if (!originalOptions) { + task.reject({ + code: PdfErrorCode.Unknown, + message: `No retry information available for document ${documentId}`, + }); + return task; + } + + // Merge retry options (e.g., new password) + const mergedOptions = { + ...originalOptions, + ...(retryOptions?.password && { password: retryOptions.password }), + }; + + // Update stored options + this.loadOptions.set(documentId, mergedOptions); + + // Set back to loading state + this.dispatchCoreAction(retryLoadingDocument(documentId)); + + this.logger.info( + 'DocumentManagerPlugin', + 'RetryDocument', + `Retrying to load document ${documentId}`, + ); + + // Retry the load + if ('url' in mergedOptions) { + const file: PdfFileUrl = { + id: documentId, + name: this.extractNameFromUrl(mergedOptions.url), + url: mergedOptions.url, + }; + + const engineTask = this.engine.openDocumentUrl(file, { + password: mergedOptions.password, + mode: mergedOptions.mode, + headers: mergedOptions.headers, + }); + + engineTask.wait( + (pdfDocument) => { + this.dispatchCoreAction(setDocumentLoaded(documentId, pdfDocument)); + task.resolve(documentId); + }, + (error) => { + this.dispatchCoreAction( + setDocumentError( + documentId, + error.reason?.message || 'Failed to load document', + error.reason?.code, + error.reason, + ), + ); + this.documentError$.emit({ + documentId, + message: error.reason?.message || 'Failed to load document', + code: error.reason?.code, + reason: error.reason, + }); + task.fail(error); + }, + ); + } else { + // Buffer retry + const file: PdfFile = { + id: documentId, + name: mergedOptions.name, + content: mergedOptions.buffer, + }; + + const engineTask = this.engine.openDocumentBuffer(file, { + password: mergedOptions.password, + }); + + engineTask.wait( + (pdfDocument) => { + this.dispatchCoreAction(setDocumentLoaded(documentId, pdfDocument)); + task.resolve(documentId); + }, + (error) => { + this.dispatchCoreAction( + setDocumentError( + documentId, + error.reason?.message || 'Failed to load document', + error.reason?.code, + error.reason, + ), + ); + this.documentError$.emit({ + documentId, + message: error.reason?.message || 'Failed to load document', + code: error.reason?.code, + reason: error.reason, + }); + task.fail(error); + }, + ); + } + + return task; + } + + private closeDocument(documentId: string): Task { + const task = new Task(); + + const document = this.getDocument(documentId); + if (!document) { + this.logger.warn( + 'DocumentManagerPlugin', + 'CloseDocument', + `Cannot close document ${documentId}: not open`, + ); + task.resolve(); + return task; + } + + this.engine.closeDocument(document).wait( + () => { + this.dispatchCoreAction(closeDocumentAction(documentId)); + task.resolve(); + }, + (error) => { + this.logger.error( + 'DocumentManagerPlugin', + 'CloseDocument', + `Failed to close document ${documentId}`, + error, + ); + task.fail(error); + }, + ); + + return task; + } + + private closeAllDocuments(): Task { + const documentIds = Object.keys(this.coreState.core.documents); + const tasks = documentIds.map((documentId) => this.closeDocument(documentId)); + + this.logger.info( + 'DocumentManagerPlugin', + 'CloseAllDocuments', + `Closing ${documentIds.length} documents`, + ); + + return Task.all(tasks); + } + + // ───────────────────────────────────────────────────────── + // Active Document Control + // ───────────────────────────────────────────────────────── + + private setActiveDocument(documentId: string): void { + if (!this.isDocumentOpen(documentId)) { + throw new Error(`Cannot set active document: ${documentId} is not open`); + } + + this.dispatchCoreAction(setActiveDocumentAction(documentId)); + } + + private getActiveDocumentId(): string | null { + return this.coreState.core.activeDocumentId; + } + + private getActiveDocument(): PdfDocumentObject | null { + const activeId = this.coreState.core.activeDocumentId; + if (!activeId) return null; + + const docState = this.coreState.core.documents[activeId]; + return docState?.document ?? null; + } + + // ───────────────────────────────────────────────────────── + // Tab Order Management + // ───────────────────────────────────────────────────────── + + private moveDocument(documentId: string, toIndex: number): void { + const currentOrder = this.state.documentOrder; + const fromIndex = currentOrder.indexOf(documentId); + + if (fromIndex === -1) { + throw new Error(`Document ${documentId} not found in order`); + } + + if (toIndex < 0 || toIndex >= currentOrder.length) { + throw new Error(`Invalid index ${toIndex}`); + } + + if (fromIndex === toIndex) return; + + const newOrder = [...currentOrder]; + newOrder.splice(fromIndex, 1); + newOrder.splice(toIndex, 0, documentId); + + this.dispatch(setDocumentOrder(newOrder)); + + this.documentOrderChanged$.emit({ + order: newOrder, + movedDocumentId: documentId, + fromIndex, + toIndex, + }); + } + + private swapDocuments(documentId1: string, documentId2: string): void { + const currentOrder = this.state.documentOrder; + const index1 = currentOrder.indexOf(documentId1); + const index2 = currentOrder.indexOf(documentId2); + + if (index1 === -1 || index2 === -1) { + throw new Error('One or both documents not found in order'); + } + + const newOrder = [...currentOrder]; + [newOrder[index1], newOrder[index2]] = [newOrder[index2], newOrder[index1]]; + + this.dispatch(setDocumentOrder(newOrder)); + + this.documentOrderChanged$.emit({ + order: newOrder, + }); + } + + // ───────────────────────────────────────────────────────── + // Queries + // ───────────────────────────────────────────────────────── + + private getDocument(documentId: string): PdfDocumentObject | null { + const docState = this.coreState.core.documents[documentId]; + return docState?.document ?? null; + } + + private getDocumentState(documentId: string): DocumentState | null { + return this.coreState.core.documents[documentId] ?? null; + } + + private getOpenDocuments(): DocumentState[] { + // Return in order + return this.state.documentOrder + .map((documentId) => this.getDocumentState(documentId)) + .filter((state): state is DocumentState => state !== null); + } + + private isDocumentOpen(documentId: string): boolean { + return !!this.coreState.core.documents[documentId]; + } + + private getDocumentCount(): number { + return Object.keys(this.coreState.core.documents).length; + } + + private getDocumentIndex(documentId: string): number { + return this.state.documentOrder.indexOf(documentId); + } + + // ───────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────── + + private generateDocumentId(): string { + return `doc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + private extractNameFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const filename = pathname.split('/').pop() || 'document.pdf'; + return decodeURIComponent(filename); + } catch { + return 'document.pdf'; + } + } + + // ───────────────────────────────────────────────────────── + // Plugin Lifecycle + // ───────────────────────────────────────────────────────── + + async initialize(_config: DocumentManagerPluginConfig): Promise { + this.logger.info('DocumentManagerPlugin', 'Initialize', 'Document Manager Plugin initialized', { + maxDocuments: this.maxDocuments, + }); + } + + async destroy(): Promise { + // Close all documents + await this.closeAllDocuments().toPromise(); + + // Clear load options + this.loadOptions.clear(); + + // Clear emitters + this.documentOpened$.clear(); + this.documentClosed$.clear(); + this.activeDocumentChanged$.clear(); + this.documentOrderChanged$.clear(); + + super.destroy(); + } +} diff --git a/packages/plugin-document-manager/src/lib/index.ts b/packages/plugin-document-manager/src/lib/index.ts new file mode 100644 index 000000000..d689d8d4a --- /dev/null +++ b/packages/plugin-document-manager/src/lib/index.ts @@ -0,0 +1,24 @@ +import { PluginPackage } from '@embedpdf/core'; +import { DocumentManagerPlugin } from './document-manager-plugin'; +import { manifest, DOCUMENT_MANAGER_PLUGIN_ID } from './manifest'; +import { DocumentManagerPluginConfig, DocumentManagerState } from './types'; +import { documentManagerReducer, initialState } from './reducer'; +import { DocumentManagerAction } from './actions'; + +export const DocumentManagerPluginPackage: PluginPackage< + DocumentManagerPlugin, + DocumentManagerPluginConfig, + DocumentManagerState, + DocumentManagerAction +> = { + manifest, + create: (registry, config) => + new DocumentManagerPlugin(DOCUMENT_MANAGER_PLUGIN_ID, registry, config), + reducer: documentManagerReducer, + initialState, +}; + +export * from './document-manager-plugin'; +export * from './types'; +export * from './manifest'; +export * from './actions'; diff --git a/packages/plugin-document-manager/src/lib/manifest.ts b/packages/plugin-document-manager/src/lib/manifest.ts new file mode 100644 index 000000000..1f2898dd8 --- /dev/null +++ b/packages/plugin-document-manager/src/lib/manifest.ts @@ -0,0 +1,17 @@ +import { PluginManifest } from '@embedpdf/core'; +import { DocumentManagerPluginConfig } from './types'; + +export const DOCUMENT_MANAGER_PLUGIN_ID = 'document-manager'; + +export const manifest: PluginManifest = { + id: DOCUMENT_MANAGER_PLUGIN_ID, + name: 'Document Manager Plugin', + version: '1.0.0', + provides: ['document-manager'], + requires: [], + optional: [], + defaultConfig: { + enabled: true, + maxDocuments: 10, + }, +}; diff --git a/packages/plugin-document-manager/src/lib/reducer.ts b/packages/plugin-document-manager/src/lib/reducer.ts new file mode 100644 index 000000000..a50aec3d8 --- /dev/null +++ b/packages/plugin-document-manager/src/lib/reducer.ts @@ -0,0 +1,58 @@ +import { Reducer } from '@embedpdf/core'; +import { DocumentManagerState } from './types'; +import { + DocumentManagerAction, + SET_DOCUMENT_ORDER, + ADD_TO_DOCUMENT_ORDER, + REMOVE_FROM_DOCUMENT_ORDER, +} from './actions'; + +export const initialState: DocumentManagerState = { + documentOrder: [], +}; + +export const documentManagerReducer: Reducer = ( + state = initialState, + action, +) => { + switch (action.type) { + case SET_DOCUMENT_ORDER: + return { + ...state, + documentOrder: action.payload, + }; + + case ADD_TO_DOCUMENT_ORDER: { + const { documentId, index } = action.payload; + const newOrder = [...state.documentOrder]; + + // Remove if already exists + const existingIndex = newOrder.indexOf(documentId); + if (existingIndex !== -1) { + newOrder.splice(existingIndex, 1); + } + + // Add at specified index or end + if (index !== undefined && index >= 0 && index <= newOrder.length) { + newOrder.splice(index, 0, documentId); + } else { + newOrder.push(documentId); + } + + return { + ...state, + documentOrder: newOrder, + }; + } + + case REMOVE_FROM_DOCUMENT_ORDER: { + return { + ...state, + documentOrder: state.documentOrder.filter((id) => id !== action.payload), + }; + } + + default: + return state; + } +}; diff --git a/packages/plugin-document-manager/src/lib/types.ts b/packages/plugin-document-manager/src/lib/types.ts new file mode 100644 index 000000000..1547a394f --- /dev/null +++ b/packages/plugin-document-manager/src/lib/types.ts @@ -0,0 +1,89 @@ +import { BasePluginConfig, EventHook, DocumentState } from '@embedpdf/core'; +import { PdfDocumentObject, Rotation, Task, PdfErrorReason } from '@embedpdf/models'; + +export interface DocumentManagerPluginConfig extends BasePluginConfig { + maxDocuments?: number; +} + +export interface DocumentManagerState { + // Track document order (for tabs) + documentOrder: string[]; +} + +export interface DocumentChangeEvent { + previousDocumentId: string | null; + currentDocumentId: string | null; +} + +export interface DocumentOrderChangeEvent { + order: string[]; + movedDocumentId?: string; + fromIndex?: number; + toIndex?: number; +} + +export interface DocumentErrorEvent { + documentId: string; + message: string; + code?: number; + reason?: PdfErrorReason; +} + +// Load options +export interface LoadDocumentUrlOptions { + url: string; + documentId?: string; + password?: string; + mode?: 'auto' | 'range-request' | 'full-fetch'; + headers?: Record; + scale?: number; + rotation?: Rotation; +} + +export interface LoadDocumentBufferOptions { + buffer: ArrayBuffer; + name: string; + documentId?: string; + password?: string; + scale?: number; + rotation?: Rotation; +} + +export interface RetryOptions { + password?: string; +} + +export interface DocumentManagerCapability { + // Document lifecycle + openFileDialog: () => void; + openDocumentUrl(options: LoadDocumentUrlOptions): Task; + openDocumentBuffer(options: LoadDocumentBufferOptions): Task; + retryDocument(documentId: string, options?: RetryOptions): Task; + closeDocument(documentId: string): Task; + closeAllDocuments(): Task; + + // Active document control + setActiveDocument(documentId: string): void; + getActiveDocumentId(): string | null; + getActiveDocument(): PdfDocumentObject | null; + + // Tab order management + getDocumentOrder(): string[]; + moveDocument(documentId: string, toIndex: number): void; + swapDocuments(documentId1: string, documentId2: string): void; + + // Queries + getDocument(documentId: string): PdfDocumentObject | null; + getDocumentState(documentId: string): DocumentState | null; + getOpenDocuments(): DocumentState[]; + isDocumentOpen(documentId: string): boolean; + getDocumentCount(): number; + getDocumentIndex(documentId: string): number; + + // Events (now emit DocumentState directly) + onDocumentOpened: EventHook; + onDocumentClosed: EventHook; + onDocumentError: EventHook; + onActiveDocumentChanged: EventHook; + onDocumentOrderChanged: EventHook; +} diff --git a/packages/plugin-document-manager/src/preact/adapter.ts b/packages/plugin-document-manager/src/preact/adapter.ts new file mode 100644 index 000000000..49538ff69 --- /dev/null +++ b/packages/plugin-document-manager/src/preact/adapter.ts @@ -0,0 +1,10 @@ +export { Fragment } from 'preact'; +export { useEffect, useRef, useState, useCallback } from 'preact/hooks'; +export type { ComponentChildren as ReactNode } from 'preact'; + +export type HTMLAttributes = import('preact').JSX.HTMLAttributes< + T extends EventTarget ? T : never +>; +export type ChangeEvent = import('preact').JSX.TargetedEvent< + T extends EventTarget ? T : never +>; diff --git a/packages/plugin-document-manager/src/preact/core.ts b/packages/plugin-document-manager/src/preact/core.ts new file mode 100644 index 000000000..a1403497b --- /dev/null +++ b/packages/plugin-document-manager/src/preact/core.ts @@ -0,0 +1 @@ +export * from '@embedpdf/core/preact'; diff --git a/packages/plugin-document-manager/src/preact/index.ts b/packages/plugin-document-manager/src/preact/index.ts new file mode 100644 index 000000000..90a217667 --- /dev/null +++ b/packages/plugin-document-manager/src/preact/index.ts @@ -0,0 +1 @@ +export * from '../shared'; diff --git a/packages/plugin-document-manager/src/preact/tsconfig.preact.json b/packages/plugin-document-manager/src/preact/tsconfig.preact.json new file mode 100644 index 000000000..eed2b19f0 --- /dev/null +++ b/packages/plugin-document-manager/src/preact/tsconfig.preact.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "baseUrl": "../..", + "paths": { + "@framework": ["src/preact/adapter.ts"], + "@embedpdf/core/@framework": ["src/preact/core.ts"], + "@embedpdf/plugin-document-manager": ["src/lib/index.ts"] + } + }, + "include": ["../shared/**/*.ts", "../shared/**/*.tsx", "./**/*.ts", "./**/*.tsx"] +} diff --git a/packages/plugin-document-manager/src/react/adapter.ts b/packages/plugin-document-manager/src/react/adapter.ts new file mode 100644 index 000000000..a65e6432b --- /dev/null +++ b/packages/plugin-document-manager/src/react/adapter.ts @@ -0,0 +1,2 @@ +export { Fragment, useEffect, useRef, useState, useCallback } from 'react'; +export type { ReactNode, HTMLAttributes, CSSProperties, ChangeEvent } from 'react'; diff --git a/packages/plugin-document-manager/src/react/core.ts b/packages/plugin-document-manager/src/react/core.ts new file mode 100644 index 000000000..60aefb42b --- /dev/null +++ b/packages/plugin-document-manager/src/react/core.ts @@ -0,0 +1 @@ +export * from '@embedpdf/core/react'; diff --git a/packages/plugin-document-manager/src/react/index.ts b/packages/plugin-document-manager/src/react/index.ts new file mode 100644 index 000000000..90a217667 --- /dev/null +++ b/packages/plugin-document-manager/src/react/index.ts @@ -0,0 +1 @@ +export * from '../shared'; diff --git a/packages/plugin-document-manager/src/react/tsconfig.react.json b/packages/plugin-document-manager/src/react/tsconfig.react.json new file mode 100644 index 000000000..7e4fb486a --- /dev/null +++ b/packages/plugin-document-manager/src/react/tsconfig.react.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "baseUrl": "../..", + "paths": { + "@framework": ["src/react/adapter.ts"], + "@embedpdf/core/@framework": ["src/react/core.ts"], + "@embedpdf/plugin-document-manager": ["src/lib/index.ts"] + } + }, + "include": ["../shared/**/*.ts", "../shared/**/*.tsx", "./**/*.ts", "./**/*.tsx"] +} diff --git a/packages/plugin-document-manager/src/shared/components/document-content.tsx b/packages/plugin-document-manager/src/shared/components/document-content.tsx new file mode 100644 index 000000000..fa5913411 --- /dev/null +++ b/packages/plugin-document-manager/src/shared/components/document-content.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from '@framework'; +import { useDocumentState } from '../hooks'; +import { DocumentState } from '@embedpdf/core'; + +export interface DocumentContentRenderProps { + documentState: DocumentState; + isLoading: boolean; + isError: boolean; + isLoaded: boolean; +} + +interface DocumentContentProps { + documentId: string | null; + children: (props: DocumentContentRenderProps) => ReactNode; +} + +/** + * Headless component for rendering document content with loading/error states + * + * @example + * + * {({ document, isLoading, isError, isLoaded }) => { + * if (isLoading) return ; + * if (isError) return ; + * if (isLoaded) return ; + * return null; + * }} + * + */ +export function DocumentContent({ documentId, children }: DocumentContentProps) { + const documentState = useDocumentState(documentId); + + if (!documentState) return null; + + const isLoading = documentState.status === 'loading'; + const isError = documentState.status === 'error'; + const isLoaded = documentState.status === 'loaded'; + + return <>{children({ documentState, isLoading, isError, isLoaded })}; +} diff --git a/packages/plugin-document-manager/src/shared/components/document-tabs.tsx b/packages/plugin-document-manager/src/shared/components/document-tabs.tsx new file mode 100644 index 000000000..90e59cecf --- /dev/null +++ b/packages/plugin-document-manager/src/shared/components/document-tabs.tsx @@ -0,0 +1,79 @@ +import { ReactNode, useCallback } from '@framework'; +import { useOpenDocuments, useActiveDocument, useDocumentManagerCapability } from '../hooks'; +import { DocumentState } from '@embedpdf/core'; + +export interface TabActions { + select: (documentId: string) => void; + close: (documentId: string) => void; + move: (documentId: string, toIndex: number) => void; +} + +export interface DocumentTabsRenderProps { + documentStates: DocumentState[]; + activeDocumentId: string | null; + actions: TabActions; +} + +interface DocumentTabsProps { + children: (props: DocumentTabsRenderProps) => ReactNode; +} + +/** + * Headless component for managing document tabs + * Provides all state and actions, completely UI-agnostic + * + * @example + * + * {({ documents, activeDocumentId, actions }) => ( + *
+ * {documents.map((doc) => ( + * + * + * ))} + *
+ * )} + *
+ */ +export function DocumentTabs({ children }: DocumentTabsProps) { + const documentStates = useOpenDocuments(); + const { activeDocumentId } = useActiveDocument(); + const { provides } = useDocumentManagerCapability(); + + const select = useCallback( + (documentId: string) => { + provides?.setActiveDocument(documentId); + }, + [provides], + ); + + const close = useCallback( + (documentId: string) => { + provides?.closeDocument(documentId); + }, + [provides], + ); + + const move = useCallback( + (documentId: string, toIndex: number) => { + provides?.moveDocument(documentId, toIndex); + }, + [provides], + ); + + const actions: TabActions = { + select, + close, + move, + }; + + return <>{children({ documentStates, activeDocumentId, actions })}; +} diff --git a/packages/plugin-document-manager/src/shared/components/file-picker.tsx b/packages/plugin-document-manager/src/shared/components/file-picker.tsx new file mode 100644 index 000000000..8da6250a6 --- /dev/null +++ b/packages/plugin-document-manager/src/shared/components/file-picker.tsx @@ -0,0 +1,36 @@ +import { ChangeEvent, useEffect, useRef } from '@framework'; +import { useDocumentManagerCapability, useDocumentManagerPlugin } from '../hooks'; + +export function FilePicker() { + const { plugin } = useDocumentManagerPlugin(); + const { provides } = useDocumentManagerCapability(); + const inputRef = useRef(null); + + useEffect(() => { + if (!plugin?.onOpenFileRequest) return; + const unsub = plugin.onOpenFileRequest((req) => { + if (req === 'open') inputRef.current?.click(); + }); + return unsub; + }, [plugin]); + + const onChange = async (e: ChangeEvent) => { + const file = (e.currentTarget as HTMLInputElement).files?.[0]; + if (!file || !provides) return; + const buffer = await file.arrayBuffer(); + provides.openDocumentBuffer({ + name: file.name, + buffer, + }); + }; + + return ( + + ); +} diff --git a/packages/plugin-document-manager/src/shared/components/index.ts b/packages/plugin-document-manager/src/shared/components/index.ts new file mode 100644 index 000000000..3a8ed7ef0 --- /dev/null +++ b/packages/plugin-document-manager/src/shared/components/index.ts @@ -0,0 +1,3 @@ +export * from './document-content'; +export * from './document-tabs'; +export * from './file-picker'; diff --git a/packages/plugin-document-manager/src/shared/hooks/index.ts b/packages/plugin-document-manager/src/shared/hooks/index.ts new file mode 100644 index 000000000..9e8427e4a --- /dev/null +++ b/packages/plugin-document-manager/src/shared/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-document-manager'; diff --git a/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts b/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts new file mode 100644 index 000000000..89dfb1310 --- /dev/null +++ b/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts @@ -0,0 +1,105 @@ +import { useEffect, useState } from '@framework'; +import { useCapability, usePlugin } from '@embedpdf/core/@framework'; +import { DocumentManagerPlugin } from '@embedpdf/plugin-document-manager'; +import { DocumentState } from '@embedpdf/core'; + +export const useDocumentManagerPlugin = () => + usePlugin(DocumentManagerPlugin.id); +export const useDocumentManagerCapability = () => + useCapability(DocumentManagerPlugin.id); + +/** + * Hook for active document state + */ +export const useActiveDocument = () => { + const { provides } = useDocumentManagerCapability(); + const [activeDocumentId, setActiveDocumentId] = useState(null); + const [activeDocument, setActiveDocument] = useState(null); + + useEffect(() => { + if (!provides) return; + + const updateActive = () => { + const id = provides.getActiveDocumentId(); + setActiveDocumentId(id); + setActiveDocument(id ? provides.getDocumentState(id) : null); + }; + + updateActive(); + + return provides.onActiveDocumentChanged(() => { + updateActive(); + }); + }, [provides]); + + return { + activeDocumentId, + activeDocument, + }; +}; + +/** + * Hook for all open documents (in order) + */ +export const useOpenDocuments = () => { + const { provides } = useDocumentManagerCapability(); + const [documents, setDocuments] = useState([]); + + useEffect(() => { + if (!provides) return; + + const updateDocuments = () => { + setDocuments(provides.getOpenDocuments()); + }; + + updateDocuments(); + + const unsubOpen = provides.onDocumentOpened(updateDocuments); + const unsubClose = provides.onDocumentClosed(updateDocuments); + const unsubOrder = provides.onDocumentOrderChanged(updateDocuments); + + return () => { + unsubOpen(); + unsubClose(); + unsubOrder(); + }; + }, [provides]); + + return documents; +}; + +/** + * Hook for a specific document's info + */ +export const useDocumentState = (documentId: string | null) => { + const { provides } = useDocumentManagerCapability(); + const [documentState, setDocumentState] = useState(null); + + useEffect(() => { + if (!provides || !documentId) { + setDocumentState(null); + return; + } + + const updateState = () => { + setDocumentState(provides.getDocumentState(documentId)); + }; + + updateState(); + + const unsubOpen = provides.onDocumentOpened((info) => { + if (info.id === documentId) updateState(); + }); + + const unsubClose = provides.onDocumentClosed((id) => { + if (id === documentId) setDocumentState(null); + }); + + return () => { + unsubOpen(); + unsubClose(); + }; + }, [provides, documentId]); + + return documentState; +}; diff --git a/packages/plugin-document-manager/src/shared/index.ts b/packages/plugin-document-manager/src/shared/index.ts new file mode 100644 index 000000000..5349324d0 --- /dev/null +++ b/packages/plugin-document-manager/src/shared/index.ts @@ -0,0 +1,12 @@ +import { createPluginPackage } from '@embedpdf/core'; +import { DocumentManagerPluginPackage as BaseDocumentManagerPackage } from '@embedpdf/plugin-document-manager'; +import { FilePicker } from './components'; + +export * from './hooks'; +export * from './components'; +export * from '@embedpdf/plugin-document-manager'; + +// A convenience package that auto-registers our utilities +export const DocumentManagerPackage = createPluginPackage(BaseDocumentManagerPackage) + .addUtility(FilePicker) // headless utility consumers can mount once and call cap.openFileDialog() + .build(); diff --git a/packages/plugin-document-manager/src/vue/components/document-content.vue b/packages/plugin-document-manager/src/vue/components/document-content.vue new file mode 100644 index 000000000..f245fd0d9 --- /dev/null +++ b/packages/plugin-document-manager/src/vue/components/document-content.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/plugin-document-manager/src/vue/components/document-tabs.vue b/packages/plugin-document-manager/src/vue/components/document-tabs.vue new file mode 100644 index 000000000..57d143505 --- /dev/null +++ b/packages/plugin-document-manager/src/vue/components/document-tabs.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/plugin-document-manager/src/vue/components/file-picker.vue b/packages/plugin-document-manager/src/vue/components/file-picker.vue new file mode 100644 index 000000000..5036d7753 --- /dev/null +++ b/packages/plugin-document-manager/src/vue/components/file-picker.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/plugin-document-manager/src/vue/components/index.ts b/packages/plugin-document-manager/src/vue/components/index.ts new file mode 100644 index 000000000..263c0179c --- /dev/null +++ b/packages/plugin-document-manager/src/vue/components/index.ts @@ -0,0 +1,3 @@ +export { default as DocumentContent } from './document-content.vue'; +export { default as DocumentTabs } from './document-tabs.vue'; +export { default as FilePicker } from './file-picker.vue'; diff --git a/packages/plugin-document-manager/src/vue/hooks/index.ts b/packages/plugin-document-manager/src/vue/hooks/index.ts new file mode 100644 index 000000000..9e8427e4a --- /dev/null +++ b/packages/plugin-document-manager/src/vue/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-document-manager'; diff --git a/packages/plugin-document-manager/src/vue/hooks/use-document-manager.ts b/packages/plugin-document-manager/src/vue/hooks/use-document-manager.ts new file mode 100644 index 000000000..c3cb803e5 --- /dev/null +++ b/packages/plugin-document-manager/src/vue/hooks/use-document-manager.ts @@ -0,0 +1,110 @@ +import { DocumentState } from '@embedpdf/core'; +import { useCapability, usePlugin } from '@embedpdf/core/vue'; +import { DocumentManagerPlugin } from '@embedpdf/plugin-document-manager'; +import { ref, watch } from 'vue'; + +export const useDocumentManagerPlugin = () => + usePlugin(DocumentManagerPlugin.id); +export const useDocumentManagerCapability = () => + useCapability(DocumentManagerPlugin.id); + +export function useActiveDocument() { + const { provides } = useDocumentManagerCapability(); + const activeDocumentId = ref(null); + const activeDocument = ref(null); + + watch( + provides, + (providesValue, _, onCleanup) => { + if (!providesValue) return; + + const updateActive = () => { + const id = providesValue.getActiveDocumentId(); + activeDocumentId.value = id; + activeDocument.value = id ? providesValue.getDocumentState(id) : null; + }; + + updateActive(); + + const unsubscribe = providesValue.onActiveDocumentChanged(() => { + updateActive(); + }); + + onCleanup(unsubscribe); + }, + { immediate: true }, + ); + + return { + activeDocumentId, + activeDocument, + }; +} + +export function useOpenDocuments() { + const { provides } = useDocumentManagerCapability(); + const documents = ref([]); + + watch( + provides, + (providesValue, _, onCleanup) => { + if (!providesValue) return; + + const updateDocuments = () => { + documents.value = providesValue.getOpenDocuments(); + }; + + updateDocuments(); + + const unsubOpen = providesValue.onDocumentOpened(updateDocuments); + const unsubClose = providesValue.onDocumentClosed(updateDocuments); + const unsubOrder = providesValue.onDocumentOrderChanged(updateDocuments); + + onCleanup(() => { + unsubOpen(); + unsubClose(); + unsubOrder(); + }); + }, + { immediate: true }, + ); + + return documents; +} + +export function useDocumentState(documentIdRef: () => string | null) { + const { provides } = useDocumentManagerCapability(); + const documentState = ref(null); + + watch( + [provides, documentIdRef], + ([providesValue, documentId], _, onCleanup) => { + if (!providesValue || !documentId) { + documentState.value = null; + return; + } + + const updateState = () => { + documentState.value = providesValue.getDocumentState(documentId); + }; + + updateState(); + + const unsubOpen = providesValue.onDocumentOpened((info) => { + if (info.id === documentId) updateState(); + }); + + const unsubClose = providesValue.onDocumentClosed((id) => { + if (id === documentId) documentState.value = null; + }); + + onCleanup(() => { + unsubOpen(); + unsubClose(); + }); + }, + { immediate: true }, + ); + + return documentState; +} diff --git a/packages/plugin-document-manager/src/vue/index.ts b/packages/plugin-document-manager/src/vue/index.ts new file mode 100644 index 000000000..5349324d0 --- /dev/null +++ b/packages/plugin-document-manager/src/vue/index.ts @@ -0,0 +1,12 @@ +import { createPluginPackage } from '@embedpdf/core'; +import { DocumentManagerPluginPackage as BaseDocumentManagerPackage } from '@embedpdf/plugin-document-manager'; +import { FilePicker } from './components'; + +export * from './hooks'; +export * from './components'; +export * from '@embedpdf/plugin-document-manager'; + +// A convenience package that auto-registers our utilities +export const DocumentManagerPackage = createPluginPackage(BaseDocumentManagerPackage) + .addUtility(FilePicker) // headless utility consumers can mount once and call cap.openFileDialog() + .build(); diff --git a/packages/plugin-document-manager/src/vue/tsconfig.vue.json b/packages/plugin-document-manager/src/vue/tsconfig.vue.json new file mode 100644 index 000000000..ba18b1967 --- /dev/null +++ b/packages/plugin-document-manager/src/vue/tsconfig.vue.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "types": ["vue"], + "baseUrl": "../..", + "paths": { + "@embedpdf/plugin-document-manager": ["src/lib/index.ts"] + } + }, + "include": ["./**/*.ts", "./**/*.vue"] +} diff --git a/packages/plugin-document-manager/tsconfig.json b/packages/plugin-document-manager/tsconfig.json new file mode 100644 index 000000000..ebbdd534e --- /dev/null +++ b/packages/plugin-document-manager/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "react", + "rootDir": "src", + "paths": { + "@framework": ["./src/react/adapter.ts"], + "@embedpdf/core/@framework": ["./src/react/core.ts"], + "@embedpdf/plugin-document-manager": ["./src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/plugin-document-manager/vite.config.ts b/packages/plugin-document-manager/vite.config.ts new file mode 100644 index 000000000..827dc56ec --- /dev/null +++ b/packages/plugin-document-manager/vite.config.ts @@ -0,0 +1,2 @@ +import { defineLibrary } from '@embedpdf/build/vite'; +export default defineLibrary(); diff --git a/packages/plugin-viewport/src/lib/actions.ts b/packages/plugin-viewport/src/lib/actions.ts index 3df3be01c..d34d06fd4 100644 --- a/packages/plugin-viewport/src/lib/actions.ts +++ b/packages/plugin-viewport/src/lib/actions.ts @@ -1,21 +1,71 @@ import { Action } from '@embedpdf/core'; - import { ViewportInputMetrics, ViewportScrollMetrics } from './types'; +// Document lifecycle (state persistence) +export const INIT_VIEWPORT_STATE = 'INIT_VIEWPORT_STATE'; +export const CLEANUP_VIEWPORT_STATE = 'CLEANUP_VIEWPORT_STATE'; + +// Viewport registration (DOM lifecycle) +export const REGISTER_VIEWPORT = 'REGISTER_VIEWPORT'; +export const UNREGISTER_VIEWPORT = 'UNREGISTER_VIEWPORT'; + +// Viewport operations export const SET_VIEWPORT_METRICS = 'SET_VIEWPORT_METRICS'; export const SET_VIEWPORT_SCROLL_METRICS = 'SET_VIEWPORT_SCROLL_METRICS'; export const SET_VIEWPORT_GAP = 'SET_VIEWPORT_GAP'; export const SET_SCROLL_ACTIVITY = 'SET_SCROLL_ACTIVITY'; export const SET_SMOOTH_SCROLL_ACTIVITY = 'SET_SMOOTH_SCROLL_ACTIVITY'; +export const SET_ACTIVE_VIEWPORT_DOCUMENT = 'SET_ACTIVE_VIEWPORT_DOCUMENT'; + +// State persistence actions +export interface InitViewportStateAction extends Action { + type: typeof INIT_VIEWPORT_STATE; + payload: { + documentId: string; + }; +} + +export interface CleanupViewportStateAction extends Action { + type: typeof CLEANUP_VIEWPORT_STATE; + payload: { + documentId: string; + }; +} + +// Registration actions (DOM lifecycle) +export interface RegisterViewportAction extends Action { + type: typeof REGISTER_VIEWPORT; + payload: { + documentId: string; + }; +} + +export interface UnregisterViewportAction extends Action { + type: typeof UNREGISTER_VIEWPORT; + payload: { + documentId: string; + }; +} + +export interface SetActiveViewportDocumentAction extends Action { + type: typeof SET_ACTIVE_VIEWPORT_DOCUMENT; + payload: string | null; // documentId +} export interface SetViewportMetricsAction extends Action { type: typeof SET_VIEWPORT_METRICS; - payload: ViewportInputMetrics; + payload: { + documentId: string; + metrics: ViewportInputMetrics; + }; } export interface SetViewportScrollMetricsAction extends Action { type: typeof SET_VIEWPORT_SCROLL_METRICS; - payload: ViewportScrollMetrics; + payload: { + documentId: string; + scrollMetrics: ViewportScrollMetrics; + }; } export interface SetViewportGapAction extends Action { @@ -25,50 +75,84 @@ export interface SetViewportGapAction extends Action { export interface SetScrollActivityAction extends Action { type: typeof SET_SCROLL_ACTIVITY; - payload: boolean; + payload: { + documentId: string; + isScrolling: boolean; + }; } export interface SetSmoothScrollActivityAction extends Action { type: typeof SET_SMOOTH_SCROLL_ACTIVITY; - payload: boolean; + payload: { + documentId: string; + isSmoothScrolling: boolean; + }; } export type ViewportAction = + | InitViewportStateAction + | CleanupViewportStateAction + | RegisterViewportAction + | UnregisterViewportAction + | SetActiveViewportDocumentAction | SetViewportMetricsAction | SetViewportScrollMetricsAction | SetViewportGapAction | SetScrollActivityAction | SetSmoothScrollActivityAction; +// Action Creators + +export function initViewportState(documentId: string): InitViewportStateAction { + return { type: INIT_VIEWPORT_STATE, payload: { documentId } }; +} + +export function cleanupViewportState(documentId: string): CleanupViewportStateAction { + return { type: CLEANUP_VIEWPORT_STATE, payload: { documentId } }; +} + +export function registerViewport(documentId: string): RegisterViewportAction { + return { type: REGISTER_VIEWPORT, payload: { documentId } }; +} + +export function unregisterViewport(documentId: string): UnregisterViewportAction { + return { type: UNREGISTER_VIEWPORT, payload: { documentId } }; +} + +export function setActiveViewportDocument( + documentId: string | null, +): SetActiveViewportDocumentAction { + return { type: SET_ACTIVE_VIEWPORT_DOCUMENT, payload: documentId }; +} + export function setViewportGap(viewportGap: number): SetViewportGapAction { - return { - type: SET_VIEWPORT_GAP, - payload: viewportGap, - }; + return { type: SET_VIEWPORT_GAP, payload: viewportGap }; } export function setViewportMetrics( - viewportMetrics: ViewportInputMetrics, + documentId: string, + metrics: ViewportInputMetrics, ): SetViewportMetricsAction { - return { - type: SET_VIEWPORT_METRICS, - payload: viewportMetrics, - }; + return { type: SET_VIEWPORT_METRICS, payload: { documentId, metrics } }; } export function setViewportScrollMetrics( + documentId: string, scrollMetrics: ViewportScrollMetrics, ): SetViewportScrollMetricsAction { - return { - type: SET_VIEWPORT_SCROLL_METRICS, - payload: scrollMetrics, - }; + return { type: SET_VIEWPORT_SCROLL_METRICS, payload: { documentId, scrollMetrics } }; } -export function setScrollActivity(isScrolling: boolean): SetScrollActivityAction { - return { type: SET_SCROLL_ACTIVITY, payload: isScrolling }; +export function setScrollActivity( + documentId: string, + isScrolling: boolean, +): SetScrollActivityAction { + return { type: SET_SCROLL_ACTIVITY, payload: { documentId, isScrolling } }; } -export function setSmoothScrollActivity(isSmoothScrolling: boolean): SetSmoothScrollActivityAction { - return { type: SET_SMOOTH_SCROLL_ACTIVITY, payload: isSmoothScrolling }; +export function setSmoothScrollActivity( + documentId: string, + isSmoothScrolling: boolean, +): SetSmoothScrollActivityAction { + return { type: SET_SMOOTH_SCROLL_ACTIVITY, payload: { documentId, isSmoothScrolling } }; } diff --git a/packages/plugin-viewport/src/lib/reducer.ts b/packages/plugin-viewport/src/lib/reducer.ts index 64813cdbf..995152894 100644 --- a/packages/plugin-viewport/src/lib/reducer.ts +++ b/packages/plugin-viewport/src/lib/reducer.ts @@ -1,17 +1,20 @@ import { Reducer } from '@embedpdf/core'; - import { + ViewportAction, + INIT_VIEWPORT_STATE, + CLEANUP_VIEWPORT_STATE, + REGISTER_VIEWPORT, + UNREGISTER_VIEWPORT, + SET_ACTIVE_VIEWPORT_DOCUMENT, SET_VIEWPORT_METRICS, SET_VIEWPORT_SCROLL_METRICS, SET_VIEWPORT_GAP, - ViewportAction, SET_SCROLL_ACTIVITY, SET_SMOOTH_SCROLL_ACTIVITY, } from './actions'; -import { ViewportState } from './types'; +import { ViewportState, ViewportDocumentState } from './types'; -export const initialState: ViewportState = { - viewportGap: 0, +const initialViewportDocumentState: ViewportDocumentState = { viewportMetrics: { width: 0, height: 0, @@ -21,62 +24,193 @@ export const initialState: ViewportState = { clientHeight: 0, scrollWidth: 0, scrollHeight: 0, - relativePosition: { - x: 0, - y: 0, - }, + relativePosition: { x: 0, y: 0 }, }, isScrolling: false, isSmoothScrolling: false, }; +export const initialState: ViewportState = { + viewportGap: 0, + documents: {}, + activeViewports: new Set(), + activeDocumentId: null, +}; + export const viewportReducer: Reducer = ( state = initialState, action, ) => { switch (action.type) { - case SET_VIEWPORT_GAP: - return { ...state, viewportGap: action.payload }; - case SET_VIEWPORT_METRICS: + // ───────────────────────────────────────────────────────── + // State Persistence (Document Lifecycle) + // ───────────────────────────────────────────────────────── + + case INIT_VIEWPORT_STATE: { + const { documentId } = action.payload; + return { + ...state, + documents: { + ...state.documents, + [documentId]: initialViewportDocumentState, + }, + }; + } + + case CLEANUP_VIEWPORT_STATE: { + const { documentId } = action.payload; + const { [documentId]: removed, ...remainingDocs } = state.documents; + + // Also remove from active viewports if present + const newActiveViewports = new Set(state.activeViewports); + newActiveViewports.delete(documentId); + + return { + ...state, + documents: remainingDocs, + activeViewports: newActiveViewports, + activeDocumentId: state.activeDocumentId === documentId ? null : state.activeDocumentId, + }; + } + + // ───────────────────────────────────────────────────────── + // Viewport Registration (DOM Lifecycle) + // ───────────────────────────────────────────────────────── + + case REGISTER_VIEWPORT: { + const { documentId } = action.payload; + const newActiveViewports = new Set(state.activeViewports); + newActiveViewports.add(documentId); + + return { + ...state, + activeViewports: newActiveViewports, + // Set as active if no active document + activeDocumentId: state.activeDocumentId ?? documentId, + }; + } + + case UNREGISTER_VIEWPORT: { + const { documentId } = action.payload; + const newActiveViewports = new Set(state.activeViewports); + newActiveViewports.delete(documentId); + + return { + ...state, + activeViewports: newActiveViewports, + }; + } + + case SET_ACTIVE_VIEWPORT_DOCUMENT: { + return { + ...state, + activeDocumentId: action.payload, + }; + } + + // ───────────────────────────────────────────────────────── + // Viewport Operations + // ───────────────────────────────────────────────────────── + + case SET_VIEWPORT_GAP: { + return { + ...state, + viewportGap: action.payload, + }; + } + + case SET_VIEWPORT_METRICS: { + const { documentId, metrics } = action.payload; + const viewport = state.documents[documentId]; + if (!viewport) return state; + + return { + ...state, + documents: { + ...state.documents, + [documentId]: { + ...viewport, + viewportMetrics: { + width: metrics.width, + height: metrics.height, + scrollTop: metrics.scrollTop, + scrollLeft: metrics.scrollLeft, + clientWidth: metrics.clientWidth, + clientHeight: metrics.clientHeight, + scrollWidth: metrics.scrollWidth, + scrollHeight: metrics.scrollHeight, + relativePosition: { + x: + metrics.scrollWidth <= metrics.clientWidth + ? 0 + : metrics.scrollLeft / (metrics.scrollWidth - metrics.clientWidth), + y: + metrics.scrollHeight <= metrics.clientHeight + ? 0 + : metrics.scrollTop / (metrics.scrollHeight - metrics.clientHeight), + }, + }, + }, + }, + }; + } + + case SET_VIEWPORT_SCROLL_METRICS: { + const { documentId, scrollMetrics } = action.payload; + const viewport = state.documents[documentId]; + if (!viewport) return state; + return { ...state, - viewportMetrics: { - width: action.payload.width, - height: action.payload.height, - scrollTop: action.payload.scrollTop, - scrollLeft: action.payload.scrollLeft, - clientWidth: action.payload.clientWidth, - clientHeight: action.payload.clientHeight, - scrollWidth: action.payload.scrollWidth, - scrollHeight: action.payload.scrollHeight, - relativePosition: { - x: - action.payload.scrollWidth <= action.payload.clientWidth - ? 0 - : action.payload.scrollLeft / - (action.payload.scrollWidth - action.payload.clientWidth), - y: - action.payload.scrollHeight <= action.payload.clientHeight - ? 0 - : action.payload.scrollTop / - (action.payload.scrollHeight - action.payload.clientHeight), + documents: { + ...state.documents, + [documentId]: { + ...viewport, + viewportMetrics: { + ...viewport.viewportMetrics, + scrollTop: scrollMetrics.scrollTop, + scrollLeft: scrollMetrics.scrollLeft, + }, + isScrolling: true, }, }, }; - case SET_VIEWPORT_SCROLL_METRICS: + } + + case SET_SCROLL_ACTIVITY: { + const { documentId, isScrolling } = action.payload; + const viewport = state.documents[documentId]; + if (!viewport) return state; + return { ...state, - viewportMetrics: { - ...state.viewportMetrics, - scrollTop: action.payload.scrollTop, - scrollLeft: action.payload.scrollLeft, + documents: { + ...state.documents, + [documentId]: { + ...viewport, + isScrolling, + }, }, - isScrolling: true, }; - case SET_SCROLL_ACTIVITY: - return { ...state, isScrolling: action.payload }; - case SET_SMOOTH_SCROLL_ACTIVITY: - return { ...state, isSmoothScrolling: action.payload }; + } + + case SET_SMOOTH_SCROLL_ACTIVITY: { + const { documentId, isSmoothScrolling } = action.payload; + const viewport = state.documents[documentId]; + if (!viewport) return state; + + return { + ...state, + documents: { + ...state.documents, + [documentId]: { + ...viewport, + isSmoothScrolling, + }, + }, + }; + } + default: return state; } diff --git a/packages/plugin-viewport/src/lib/types.ts b/packages/plugin-viewport/src/lib/types.ts index 1ef97b8e0..0bafe1efa 100644 --- a/packages/plugin-viewport/src/lib/types.ts +++ b/packages/plugin-viewport/src/lib/types.ts @@ -1,8 +1,16 @@ -import { BasePluginConfig, EventControlOptions, EventHook } from '@embedpdf/core'; +import { BasePluginConfig, EventHook } from '@embedpdf/core'; import { Rect } from '@embedpdf/models'; export interface ViewportState { viewportGap: number; + // Per-document viewport state (persisted) + documents: Record; + // Currently active/mounted viewports (temporary) + activeViewports: Set; // documentIds that have mounted viewports + activeDocumentId: string | null; +} + +export interface ViewportDocumentState { viewportMetrics: ViewportMetrics; isScrolling: boolean; isSmoothScrolling: boolean; @@ -36,11 +44,6 @@ export interface ViewportScrollMetrics { scrollLeft: number; } -export interface ScrollControlOptions { - mode: 'debounce' | 'throttle'; - wait: number; -} - export interface ScrollToPayload { x: number; y: number; @@ -48,23 +51,59 @@ export interface ScrollToPayload { center?: boolean; } -// New scroll activity type export interface ScrollActivity { - /** Whether a smooth scroll animation is in progress */ isSmoothScrolling: boolean; - /** Whether any scrolling activity is happening */ isScrolling: boolean; } -export interface ViewportCapability { - getViewportGap: () => number; - getMetrics: () => ViewportMetrics; +// Events include documentId +export interface ViewportEvent { + documentId: string; + metrics: ViewportMetrics; +} + +export interface ScrollActivityEvent { + documentId: string; + activity: ScrollActivity; +} + +export interface ScrollChangeEvent { + documentId: string; + scrollMetrics: ViewportScrollMetrics; +} + +// Scoped viewport capability +export interface ViewportScope { + getMetrics(): ViewportMetrics; scrollTo(position: ScrollToPayload): void; + isScrolling(): boolean; + isSmoothScrolling(): boolean; + getBoundingRect(): Rect; onViewportChange: EventHook; - onViewportResize: EventHook; onScrollChange: EventHook; onScrollActivity: EventHook; - isScrolling: () => boolean; - isSmoothScrolling: () => boolean; +} + +export interface ViewportCapability { + // Global + getViewportGap(): number; + + // Active document operations + getMetrics(): ViewportMetrics; + scrollTo(position: ScrollToPayload): void; + isScrolling(): boolean; + isSmoothScrolling(): boolean; getBoundingRect(): Rect; + + // Document-scoped operations + forDocument(documentId: string): ViewportScope; + + // Check if viewport is mounted + isViewportMounted(documentId: string): boolean; + + // Events + onViewportChange: EventHook; + onViewportResize: EventHook; + onScrollChange: EventHook; + onScrollActivity: EventHook; } diff --git a/packages/plugin-viewport/src/lib/viewport-plugin.ts b/packages/plugin-viewport/src/lib/viewport-plugin.ts index 812b9f590..e41666f27 100644 --- a/packages/plugin-viewport/src/lib/viewport-plugin.ts +++ b/packages/plugin-viewport/src/lib/viewport-plugin.ts @@ -1,3 +1,5 @@ +// packages/plugin-viewport/src/lib/viewport-plugin.ts + import { BasePlugin, PluginRegistry, @@ -5,9 +7,14 @@ import { createBehaviorEmitter, Listener, } from '@embedpdf/core'; +import { Rect } from '@embedpdf/models'; import { ViewportAction, + initViewportState, + cleanupViewportState, + registerViewport, + unregisterViewport, setViewportMetrics, setViewportScrollMetrics, setViewportGap, @@ -18,13 +25,16 @@ import { ViewportPluginConfig, ViewportState, ViewportCapability, + ViewportScope, ViewportMetrics, ViewportScrollMetrics, ViewportInputMetrics, ScrollToPayload, ScrollActivity, + ViewportEvent, + ScrollActivityEvent, + ScrollChangeEvent, } from './types'; -import { Rect } from '@embedpdf/models'; export class ViewportPlugin extends BasePlugin< ViewportPluginConfig, @@ -34,20 +44,19 @@ export class ViewportPlugin extends BasePlugin< > { static readonly id = 'viewport' as const; - private readonly viewportResize$ = createBehaviorEmitter(); - private readonly viewportMetrics$ = createBehaviorEmitter(); - private readonly scrollMetrics$ = createBehaviorEmitter(); - private readonly scrollReq$ = createEmitter<{ - x: number; - y: number; - behavior?: ScrollBehavior; - }>(); - private readonly scrollActivity$ = createBehaviorEmitter(); - - /* ------------------------------------------------------------------ */ - /* “live rect” infrastructure */ - /* ------------------------------------------------------------------ */ - private rectProvider: (() => Rect) | null = null; + private readonly viewportResize$ = createBehaviorEmitter(); + private readonly viewportMetrics$ = createBehaviorEmitter(); + private readonly scrollMetrics$ = createBehaviorEmitter(); + private readonly scrollActivity$ = createBehaviorEmitter(); + + // Scroll request emitters per document (persisted with state) + private readonly scrollRequests$ = new Map< + string, + ReturnType> + >(); + + // Rect providers per document (only for mounted viewports) + private rectProviders = new Map Rect>(); private readonly scrollEndDelay: number; @@ -65,120 +74,317 @@ export class ViewportPlugin extends BasePlugin< this.scrollEndDelay = config.scrollEndDelay || 100; } + // ───────────────────────────────────────────────────────── + // Document Lifecycle (from BasePlugin) + // ───────────────────────────────────────────────────────── + + protected override onDocumentLoadingStarted(documentId: string): void { + // Initialize viewport state for this document + this.dispatch(initViewportState(documentId)); + + // Create scroll request emitter + this.scrollRequests$.set(documentId, createEmitter()); + + this.logger.debug( + 'ViewportPlugin', + 'DocumentOpened', + `Initialized viewport state for document: ${documentId}`, + ); + } + + protected override onDocumentClosed(documentId: string): void { + // Cleanup viewport state + this.dispatch(cleanupViewportState(documentId)); + + // Cleanup scroll request emitter + this.scrollRequests$.get(documentId)?.clear(); + this.scrollRequests$.delete(documentId); + + // Cleanup rect provider if exists + this.rectProviders.delete(documentId); + + this.logger.debug( + 'ViewportPlugin', + 'DocumentClosed', + `Cleaned up viewport state for document: ${documentId}`, + ); + } + + // ───────────────────────────────────────────────────────── + // Capability + // ───────────────────────────────────────────────────────── + protected buildCapability(): ViewportCapability { return { + // Global getViewportGap: () => this.state.viewportGap, - getMetrics: () => this.state.viewportMetrics, - getBoundingRect: (): Rect => - this.rectProvider?.() ?? { - origin: { x: 0, y: 0 }, - size: { width: 0, height: 0 }, - }, + + // Active document operations + getMetrics: () => this.getMetrics(), scrollTo: (pos: ScrollToPayload) => this.scrollTo(pos), - isScrolling: () => this.state.isScrolling, - isSmoothScrolling: () => this.state.isSmoothScrolling, - onScrollChange: this.scrollMetrics$.on, + isScrolling: () => this.isScrolling(), + isSmoothScrolling: () => this.isSmoothScrolling(), + getBoundingRect: () => this.getBoundingRect(), + + // Document-scoped operations + forDocument: (documentId: string) => this.createViewportScope(documentId), + + // Check if viewport is currently mounted + isViewportMounted: (documentId: string) => this.state.activeViewports.has(documentId), + + // Events onViewportChange: this.viewportMetrics$.on, onViewportResize: this.viewportResize$.on, + onScrollChange: this.scrollMetrics$.on, onScrollActivity: this.scrollActivity$.on, }; } - public setViewportResizeMetrics(viewportMetrics: ViewportInputMetrics) { - this.dispatch(setViewportMetrics(viewportMetrics)); - this.viewportResize$.emit(this.state.viewportMetrics); + // ───────────────────────────────────────────────────────── + // Document Scoping + // ───────────────────────────────────────────────────────── + + private createViewportScope(documentId: string): ViewportScope { + return { + getMetrics: () => this.getMetrics(documentId), + scrollTo: (pos: ScrollToPayload) => this.scrollTo(pos, documentId), + isScrolling: () => this.isScrolling(documentId), + isSmoothScrolling: () => this.isSmoothScrolling(documentId), + getBoundingRect: () => this.getBoundingRect(documentId), + onViewportChange: (listener: Listener) => + this.viewportMetrics$.on((event) => { + if (event.documentId === documentId) listener(event.metrics); + }), + onScrollChange: (listener: Listener) => + this.scrollMetrics$.on((event) => { + if (event.documentId === documentId) listener(event.scrollMetrics); + }), + onScrollActivity: (listener: Listener) => + this.scrollActivity$.on((event) => { + if (event.documentId === documentId) listener(event.activity); + }), + }; + } + + // ───────────────────────────────────────────────────────── + // Viewport Registration (Public API for components) + // ───────────────────────────────────────────────────────── + + public registerViewport(documentId: string): void { + // Check if state exists (document must be opened first) + if (!this.state.documents[documentId]) { + throw new Error( + `Cannot register viewport for ${documentId}: document state not found. ` + + `Document must be opened before registering viewport.`, + ); + } + + // Mark as active/mounted + if (!this.state.activeViewports.has(documentId)) { + this.dispatch(registerViewport(documentId)); + + this.logger.debug( + 'ViewportPlugin', + 'RegisterViewport', + `Registered viewport (DOM mounted) for document: ${documentId}`, + ); + } + } + + public unregisterViewport(documentId: string): void { + // Mark as inactive/unmounted (but preserve state!) + if (this.state.activeViewports.has(documentId)) { + this.dispatch(unregisterViewport(documentId)); + + // Remove rect provider (DOM no longer exists) + this.rectProviders.delete(documentId); + + this.logger.debug( + 'ViewportPlugin', + 'UnregisterViewport', + `Unregistered viewport (DOM unmounted) for document: ${documentId}. State preserved.`, + ); + } + } + + // ───────────────────────────────────────────────────────── + // Per-Document Operations + // ───────────────────────────────────────────────────────── + + public setViewportResizeMetrics(documentId: string, metrics: ViewportInputMetrics): void { + this.dispatch(setViewportMetrics(documentId, metrics)); + + const viewport = this.state.documents[documentId]; + if (viewport) { + this.viewportResize$.emit({ + documentId, + metrics: viewport.viewportMetrics, + }); + } } - public setViewportScrollMetrics(scrollMetrics: ViewportScrollMetrics) { + public setViewportScrollMetrics(documentId: string, scrollMetrics: ViewportScrollMetrics): void { + const viewport = this.state.documents[documentId]; + if (!viewport) return; + if ( - scrollMetrics.scrollTop !== this.state.viewportMetrics.scrollTop || - scrollMetrics.scrollLeft !== this.state.viewportMetrics.scrollLeft + scrollMetrics.scrollTop !== viewport.viewportMetrics.scrollTop || + scrollMetrics.scrollLeft !== viewport.viewportMetrics.scrollLeft ) { - this.dispatch(setViewportScrollMetrics(scrollMetrics)); - this.bumpScrollActivity(); + this.dispatch(setViewportScrollMetrics(documentId, scrollMetrics)); + this.bumpScrollActivity(documentId); + this.scrollMetrics$.emit({ - scrollTop: scrollMetrics.scrollTop, - scrollLeft: scrollMetrics.scrollLeft, + documentId, + scrollMetrics, }); } } - public onScrollRequest(listener: Listener) { - return this.scrollReq$.on(listener); + public onScrollRequest(documentId: string, listener: Listener) { + const emitter = this.scrollRequests$.get(documentId); + if (!emitter) { + throw new Error( + `Cannot subscribe to scroll requests for ${documentId}: ` + + `document state not initialized`, + ); + } + return emitter.on(listener); + } + + public registerBoundingRectProvider(documentId: string, provider: (() => Rect) | null): void { + if (provider) { + this.rectProviders.set(documentId, provider); + } else { + this.rectProviders.delete(documentId); + } + } + + // ───────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────── + + private getActiveDocumentId(): string { + const id = this.state.activeDocumentId ?? this.coreState.core.activeDocumentId; + if (!id) throw new Error('No active document'); + return id; + } + + private getViewportState(documentId?: string) { + const id = documentId ?? this.getActiveDocumentId(); + const viewport = this.state.documents[id]; + if (!viewport) { + throw new Error(`Viewport state not found for document: ${id}`); + } + return viewport; + } + + private getMetrics(documentId?: string): ViewportMetrics { + return this.getViewportState(documentId).viewportMetrics; } - public registerBoundingRectProvider(provider: (() => Rect) | null) { - this.rectProvider = provider; + private isScrolling(documentId?: string): boolean { + return this.getViewportState(documentId).isScrolling; } - private bumpScrollActivity() { - this.debouncedDispatch(setScrollActivity(false), this.scrollEndDelay); - this.debouncedDispatch(setSmoothScrollActivity(false), this.scrollEndDelay); + private isSmoothScrolling(documentId?: string): boolean { + return this.getViewportState(documentId).isSmoothScrolling; } - private scrollTo(pos: ScrollToPayload) { + private getBoundingRect(documentId?: string): Rect { + const id = documentId ?? this.getActiveDocumentId(); + const provider = this.rectProviders.get(id); + + return ( + provider?.() ?? { + origin: { x: 0, y: 0 }, + size: { width: 0, height: 0 }, + } + ); + } + + private scrollTo(pos: ScrollToPayload, documentId?: string): void { + const id = documentId ?? this.getActiveDocumentId(); + const viewport = this.getViewportState(id); const { x, y, center, behavior = 'auto' } = pos; if (behavior === 'smooth') { - this.dispatch(setSmoothScrollActivity(true)); + this.dispatch(setSmoothScrollActivity(id, true)); } + let finalX = x; + let finalY = y; + if (center) { - const metrics = this.state.viewportMetrics; - // Calculate the centered position by adding half the viewport dimensions - const centeredX = x - metrics.clientWidth / 2; - const centeredY = y - metrics.clientHeight / 2; - - this.scrollReq$.emit({ - x: centeredX, - y: centeredY, - behavior, - }); - } else { - this.scrollReq$.emit({ - x, - y, - behavior, - }); + const metrics = viewport.viewportMetrics; + finalX = x - metrics.clientWidth / 2; + finalY = y - metrics.clientHeight / 2; } - } - private emitScrollActivity() { - const scrollActivity: ScrollActivity = { - isSmoothScrolling: this.state.isSmoothScrolling, - isScrolling: this.state.isScrolling, - }; + const emitter = this.scrollRequests$.get(id); + if (emitter) { + emitter.emit({ x: finalX, y: finalY, behavior }); + } + } - this.scrollActivity$.emit(scrollActivity); + private bumpScrollActivity(documentId: string): void { + this.debouncedDispatch(setScrollActivity(documentId, false), this.scrollEndDelay); + this.debouncedDispatch(setSmoothScrollActivity(documentId, false), this.scrollEndDelay); } - // Subscribe to store changes to notify onViewportChange + // ───────────────────────────────────────────────────────── + // State Change Handling + // ───────────────────────────────────────────────────────── + override onStoreUpdated(prevState: ViewportState, newState: ViewportState): void { - if (prevState !== newState) { - this.viewportMetrics$.emit(newState.viewportMetrics); - - // Emit scroll activity when scrolling state changes - if ( - prevState.isScrolling !== newState.isScrolling || - prevState.isSmoothScrolling !== newState.isSmoothScrolling - ) { - this.emitScrollActivity(); + // Emit viewport change events for each changed document + for (const documentId in newState.documents) { + const prevViewport = prevState.documents[documentId]; + const newViewport = newState.documents[documentId]; + + if (prevViewport !== newViewport) { + this.viewportMetrics$.emit({ + documentId, + metrics: newViewport.viewportMetrics, + }); + + // Emit scroll activity when scrolling state changes + if ( + prevViewport && + (prevViewport.isScrolling !== newViewport.isScrolling || + prevViewport.isSmoothScrolling !== newViewport.isSmoothScrolling) + ) { + this.scrollActivity$.emit({ + documentId, + activity: { + isScrolling: newViewport.isScrolling, + isSmoothScrolling: newViewport.isSmoothScrolling, + }, + }); + } } } } + // ───────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────── + async initialize(_config: ViewportPluginConfig) { - // No initialization needed + this.logger.info('ViewportPlugin', 'Initialize', 'Viewport plugin initialized'); } async destroy(): Promise { - super.destroy(); - // Clear out any handlers + // Clear all emitters this.viewportMetrics$.clear(); this.viewportResize$.clear(); this.scrollMetrics$.clear(); - this.scrollReq$.clear(); this.scrollActivity$.clear(); - this.rectProvider = null; + + this.scrollRequests$.forEach((emitter) => emitter.clear()); + this.scrollRequests$.clear(); + this.rectProviders.clear(); + + super.destroy(); } } diff --git a/packages/plugin-viewport/src/shared/components/viewport.tsx b/packages/plugin-viewport/src/shared/components/viewport.tsx index 9599a0cf3..c8bb7470d 100644 --- a/packages/plugin-viewport/src/shared/components/viewport.tsx +++ b/packages/plugin-viewport/src/shared/components/viewport.tsx @@ -1,15 +1,18 @@ import { ReactNode, useEffect, useState, HTMLAttributes } from '@framework'; - import { useViewportCapability } from '../hooks'; import { useViewportRef } from '../hooks/use-viewport-ref'; type ViewportProps = HTMLAttributes & { children: ReactNode; + /** + * The ID of the document that this viewport displays + */ + documentId: string; }; -export function Viewport({ children, ...props }: ViewportProps) { +export function Viewport({ children, documentId, ...props }: ViewportProps) { const [viewportGap, setViewportGap] = useState(0); - const viewportRef = useViewportRef(); + const viewportRef = useViewportRef(documentId); const { provides: viewportProvides } = useViewportCapability(); useEffect(() => { diff --git a/packages/plugin-viewport/src/shared/hooks/use-viewport-ref.ts b/packages/plugin-viewport/src/shared/hooks/use-viewport-ref.ts index 4de4e5381..e64b76ce2 100644 --- a/packages/plugin-viewport/src/shared/hooks/use-viewport-ref.ts +++ b/packages/plugin-viewport/src/shared/hooks/use-viewport-ref.ts @@ -1,9 +1,8 @@ import { Rect } from '@embedpdf/models'; import { useLayoutEffect, useRef } from '@framework'; - import { useViewportPlugin } from './use-viewport'; -export function useViewportRef() { +export function useViewportRef(documentId: string) { const { plugin: viewportPlugin } = useViewportPlugin(); const containerRef = useRef(null); @@ -13,7 +12,15 @@ export function useViewportRef() { const container = containerRef.current; if (!container) return; - /* ---------- live rect provider --------------------------------- */ + // Register this viewport for the document + try { + viewportPlugin.registerViewport(documentId); + } catch (error) { + console.error(`Failed to register viewport for document ${documentId}:`, error); + return; + } + + // Provide rect calculator const provideRect = (): Rect => { const r = container.getBoundingClientRect(); return { @@ -21,20 +28,30 @@ export function useViewportRef() { size: { width: r.width, height: r.height }, }; }; - viewportPlugin.registerBoundingRectProvider(provideRect); + viewportPlugin.registerBoundingRectProvider(documentId, provideRect); + + // Get saved viewport state and restore scroll position + const savedMetrics = viewportPlugin.provides().forDocument(documentId).getMetrics(); + if (savedMetrics && (savedMetrics.scrollTop > 0 || savedMetrics.scrollLeft > 0)) { + // Restore scroll position on next frame + requestAnimationFrame(() => { + container.scrollTop = savedMetrics.scrollTop; + container.scrollLeft = savedMetrics.scrollLeft; + }); + } - // Example: On scroll, call setMetrics + // On scroll const onScroll = () => { - viewportPlugin.setViewportScrollMetrics({ + viewportPlugin.setViewportScrollMetrics(documentId, { scrollTop: container.scrollTop, scrollLeft: container.scrollLeft, }); }; container.addEventListener('scroll', onScroll); - // Example: On resize, call setMetrics + // On resize const resizeObserver = new ResizeObserver(() => { - viewportPlugin.setViewportResizeMetrics({ + viewportPlugin.setViewportResizeMetrics(documentId, { width: container.offsetWidth, height: container.offsetHeight, clientWidth: container.clientWidth, @@ -47,7 +64,9 @@ export function useViewportRef() { }); resizeObserver.observe(container); + // Subscribe to scroll requests for this document const unsubscribeScrollRequest = viewportPlugin.onScrollRequest( + documentId, ({ x, y, behavior = 'auto' }) => { requestAnimationFrame(() => { container.scrollTo({ left: x, top: y, behavior }); @@ -57,13 +76,13 @@ export function useViewportRef() { // Cleanup return () => { - viewportPlugin.registerBoundingRectProvider(null); + viewportPlugin.unregisterViewport(documentId); + viewportPlugin.registerBoundingRectProvider(documentId, null); container.removeEventListener('scroll', onScroll); resizeObserver.disconnect(); unsubscribeScrollRequest(); }; - }, [viewportPlugin]); + }, [viewportPlugin, documentId]); - // Return the ref so your React code can attach it to a div return containerRef; } diff --git a/packages/plugin-viewport/src/shared/hooks/use-viewport.ts b/packages/plugin-viewport/src/shared/hooks/use-viewport.ts index 59a8a734d..9889cfb7c 100644 --- a/packages/plugin-viewport/src/shared/hooks/use-viewport.ts +++ b/packages/plugin-viewport/src/shared/hooks/use-viewport.ts @@ -5,7 +5,11 @@ import { ScrollActivity, ViewportPlugin } from '@embedpdf/plugin-viewport'; export const useViewportPlugin = () => usePlugin(ViewportPlugin.id); export const useViewportCapability = () => useCapability(ViewportPlugin.id); -export const useViewportScrollActivity = () => { +/** + * Hook to get scroll activity for a specific document + * @param documentId Optional document ID. If not provided, uses active document. + */ +export const useViewportScrollActivity = (documentId?: string) => { const { provides } = useViewportCapability(); const [scrollActivity, setScrollActivity] = useState({ isScrolling: false, @@ -15,8 +19,14 @@ export const useViewportScrollActivity = () => { useEffect(() => { if (!provides) return; - return provides.onScrollActivity(setScrollActivity); - }, [provides]); + // Subscribe to scroll activity events + return provides.onScrollActivity((event) => { + // Filter by documentId if provided + if (!documentId || event.documentId === documentId) { + setScrollActivity(event.activity); + } + }); + }, [provides, documentId]); return scrollActivity; }; diff --git a/packages/plugin-viewport/src/vue/components/viewport.vue b/packages/plugin-viewport/src/vue/components/viewport.vue index 08e84afa8..c0b659fca 100644 --- a/packages/plugin-viewport/src/vue/components/viewport.vue +++ b/packages/plugin-viewport/src/vue/components/viewport.vue @@ -1,12 +1,19 @@ - - + + @@ -23,6 +23,7 @@ import AnnotationPaintLayer from './annotation-paint-layer.vue'; import { ResizeHandleUI, VertexHandleUI } from '../types'; const props = defineProps<{ + documentId: string; pageIndex: number; scale: number; pageWidth: number; diff --git a/packages/plugin-annotation/src/vue/components/annotation-paint-layer.vue b/packages/plugin-annotation/src/vue/components/annotation-paint-layer.vue index 37a8a4b45..89a106316 100644 --- a/packages/plugin-annotation/src/vue/components/annotation-paint-layer.vue +++ b/packages/plugin-annotation/src/vue/components/annotation-paint-layer.vue @@ -16,6 +16,7 @@ import { AnyPreviewState, HandlerServices } from '@embedpdf/plugin-annotation'; import PreviewRenderer from './preview-renderer.vue'; const props = defineProps<{ + documentId: string; pageIndex: number; scale: number; }>(); @@ -72,18 +73,23 @@ let unregister: (() => void) | undefined; watchEffect((onCleanup) => { if (annotationPlugin.value) { - unregister = annotationPlugin.value.registerPageHandlers(props.pageIndex, props.scale, { - services: services.value, - onPreview: (toolId, state) => { - const next = new Map(previews.value); - if (state) { - next.set(toolId, state); - } else { - next.delete(toolId); - } - previews.value = next; + unregister = annotationPlugin.value.registerPageHandlers( + props.documentId, + props.pageIndex, + props.scale, + { + services: services.value, + onPreview: (toolId, state) => { + const next = new Map(previews.value); + if (state) { + next.set(toolId, state); + } else { + next.delete(toolId); + } + previews.value = next; + }, }, - }); + ); } onCleanup(() => { diff --git a/packages/plugin-annotation/src/vue/components/annotations.vue b/packages/plugin-annotation/src/vue/components/annotations.vue index 03445a865..9909716c3 100644 --- a/packages/plugin-annotation/src/vue/components/annotations.vue +++ b/packages/plugin-annotation/src/vue/components/annotations.vue @@ -366,7 +366,7 @@ - let { +
+
[`style:${k}`, v])) : {}} + class={propsClass} + {...restProps} + > + {#if customAnnotationRenderer} + {@render customAnnotationRenderer?.({ + annotation: currentObject, + children, + isSelected, scale, - pageIndex, rotation, pageWidth, pageHeight, - trackedAnnotation, - children, - isSelected, - isDraggable, - isResizable, - lockAspectRatio = false, - style, - class: propsClass = '', - vertexConfig, - selectionMenu, - outlineOffset = 1, - onDoubleClick, + pageIndex, onSelect, - zIndex = 20, - resizeUI, - vertexUI, - selectionOutlineColor = '#007ACC', - customAnnotationRenderer, - ...restProps - }: AnnotationContainerProps = $props(); - - let preview = $state(trackedAnnotation.object); - let annotationCapability = useAnnotationCapability(); - let gestureBaseRef = $state(null); - - let currentObject = $derived(preview ? { ...trackedAnnotation.object, ...preview } : trackedAnnotation.object); - - // Defaults retain current behavior - const HANDLE_COLOR = $derived(resizeUI?.color ?? '#007ACC'); - const VERTEX_COLOR = $derived(vertexUI?.color ?? '#007ACC'); - const HANDLE_SIZE = $derived(resizeUI?.size ?? 12); - const VERTEX_SIZE = $derived(vertexUI?.size ?? 12); - - const interactionHandles = useInteractionHandles({ - controller: { - element: currentObject.rect, - vertices: vertexConfig?.extractVertices(currentObject), - constraints: { - minWidth: 10, - minHeight: 10, - boundingBox: { width: pageWidth / scale, height: pageHeight / scale }, - }, - maintainAspectRatio: lockAspectRatio, - pageRotation: rotation, - scale: scale, - enabled: isSelected, - onUpdate: (event) => { - if (!event.transformData?.type) return; - - if (event.state === 'start') { - gestureBaseRef = currentObject; - } - - const transformType = event.transformData.type; - const base = gestureBaseRef ?? currentObject; - - const changes = event.transformData.changes.vertices - ? vertexConfig?.transformAnnotation(base, event.transformData.changes.vertices) - : { rect: event.transformData.changes.rect }; - - - const patched = annotationCapability.provides?.transformAnnotation(base, { - type: transformType, - changes: changes as Partial, - metadata: event.transformData.metadata, - }); - - if (patched) { - preview = { - ...preview, - ...patched, - }; - } - if (event.state === 'end' && patched) { - gestureBaseRef = null; - // Sanitize to remove Svelte reactive properties before updating - // Use deepToRaw to recursively strip proxies while preserving complex objects - const sanitized = deepToRaw(patched); - annotationCapability.provides?.updateAnnotation(pageIndex, trackedAnnotation.object.id, sanitized); - } - - }, - }, - resizeUI: { - handleSize: HANDLE_SIZE, - spacing: outlineOffset, - offsetMode: 'outside', - includeSides: lockAspectRatio ? false : true, - zIndex: zIndex + 1, - }, - vertexUI: { - vertexSize: VERTEX_SIZE, - zIndex: zIndex + 2, - }, - includeVertices: vertexConfig ? true : false, - }); - - // Derived accessors for template - const resizeHandles = $derived(interactionHandles.resize); - const vertexHandles = $derived(interactionHandles.vertices); - - -
-
[`style:${k}`, v])) : {}} - class={propsClass} - {...restProps} - > - - {#if customAnnotationRenderer} - {@render customAnnotationRenderer?.({ - annotation: currentObject, - children, - isSelected, - scale, - rotation, - pageWidth, - pageHeight, - pageIndex, - onSelect, - })} + })} + {:else} + {@render children(currentObject)} + {/if} + + {#if isSelected && isResizable} + {#each resizeHandles as { key, ...hProps } (key)} + {#if resizeUI?.component} + {@const Component = resizeUI.component} + {:else} - {@render children(currentObject)} +
{/if} - - {#if isSelected && isResizable} - {#each resizeHandles as { key, ...hProps } (key)} - {#if resizeUI?.component} - {@const Component = resizeUI.component} - - {:else} -
- {/if} - {/each} - {/if} - - {#if isSelected} - {#each vertexHandles as { key, ...vProps } (key)} - {#if vertexUI?.component} - {@const Component = vertexUI.component} - - {:else} -
- {/if} - {/each} + {/each} + {/if} + + {#if isSelected} + {#each vertexHandles as { key, ...vProps } (key)} + {#if vertexUI?.component} + {@const Component = vertexUI.component} + + {:else} +
{/if} -
- - - {#snippet children({ rect, menuWrapperProps })} - {#if selectionMenu} - {@render selectionMenu({ - annotation: trackedAnnotation, - selected: isSelected, - rect, - menuWrapperProps, - })} - {/if} - {/snippet} - -
\ No newline at end of file + {/each} + {/if} +
+ + + {#snippet children({ rect, menuWrapperProps })} + {#if selectionMenu} + {@render selectionMenu({ + annotation: trackedAnnotation, + selected: isSelected, + rect, + menuWrapperProps, + })} + {/if} + {/snippet} + +
From ae0537fe17d787fae1b3a98876bff2805cd66b72 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 09:55:38 -0700 Subject: [PATCH 049/225] drag working --- .../components/AnnotationContainer.svelte | 92 ++++++++++--------- .../svelte/hooks/use-drag-resize.svelte.ts | 16 +++- .../hooks/use-interaction-handles.svelte.ts | 23 +++-- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte index 98d7ece1b..9fd2965be 100644 --- a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte +++ b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte @@ -89,58 +89,64 @@ } }); - const interactionHandles = useInteractionHandles({ - controller: { + const controller = $derived({ element: currentObject.rect, vertices: vertexConfig?.extractVertices(currentObject), constraints: { - minWidth: 10, - minHeight: 10, - boundingBox: { width: pageWidth / scale, height: pageHeight / scale }, + minWidth: 10, + minHeight: 10, + boundingBox: { width: pageWidth / scale, height: pageHeight / scale }, }, maintainAspectRatio: lockAspectRatio, pageRotation: rotation, scale: scale, enabled: isSelected, onUpdate: (event) => { - if (!event.transformData?.type) return; - - if (event.state === 'start') { - gestureBaseRef = currentObject; - } - - const transformType = event.transformData.type; - const base = gestureBaseRef ?? currentObject; - - const changes = event.transformData.changes.vertices - ? vertexConfig?.transformAnnotation(base, event.transformData.changes.vertices) - : { rect: event.transformData.changes.rect }; - - const patched = annotationCapability.provides?.transformAnnotation(base, { - type: transformType, - changes: changes as Partial, - metadata: event.transformData.metadata, - }); - - if (patched) { - preview = { - ...preview, - ...patched, - }; - } - if (event.state === 'end' && patched) { - gestureBaseRef = null; - // Sanitize to remove Svelte reactive properties before updating - // Use deepToRaw to recursively strip proxies while preserving complex objects - const sanitized = deepToRaw(patched); - annotationCapability.provides?.updateAnnotation( - pageIndex, - trackedAnnotation.object.id, - sanitized, - ); - } + if (!event.transformData?.type) return; + + if (event.state === 'start') { + gestureBaseRef = currentObject; + } + + const transformType = event.transformData.type; + const base = gestureBaseRef ?? currentObject; + + const changes = event.transformData.changes.vertices + ? vertexConfig?.transformAnnotation(base, event.transformData.changes.vertices) + : { rect: event.transformData.changes.rect }; + + const patched = annotationCapability.provides?.transformAnnotation(base, { + type: transformType, + changes: changes as Partial, + metadata: event.transformData.metadata, + }); + + if (patched) { + preview = { + ...preview, + ...patched, + }; + } + if (event.state === 'end' && patched) { + gestureBaseRef = null; + // Sanitize to remove Svelte reactive properties before updating + // Use deepToRaw to recursively strip proxies while preserving complex objects + const sanitized = deepToRaw(patched); + annotationCapability.provides?.updateAnnotation( + pageIndex, + trackedAnnotation.object.id, + sanitized, + ); + } }, - }, + }) + + + +// CONFIRMED - controller is reactive and updating here + + const interactionHandles = useInteractionHandles(() => ({ + controller, resizeUI: { handleSize: HANDLE_SIZE, spacing: outlineOffset, @@ -153,7 +159,7 @@ zIndex: zIndex + 2, }, includeVertices: vertexConfig ? true : false, - }); + })); // Derived accessors for template const resizeHandles = $derived(interactionHandles.resize); diff --git a/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts b/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts index b61b17eed..0a4287c8b 100644 --- a/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts +++ b/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts @@ -17,11 +17,23 @@ export interface ResizeHandleEventProps { onpointercancel: (e: PointerEvent) => void; } -export function useDragResize(options: UseDragResizeOptions) { - const { onUpdate, enabled = true, ...config } = options; +export function useDragResize(getOptions: () => UseDragResizeOptions) { + // Use getter function to maintain reactivity + const config = $derived.by(() => { + const opts = getOptions(); + const { onUpdate, enabled, ...rest } = opts; + return rest; + }); + + const enabled = $derived(getOptions().enabled ?? true); + const onUpdate = $derived(getOptions().onUpdate); let controller = $state(null); + $effect(() => { + console.log('config in use drag resize', config); + }); + // Initialize or update controller $effect(() => { if (!controller) { diff --git a/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts b/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts index 2990e54ff..b9afaaba2 100644 --- a/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts +++ b/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts @@ -15,7 +15,7 @@ export type HandleElementProps = { onpointercancel: (e: PointerEvent) => void; } & Record; -export function useInteractionHandles(opts: { +export function useInteractionHandles(getOpts: () => { controller: UseDragResizeOptions; resizeUI?: ResizeUI; vertexUI?: VertexUI; @@ -25,16 +25,19 @@ export function useInteractionHandles(opts: { ) => Record | void; vertexAttrs?: (i: number) => Record | void; }) { - const { - controller, - resizeUI, - vertexUI, - includeVertices = false, - handleAttrs, - vertexAttrs, - } = opts; + // Use getter function and $derived to maintain reactivity + const controller = $derived(getOpts().controller); + const resizeUI = $derived(getOpts().resizeUI); + const vertexUI = $derived(getOpts().vertexUI); + const includeVertices = $derived(getOpts().includeVertices ?? false); + const handleAttrs = $derived(getOpts().handleAttrs); + const vertexAttrs = $derived(getOpts().vertexAttrs); - const dragResize = useDragResize(controller); + const dragResize = useDragResize(() => controller); + + $effect(() => { + console.log('controller in use interaction handles', controller); + }); // Resize handles: computed from controller config const resize = $derived.by((): HandleElementProps[] => { From 90e3ff4004dfd0c3666b62c74a78a79217f7d939 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 09:57:20 -0700 Subject: [PATCH 050/225] remove console logs --- .../src/svelte/components/AnnotationContainer.svelte | 3 --- packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts | 4 ---- .../utils/src/svelte/hooks/use-interaction-handles.svelte.ts | 4 +--- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte index 9fd2965be..8d6c2b35a 100644 --- a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte +++ b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte @@ -142,9 +142,6 @@ }) - -// CONFIRMED - controller is reactive and updating here - const interactionHandles = useInteractionHandles(() => ({ controller, resizeUI: { diff --git a/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts b/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts index 0a4287c8b..1237020e3 100644 --- a/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts +++ b/packages/utils/src/svelte/hooks/use-drag-resize.svelte.ts @@ -30,10 +30,6 @@ export function useDragResize(getOptions: () => UseDragResizeOptions) { let controller = $state(null); - $effect(() => { - console.log('config in use drag resize', config); - }); - // Initialize or update controller $effect(() => { if (!controller) { diff --git a/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts b/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts index b9afaaba2..59d20a545 100644 --- a/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts +++ b/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts @@ -35,9 +35,6 @@ export function useInteractionHandles(getOpts: () => { const dragResize = useDragResize(() => controller); - $effect(() => { - console.log('controller in use interaction handles', controller); - }); // Resize handles: computed from controller config const resize = $derived.by((): HandleElementProps[] => { @@ -64,6 +61,7 @@ export function useInteractionHandles(getOpts: () => { })); }); + // Use getter function to maintain reactivity return { get dragProps() { return dragResize.dragProps; From 5f2862995302949a8eae3b974905c4a2ef2bf3d6 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 09:59:39 -0700 Subject: [PATCH 051/225] minor cleanup --- .../components/AnnotationContainer.svelte | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte index 8d6c2b35a..daadde532 100644 --- a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte +++ b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte @@ -89,61 +89,58 @@ } }); - const controller = $derived({ + const interactionHandles = useInteractionHandles(() => ({ + controller: { element: currentObject.rect, vertices: vertexConfig?.extractVertices(currentObject), constraints: { - minWidth: 10, - minHeight: 10, - boundingBox: { width: pageWidth / scale, height: pageHeight / scale }, + minWidth: 10, + minHeight: 10, + boundingBox: { width: pageWidth / scale, height: pageHeight / scale }, }, maintainAspectRatio: lockAspectRatio, pageRotation: rotation, scale: scale, enabled: isSelected, onUpdate: (event) => { - if (!event.transformData?.type) return; - - if (event.state === 'start') { - gestureBaseRef = currentObject; - } - - const transformType = event.transformData.type; - const base = gestureBaseRef ?? currentObject; - - const changes = event.transformData.changes.vertices - ? vertexConfig?.transformAnnotation(base, event.transformData.changes.vertices) - : { rect: event.transformData.changes.rect }; - - const patched = annotationCapability.provides?.transformAnnotation(base, { - type: transformType, - changes: changes as Partial, - metadata: event.transformData.metadata, - }); - - if (patched) { - preview = { - ...preview, - ...patched, - }; - } - if (event.state === 'end' && patched) { - gestureBaseRef = null; - // Sanitize to remove Svelte reactive properties before updating - // Use deepToRaw to recursively strip proxies while preserving complex objects - const sanitized = deepToRaw(patched); - annotationCapability.provides?.updateAnnotation( - pageIndex, - trackedAnnotation.object.id, - sanitized, - ); - } + if (!event.transformData?.type) return; + + if (event.state === 'start') { + gestureBaseRef = currentObject; + } + + const transformType = event.transformData.type; + const base = gestureBaseRef ?? currentObject; + + const changes = event.transformData.changes.vertices + ? vertexConfig?.transformAnnotation(base, event.transformData.changes.vertices) + : { rect: event.transformData.changes.rect }; + + const patched = annotationCapability.provides?.transformAnnotation(base, { + type: transformType, + changes: changes as Partial, + metadata: event.transformData.metadata, + }); + + if (patched) { + preview = { + ...preview, + ...patched, + }; + } + if (event.state === 'end' && patched) { + gestureBaseRef = null; + // Sanitize to remove Svelte reactive properties before updating + // Use deepToRaw to recursively strip proxies while preserving complex objects + const sanitized = deepToRaw(patched); + annotationCapability.provides?.updateAnnotation( + pageIndex, + trackedAnnotation.object.id, + sanitized, + ); + } }, - }) - - - const interactionHandles = useInteractionHandles(() => ({ - controller, + }, resizeUI: { handleSize: HANDLE_SIZE, spacing: outlineOffset, From a3104dcebaeb00a371d2a382bb69174b579c5832 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 11:05:39 -0700 Subject: [PATCH 052/225] fix handles styles --- .../components/AnnotationContainer.svelte | 17 ++++++--- .../hooks/use-interaction-handles.svelte.ts | 2 +- packages/utils/src/svelte/index.ts | 2 +- packages/utils/src/svelte/utils/index.ts | 2 ++ .../src/svelte/utils/styles-to-string.ts | 36 +++++++++++++++++++ 5 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 packages/utils/src/svelte/utils/index.ts create mode 100644 packages/utils/src/svelte/utils/styles-to-string.ts diff --git a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte index daadde532..fd49b96ab 100644 --- a/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte +++ b/packages/plugin-annotation/src/svelte/components/AnnotationContainer.svelte @@ -14,6 +14,7 @@ doublePress, CounterRotate, deepToRaw, + stylesToString, } from '@embedpdf/utils/svelte'; interface AnnotationContainerProps { @@ -175,7 +176,7 @@ style:touch-action="none" style:cursor={isSelected && isDraggable ? 'move' : 'default'} style:z-index={zIndex} - {...style ? Object.fromEntries(Object.entries(style).map(([k, v]) => [`style:${k}`, v])) : {}} + style={style ? stylesToString(style) : ''} class={propsClass} {...restProps} > @@ -196,23 +197,29 @@ {/if} {#if isSelected && isResizable} - {#each resizeHandles as { key, ...hProps } (key)} + {#each resizeHandles as { key, style: handleStyle, ...hProps } (key)} {#if resizeUI?.component} {@const Component = resizeUI.component} {:else} -
+
{/if} {/each} {/if} {#if isSelected} - {#each vertexHandles as { key, ...vProps } (key)} + {#each vertexHandles as { key, style: vertexStyle, ...vProps } (key)} {#if vertexUI?.component} {@const Component = vertexUI.component} {:else} -
+
{/if} {/each} {/if} diff --git a/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts b/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts index 59d20a545..5b4498d0f 100644 --- a/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts +++ b/packages/utils/src/svelte/hooks/use-interaction-handles.svelte.ts @@ -61,7 +61,7 @@ export function useInteractionHandles(getOpts: () => { })); }); - // Use getter function to maintain reactivity + // Return getters to maintain reactivity when accessed from outside return { get dragProps() { return dragResize.dragProps; diff --git a/packages/utils/src/svelte/index.ts b/packages/utils/src/svelte/index.ts index 1cee9ae94..305492897 100644 --- a/packages/utils/src/svelte/index.ts +++ b/packages/utils/src/svelte/index.ts @@ -1,4 +1,4 @@ export * from './hooks'; export * from "./actions"; export * from './components'; -export * from './utils/deep-to-raw'; \ No newline at end of file +export * from './utils' \ No newline at end of file diff --git a/packages/utils/src/svelte/utils/index.ts b/packages/utils/src/svelte/utils/index.ts new file mode 100644 index 000000000..304f76e33 --- /dev/null +++ b/packages/utils/src/svelte/utils/index.ts @@ -0,0 +1,2 @@ +export * from './deep-to-raw'; +export * from './styles-to-string'; diff --git a/packages/utils/src/svelte/utils/styles-to-string.ts b/packages/utils/src/svelte/utils/styles-to-string.ts new file mode 100644 index 000000000..9ec5517a8 --- /dev/null +++ b/packages/utils/src/svelte/utils/styles-to-string.ts @@ -0,0 +1,36 @@ +/** + * Converts a style object with camelCase properties to a CSS string with kebab-case properties. + * + * This is useful in Svelte 5 where spreading style objects doesn't work with the `style:` directive. + * Instead, you can convert the entire style object to a string and apply it to the `style` attribute. + * + * @param style - An object containing CSS properties in camelCase format with string or number values + * @returns A CSS string with kebab-case properties suitable for the HTML style attribute + * + * @example + * ```ts + * const styles = { + * position: 'absolute', + * zIndex: 10, + * borderRadius: '50%', + * backgroundColor: '#007ACC' + * }; + * + * const cssString = stylesToString(styles); + * // Returns: "position: absolute; z-index: 10; border-radius: 50%; background-color: #007ACC" + * ``` + * + * @example + * Usage in Svelte templates: + * ```svelte + *
+ * ``` + */ +export function stylesToString(style: Record): string { + return Object.entries(style) + .map(([key, value]) => { + const cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + return `${cssKey}: ${value}`; + }) + .join('; '); +} From e75dd55019ae566bf7017877bcbb784319902fea Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Wed, 5 Nov 2025 20:55:07 +0200 Subject: [PATCH 053/225] Add new plugin view manager, for if you want more than one view --- examples/react-tailwind/package.json | 1 + examples/react-tailwind/src/application.tsx | 106 +++-- examples/react-tailwind/src/application2.tsx | 294 +++++++++++++ .../src/components/page-controls.tsx | 2 +- .../src/components/split-view-layout.tsx | 64 +++ .../src/components/tab-bar-2.tsx | 81 ++++ .../react-tailwind/src/components/tab-bar.tsx | 112 +++-- .../src/components/tab-context-menu.tsx | 83 ++++ .../src/lib/document-manager-plugin.ts | 58 ++- .../plugin-document-manager/src/lib/types.ts | 18 +- .../src/shared/components/file-picker.tsx | 18 +- .../src/shared/hooks/use-document-manager.ts | 7 +- .../src/shared/components/render-layer.tsx | 12 +- .../plugin-scroll/src/lib/scroll-plugin.ts | 5 +- packages/plugin-view-manager/package.json | 68 +++ packages/plugin-view-manager/src/index.ts | 1 + .../plugin-view-manager/src/lib/actions.ts | 112 +++++ packages/plugin-view-manager/src/lib/index.ts | 23 + .../plugin-view-manager/src/lib/manifest.ts | 17 + .../plugin-view-manager/src/lib/reducer.ts | 198 +++++++++ packages/plugin-view-manager/src/lib/types.ts | 90 ++++ .../src/lib/view-manager-plugin.ts | 414 ++++++++++++++++++ .../plugin-view-manager/src/preact/adapter.ts | 8 + .../plugin-view-manager/src/preact/core.ts | 1 + .../plugin-view-manager/src/preact/index.ts | 1 + .../src/preact/tsconfig.preact.json | 14 + .../plugin-view-manager/src/react/adapter.ts | 10 + .../plugin-view-manager/src/react/core.ts | 1 + .../plugin-view-manager/src/react/index.ts | 1 + .../src/react/tsconfig.react.json | 14 + .../src/shared/components/index.ts | 1 + .../src/shared/components/view-context.tsx | 92 ++++ .../src/shared/hooks/index.ts | 1 + .../src/shared/hooks/use-view-manager.ts | 113 +++++ .../plugin-view-manager/src/shared/index.ts | 3 + packages/plugin-view-manager/tsconfig.json | 22 + packages/plugin-view-manager/vite.config.ts | 2 + pnpm-lock.yaml | 37 ++ 38 files changed, 1961 insertions(+), 144 deletions(-) create mode 100644 examples/react-tailwind/src/application2.tsx create mode 100644 examples/react-tailwind/src/components/split-view-layout.tsx create mode 100644 examples/react-tailwind/src/components/tab-bar-2.tsx create mode 100644 examples/react-tailwind/src/components/tab-context-menu.tsx create mode 100644 packages/plugin-view-manager/package.json create mode 100644 packages/plugin-view-manager/src/index.ts create mode 100644 packages/plugin-view-manager/src/lib/actions.ts create mode 100644 packages/plugin-view-manager/src/lib/index.ts create mode 100644 packages/plugin-view-manager/src/lib/manifest.ts create mode 100644 packages/plugin-view-manager/src/lib/reducer.ts create mode 100644 packages/plugin-view-manager/src/lib/types.ts create mode 100644 packages/plugin-view-manager/src/lib/view-manager-plugin.ts create mode 100644 packages/plugin-view-manager/src/preact/adapter.ts create mode 100644 packages/plugin-view-manager/src/preact/core.ts create mode 100644 packages/plugin-view-manager/src/preact/index.ts create mode 100644 packages/plugin-view-manager/src/preact/tsconfig.preact.json create mode 100644 packages/plugin-view-manager/src/react/adapter.ts create mode 100644 packages/plugin-view-manager/src/react/core.ts create mode 100644 packages/plugin-view-manager/src/react/index.ts create mode 100644 packages/plugin-view-manager/src/react/tsconfig.react.json create mode 100644 packages/plugin-view-manager/src/shared/components/index.ts create mode 100644 packages/plugin-view-manager/src/shared/components/view-context.tsx create mode 100644 packages/plugin-view-manager/src/shared/hooks/index.ts create mode 100644 packages/plugin-view-manager/src/shared/hooks/use-view-manager.ts create mode 100644 packages/plugin-view-manager/src/shared/index.ts create mode 100644 packages/plugin-view-manager/tsconfig.json create mode 100644 packages/plugin-view-manager/vite.config.ts diff --git a/examples/react-tailwind/package.json b/examples/react-tailwind/package.json index 3edefeba7..ec21e37eb 100644 --- a/examples/react-tailwind/package.json +++ b/examples/react-tailwind/package.json @@ -33,6 +33,7 @@ "@embedpdf/plugin-capture": "workspace:*", "@embedpdf/plugin-history": "workspace:*", "@embedpdf/plugin-annotation": "workspace:*", + "@embedpdf/plugin-view-manager": "workspace:*", "@embedpdf/models": "workspace:*", "@embedpdf/pdfium": "workspace:*", "@embedpdf/engines": "workspace:*", diff --git a/examples/react-tailwind/src/application.tsx b/examples/react-tailwind/src/application.tsx index e1c5a8758..1a4e8d2ed 100644 --- a/examples/react-tailwind/src/application.tsx +++ b/examples/react-tailwind/src/application.tsx @@ -21,6 +21,7 @@ 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 { 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'; @@ -39,6 +40,7 @@ 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'; const logger = new ConsoleLogger(); @@ -99,6 +101,9 @@ export default function DocumentViewer() { width: 120, paddingY: 10, }), + createPluginRegistration(ViewManagerPluginPackage, { + defaultViewCount: 1, + }), ], [], // Empty dependency array since these never change ); @@ -147,48 +152,66 @@ export default function DocumentViewer() { {({ pluginsReady, registry }) => ( <> {pluginsReady ? ( - - {({ documentStates, activeDocumentId, actions }) => ( + (
+ currentView={view} + onSelect={(documentId) => setActiveDocument(documentId)} + onClose={(docId) => registry ?.getPlugin(DocumentManagerPlugin.id) ?.provides() - ?.openFileDialog() + ?.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); + }, + ); + }} /> - {activeDocumentId && ( + {documentId && ( toggleSidebar(activeDocumentId, 'search')} - onToggleThumbnails={() => toggleSidebar(activeDocumentId, 'thumbnails')} - isSearchOpen={getSidebarState(activeDocumentId).search} - isThumbnailsOpen={getSidebarState(activeDocumentId).thumbnails} - mode={getToolbarMode(activeDocumentId)} - onModeChange={(mode) => setToolbarMode(activeDocumentId, mode)} + documentId={documentId} + onToggleSearch={() => toggleSidebar(documentId, 'search')} + onToggleThumbnails={() => toggleSidebar(documentId, 'thumbnails')} + isSearchOpen={getSidebarState(documentId).search} + isThumbnailsOpen={getSidebarState(documentId).thumbnails} + mode={getToolbarMode(documentId)} + onModeChange={(mode) => setToolbarMode(documentId, mode)} /> )} {/* Document Content Area */} - {activeDocumentId && ( + {documentId && (
{/* Thumbnails Sidebar - Left */} - {getSidebarState(activeDocumentId).thumbnails && ( + {getSidebarState(documentId).thumbnails && ( toggleSidebar(activeDocumentId, 'thumbnails')} + documentId={documentId} + onClose={() => toggleSidebar(documentId, 'thumbnails')} /> )} {/* Main Viewer */}
- + {({ documentState, isLoading, isError, isLoaded }) => ( <> {isLoading && ( @@ -201,56 +224,53 @@ export default function DocumentViewer() { )} {isLoaded && (
- - + + ( - + />*/} @@ -258,7 +278,7 @@ export default function DocumentViewer() { )} /> {/* Page Controls */} - +
@@ -269,17 +289,17 @@ export default function DocumentViewer() {
{/* Search Sidebar - Right */} - {getSidebarState(activeDocumentId).search && ( + {getSidebarState(documentId).search && ( toggleSidebar(activeDocumentId, 'search')} + documentId={documentId} + onClose={() => toggleSidebar(documentId, 'search')} /> )}
)}
)} -
+ /> ) : (
diff --git a/examples/react-tailwind/src/application2.tsx b/examples/react-tailwind/src/application2.tsx new file mode 100644 index 000000000..c89aee519 --- /dev/null +++ b/examples/react-tailwind/src/application2.tsx @@ -0,0 +1,294 @@ +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, + DocumentTabs, + 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 { 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-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'; + +const logger = new ConsoleLogger(); + +// Type for tracking sidebar state per document +type SidebarState = { + search: boolean; + thumbnails: boolean; +}; + +export default function DocumentViewer() { + 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, + }), + ], + [], // 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 ( +
+
+ + {({ pluginsReady, registry }) => ( + <> + {pluginsReady ? ( + + {({ documentStates, activeDocumentId, actions }) => ( +
+ + registry + ?.getPlugin(DocumentManagerPlugin.id) + ?.provides() + ?.openFileDialog() + } + /> + + {activeDocumentId && ( + toggleSidebar(activeDocumentId, 'search')} + onToggleThumbnails={() => toggleSidebar(activeDocumentId, 'thumbnails')} + isSearchOpen={getSidebarState(activeDocumentId).search} + isThumbnailsOpen={getSidebarState(activeDocumentId).thumbnails} + mode={getToolbarMode(activeDocumentId)} + onModeChange={(mode) => setToolbarMode(activeDocumentId, mode)} + /> + )} + + {/* 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/components/page-controls.tsx b/examples/react-tailwind/src/components/page-controls.tsx index bafa7a370..b53df06d1 100644 --- a/examples/react-tailwind/src/components/page-controls.tsx +++ b/examples/react-tailwind/src/components/page-controls.tsx @@ -37,7 +37,7 @@ export function PageControls({ documentId }: PageControlsProps) { if (!viewport) return; return viewport.onScrollActivity((activity) => { - if (activity) { + if (activity.documentId === documentId) { setIsVisible(true); startHideTimer(); } 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..3736af6a7 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-bar-2.tsx @@ -0,0 +1,81 @@ +import { DocumentState } from '@embedpdf/core'; +import { CloseIcon, DocumentIcon, PlusIcon } from './icons'; + +type TabBarProps = { + documentStates: DocumentState[]; + activeDocumentId: string | null; + onSelect: (id: string) => void; + onClose: (id: string) => void; + onOpenFile: () => void; +}; + +export function TabBar({ + documentStates, + activeDocumentId, + onSelect, + onClose, + onOpenFile, +}: TabBarProps) { + 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 index 3736af6a7..b9bdf9bd6 100644 --- a/examples/react-tailwind/src/components/tab-bar.tsx +++ b/examples/react-tailwind/src/components/tab-bar.tsx @@ -1,81 +1,69 @@ import { DocumentState } from '@embedpdf/core'; -import { CloseIcon, DocumentIcon, PlusIcon } from './icons'; +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'; -type TabBarProps = { - documentStates: DocumentState[]; - activeDocumentId: string | null; - onSelect: (id: string) => void; - onClose: (id: string) => void; +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 }, + }); + }; -export function TabBar({ - documentStates, - activeDocumentId, - onSelect, - onClose, - onOpenFile, -}: TabBarProps) { return ( -
- {/* Document Tabs */} -
- {documentStates.map((document) => ( + <> +
+ {documentStates.map((doc) => (
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' - } `} + key={doc.id} + className={`group relative flex cursor-pointer items-center border-r border-gray-200 px-4 py-2 ${ + doc.id === currentView?.activeDocumentId ? 'bg-white' : 'hover:bg-gray-100' + }`} + onClick={() => onSelect(doc.id)} + onContextMenu={(e) => handleContextMenu(e, doc)} > - {/* Document Icon */} - - - {/* Document Name */} - - {document.name ?? `Document ${document.id.slice(0, 8)}`} - - - {/* Close Button */} + {doc.name || 'Untitled'}
))} - - {/* 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..61c10f875 --- /dev/null +++ b/examples/react-tailwind/src/components/tab-context-menu.tsx @@ -0,0 +1,83 @@ +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); + onClose(); + }; + + const otherViews = allViews.filter((v) => v.id !== currentViewId); + + return ( +
+
+ + + {otherViews.length > 0 && ( + <> +
+
Move to View
+ {otherViews.map((view, index) => ( + + ))} + + )} +
+
+ ); +} diff --git a/packages/plugin-document-manager/src/lib/document-manager-plugin.ts b/packages/plugin-document-manager/src/lib/document-manager-plugin.ts index 14bcf0577..97b3b1d64 100644 --- a/packages/plugin-document-manager/src/lib/document-manager-plugin.ts +++ b/packages/plugin-document-manager/src/lib/document-manager-plugin.ts @@ -32,6 +32,7 @@ import { LoadDocumentBufferOptions, RetryOptions, DocumentErrorEvent, + OpenDocumentResponse, } from './types'; import { DocumentManagerAction, @@ -53,7 +54,7 @@ export class DocumentManagerPlugin extends BasePlugin< private readonly activeDocumentChanged$ = createBehaviorEmitter(); private readonly documentError$ = createBehaviorEmitter(); private readonly documentOrderChanged$ = createBehaviorEmitter(); - private readonly openFileRequest$ = createEmitter<'open'>(); + private readonly openFileRequest$ = createEmitter>(); private maxDocuments?: number; @@ -72,7 +73,7 @@ export class DocumentManagerPlugin extends BasePlugin< protected buildCapability(): DocumentManagerCapability { return { // Document lifecycle - openFileDialog: () => this.openFileRequest$.emit('open'), + openFileDialog: () => this.openFileDialog(), openDocumentUrl: (options) => this.openDocumentUrl(options), openDocumentBuffer: (options) => this.openDocumentBuffer(options), retryDocument: (documentId, options) => this.retryDocument(documentId, options), @@ -169,7 +170,9 @@ export class DocumentManagerPlugin extends BasePlugin< ); } - public onOpenFileRequest(handler: Listener<'open'>): Unsubscribe { + public onOpenFileRequest( + handler: Listener>, + ): Unsubscribe { return this.openFileRequest$.on(handler); } @@ -177,8 +180,12 @@ export class DocumentManagerPlugin extends BasePlugin< // Document Loading // ───────────────────────────────────────────────────────── - private openDocumentUrl(options: LoadDocumentUrlOptions): Task { - const task = new Task(); + private openDocumentUrl( + options: LoadDocumentUrlOptions, + ): Task { + const task = new Task(); + + const documentId = options.documentId || this.generateDocumentId(); const limitError = this.checkDocumentLimit(); if (limitError) { @@ -186,7 +193,6 @@ export class DocumentManagerPlugin extends BasePlugin< return task; } - const documentId = options.documentId || this.generateDocumentId(); const documentName = this.extractNameFromUrl(options.url); // Store options for potential retry (will be cleared on success) @@ -221,14 +227,21 @@ export class DocumentManagerPlugin extends BasePlugin< headers: options.headers, }); + task.resolve({ + documentId, + task: engineTask, + }); + // Handle result - this.handleLoadTask(documentId, engineTask, task, 'OpenDocumentUrl'); + this.handleLoadTask(documentId, engineTask, 'OpenDocumentUrl'); return task; } - private openDocumentBuffer(options: LoadDocumentBufferOptions): Task { - const task = new Task(); + private openDocumentBuffer( + options: LoadDocumentBufferOptions, + ): Task { + const task = new Task(); const limitError = this.checkDocumentLimit(); if (limitError) { @@ -268,8 +281,13 @@ export class DocumentManagerPlugin extends BasePlugin< password: options.password, }); + task.resolve({ + documentId, + task: engineTask, + }); + // Handle result - this.handleLoadTask(documentId, engineTask, task, 'OpenDocumentBuffer'); + this.handleLoadTask(documentId, engineTask, 'OpenDocumentBuffer'); return task; } @@ -277,8 +295,8 @@ export class DocumentManagerPlugin extends BasePlugin< private retryDocument( documentId: string, retryOptions?: RetryOptions, - ): Task { - const task = new Task(); + ): Task { + const task = new Task(); // Validate retry const validation = this.validateRetry(documentId); @@ -314,9 +332,20 @@ export class DocumentManagerPlugin extends BasePlugin< ? this.retryUrlDocument(documentId, mergedOptions) : this.retryBufferDocument(documentId, mergedOptions); + task.resolve({ + documentId, + task: engineTask, + }); + // Handle result - this.handleLoadTask(documentId, engineTask, task, 'RetryDocument'); + this.handleLoadTask(documentId, engineTask, 'RetryDocument'); + + return task; + } + private openFileDialog(): Task { + const task = new Task(); + this.openFileRequest$.emit(task); return task; } @@ -594,17 +623,14 @@ export class DocumentManagerPlugin extends BasePlugin< private handleLoadTask( documentId: string, engineTask: Task, - parentTask: Task, context: string, ): void { engineTask.wait( (pdfDocument) => { this.dispatchCoreAction(setDocumentLoaded(documentId, pdfDocument)); - parentTask.resolve(documentId); }, (error) => { this.handleLoadError(documentId, error, context); - parentTask.fail(error); }, ); } diff --git a/packages/plugin-document-manager/src/lib/types.ts b/packages/plugin-document-manager/src/lib/types.ts index 1547a394f..cc227d10f 100644 --- a/packages/plugin-document-manager/src/lib/types.ts +++ b/packages/plugin-document-manager/src/lib/types.ts @@ -53,12 +53,22 @@ export interface RetryOptions { password?: string; } +export interface OpenDocumentResponse { + documentId: string; + task: Task; +} + export interface DocumentManagerCapability { // Document lifecycle - openFileDialog: () => void; - openDocumentUrl(options: LoadDocumentUrlOptions): Task; - openDocumentBuffer(options: LoadDocumentBufferOptions): Task; - retryDocument(documentId: string, options?: RetryOptions): Task; + openFileDialog: () => Task; + openDocumentUrl(options: LoadDocumentUrlOptions): Task; + openDocumentBuffer( + options: LoadDocumentBufferOptions, + ): Task; + retryDocument( + documentId: string, + options?: RetryOptions, + ): Task; closeDocument(documentId: string): Task; closeAllDocuments(): Task; diff --git a/packages/plugin-document-manager/src/shared/components/file-picker.tsx b/packages/plugin-document-manager/src/shared/components/file-picker.tsx index 8da6250a6..5a8c466bd 100644 --- a/packages/plugin-document-manager/src/shared/components/file-picker.tsx +++ b/packages/plugin-document-manager/src/shared/components/file-picker.tsx @@ -1,15 +1,19 @@ import { ChangeEvent, useEffect, useRef } from '@framework'; import { useDocumentManagerCapability, useDocumentManagerPlugin } from '../hooks'; +import { PdfErrorReason, Task } from '@embedpdf/models'; +import { OpenDocumentResponse } from '@embedpdf/plugin-document-manager'; export function FilePicker() { const { plugin } = useDocumentManagerPlugin(); const { provides } = useDocumentManagerCapability(); const inputRef = useRef(null); + const taskRef = useRef | null>(null); useEffect(() => { if (!plugin?.onOpenFileRequest) return; - const unsub = plugin.onOpenFileRequest((req) => { - if (req === 'open') inputRef.current?.click(); + const unsub = plugin.onOpenFileRequest((task) => { + taskRef.current = task; + inputRef.current?.click(); }); return unsub; }, [plugin]); @@ -18,10 +22,18 @@ export function FilePicker() { const file = (e.currentTarget as HTMLInputElement).files?.[0]; if (!file || !provides) return; const buffer = await file.arrayBuffer(); - provides.openDocumentBuffer({ + const openTask = provides.openDocumentBuffer({ name: file.name, buffer, }); + openTask.wait( + (result) => { + taskRef.current?.resolve(result); + }, + (error) => { + taskRef.current?.fail(error); + }, + ); }; return ( diff --git a/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts b/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts index 207605de2..1bff71674 100644 --- a/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts +++ b/packages/plugin-document-manager/src/shared/hooks/use-document-manager.ts @@ -41,7 +41,7 @@ export const useActiveDocument = () => { /** * Hook for all open documents (in order) */ -export const useOpenDocuments = () => { +export const useOpenDocuments = (documentIds?: string[]) => { const coreState = useCoreState(); const { provides } = useDocumentManagerCapability(); const [documentOrder, setDocumentOrder] = useState([]); @@ -64,8 +64,9 @@ export const useOpenDocuments = () => { return documentOrder .map((docId) => coreState.documents[docId]) - .filter((doc): doc is DocumentState => doc !== null && doc !== undefined); - }, [coreState, documentOrder]); + .filter((doc): doc is DocumentState => doc !== null && doc !== undefined) + .filter((doc) => !documentIds || documentIds.length === 0 || documentIds.includes(doc.id)); + }, [coreState, documentOrder, documentIds]); return documents; }; diff --git a/packages/plugin-render/src/shared/components/render-layer.tsx b/packages/plugin-render/src/shared/components/render-layer.tsx index f4ba8c39a..7caafd35c 100644 --- a/packages/plugin-render/src/shared/components/render-layer.tsx +++ b/packages/plugin-render/src/shared/components/render-layer.tsx @@ -71,7 +71,7 @@ export function RenderLayer({ }, [dprOverride]); useEffect(() => { - if (!renderProvides || !documentState) return; + if (!renderProvides) return; const task = renderProvides.forDocument(documentId).renderPage({ pageIndex, @@ -98,15 +98,7 @@ export function RenderLayer({ }); } }; - }, [ - documentId, - pageIndex, - actualScale, - actualDpr, - renderProvides, - documentState, - refreshVersion, - ]); + }, [documentId, pageIndex, actualScale, actualDpr, renderProvides, refreshVersion]); const handleImageLoad = () => { if (urlRef.current) { diff --git a/packages/plugin-scroll/src/lib/scroll-plugin.ts b/packages/plugin-scroll/src/lib/scroll-plugin.ts index 456235ea9..42c11cdf0 100644 --- a/packages/plugin-scroll/src/lib/scroll-plugin.ts +++ b/packages/plugin-scroll/src/lib/scroll-plugin.ts @@ -494,7 +494,6 @@ export class ScrollPlugin extends BasePlugin< const pages = this.getSpreadPagesWithRotatedSize(documentId); const layout = this.computeLayout(documentId, pages); - // Get viewport metrics for this document const viewport = this.viewport.forDocument(documentId); const metrics = this.computeMetrics(documentId, viewport.getMetrics(), layout.virtualItems); @@ -519,7 +518,9 @@ export class ScrollPlugin extends BasePlugin< if (!coreDoc) throw new Error(`Document ${id} not loaded`); const spreadPages = - this.spread?.getSpreadPages(id) || coreDoc.document?.pages.map((page) => [page]) || []; + this.spread?.forDocument(id).getSpreadPages() || + coreDoc.document?.pages.map((page) => [page]) || + []; return spreadPages.map((spread) => spread.map((page) => ({ ...page, diff --git a/packages/plugin-view-manager/package.json b/packages/plugin-view-manager/package.json new file mode 100644 index 000000000..d4bad4fbc --- /dev/null +++ b/packages/plugin-view-manager/package.json @@ -0,0 +1,68 @@ +{ + "name": "@embedpdf/plugin-view-manager", + "version": "1.3.14", + "type": "module", + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./preact": { + "types": "./dist/preact/index.d.ts", + "import": "./dist/preact/index.js", + "require": "./dist/preact/index.cjs" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js", + "require": "./dist/react/index.cjs" + } + }, + "scripts": { + "build:base": "vite build --mode base", + "build:react": "vite build --mode react", + "build:preact": "vite build --mode preact", + "build": "pnpm run clean && concurrently -c auto -n base,react,preact \"vite build --mode base\" \"vite build --mode react\" \"vite build --mode preact\"", + "clean": "rimraf dist", + "lint": "eslint src --color", + "lint:fix": "eslint src --color --fix" + }, + "dependencies": { + "@embedpdf/models": "workspace:*" + }, + "devDependencies": { + "@embedpdf/core": "workspace:*", + "@embedpdf/build": "workspace:*", + "@embedpdf/plugin-document-manager": "workspace:*", + "@types/react": "^18.2.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "@embedpdf/core": "workspace:*", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "preact": "^10.26.4", + "vue": ">=3.2.0" + }, + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/embedpdf/embed-pdf-viewer", + "directory": "packages/plugin-view-manager" + }, + "homepage": "https://www.embedpdf.com/docs", + "bugs": { + "url": "https://github.com/embedpdf/embed-pdf-viewer/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugin-view-manager/src/index.ts b/packages/plugin-view-manager/src/index.ts new file mode 100644 index 000000000..f41a696fd --- /dev/null +++ b/packages/plugin-view-manager/src/index.ts @@ -0,0 +1 @@ +export * from './lib'; diff --git a/packages/plugin-view-manager/src/lib/actions.ts b/packages/plugin-view-manager/src/lib/actions.ts new file mode 100644 index 000000000..07e9fab8d --- /dev/null +++ b/packages/plugin-view-manager/src/lib/actions.ts @@ -0,0 +1,112 @@ +import { Action } from '@embedpdf/core'; + +export const CREATE_VIEW = 'CREATE_VIEW'; +export const REMOVE_VIEW = 'REMOVE_VIEW'; +export const ADD_DOCUMENT_TO_VIEW = 'ADD_DOCUMENT_TO_VIEW'; +export const REMOVE_DOCUMENT_FROM_VIEW = 'REMOVE_DOCUMENT_FROM_VIEW'; +export const MOVE_DOCUMENT_WITHIN_VIEW = 'MOVE_DOCUMENT_WITHIN_VIEW'; +export const SET_VIEW_ACTIVE_DOCUMENT = 'SET_VIEW_ACTIVE_DOCUMENT'; +export const SET_FOCUSED_VIEW = 'SET_FOCUSED_VIEW'; + +export interface CreateViewAction extends Action { + type: typeof CREATE_VIEW; + payload: { + viewId: string; + createdAt: number; + }; +} + +export interface RemoveViewAction extends Action { + type: typeof REMOVE_VIEW; + payload: string; // viewId +} + +export interface AddDocumentToViewAction extends Action { + type: typeof ADD_DOCUMENT_TO_VIEW; + payload: { + viewId: string; + documentId: string; + index?: number; + }; +} + +export interface RemoveDocumentFromViewAction extends Action { + type: typeof REMOVE_DOCUMENT_FROM_VIEW; + payload: { + viewId: string; + documentId: string; + }; +} + +export interface MoveDocumentWithinViewAction extends Action { + type: typeof MOVE_DOCUMENT_WITHIN_VIEW; + payload: { + viewId: string; + documentId: string; + toIndex: number; + }; +} + +export interface SetViewActiveDocumentAction extends Action { + type: typeof SET_VIEW_ACTIVE_DOCUMENT; + payload: { + viewId: string; + documentId: string | null; + }; +} + +export interface SetFocusedViewAction extends Action { + type: typeof SET_FOCUSED_VIEW; + payload: string | null; // viewId or null +} + +export type ViewManagerAction = + | CreateViewAction + | RemoveViewAction + | AddDocumentToViewAction + | RemoveDocumentFromViewAction + | MoveDocumentWithinViewAction + | SetViewActiveDocumentAction + | SetFocusedViewAction; + +export function createView(viewId: string, createdAt: number): CreateViewAction { + return { type: CREATE_VIEW, payload: { viewId, createdAt } }; +} + +export function removeView(viewId: string): RemoveViewAction { + return { type: REMOVE_VIEW, payload: viewId }; +} + +export function addDocumentToView( + viewId: string, + documentId: string, + index?: number, +): AddDocumentToViewAction { + return { type: ADD_DOCUMENT_TO_VIEW, payload: { viewId, documentId, index } }; +} + +export function removeDocumentFromView( + viewId: string, + documentId: string, +): RemoveDocumentFromViewAction { + return { type: REMOVE_DOCUMENT_FROM_VIEW, payload: { viewId, documentId } }; +} + +export function moveDocumentWithinView( + viewId: string, + documentId: string, + toIndex: number, +): MoveDocumentWithinViewAction { + return { type: MOVE_DOCUMENT_WITHIN_VIEW, payload: { viewId, documentId, toIndex } }; +} + +export function setViewActiveDocument( + viewId: string, + documentId: string | null, +): SetViewActiveDocumentAction { + return { type: SET_VIEW_ACTIVE_DOCUMENT, payload: { viewId, documentId } }; +} + +export function setFocusedView(viewId: string | null): SetFocusedViewAction { + return { type: SET_FOCUSED_VIEW, payload: viewId }; +} diff --git a/packages/plugin-view-manager/src/lib/index.ts b/packages/plugin-view-manager/src/lib/index.ts new file mode 100644 index 000000000..cbee693c7 --- /dev/null +++ b/packages/plugin-view-manager/src/lib/index.ts @@ -0,0 +1,23 @@ +import { PluginPackage } from '@embedpdf/core'; +import { ViewManagerPlugin } from './view-manager-plugin'; +import { manifest, VIEW_MANAGER_PLUGIN_ID } from './manifest'; +import { ViewManagerPluginConfig, ViewManagerState } from './types'; +import { viewManagerReducer, initialState } from './reducer'; +import { ViewManagerAction } from './actions'; + +export const ViewManagerPluginPackage: PluginPackage< + ViewManagerPlugin, + ViewManagerPluginConfig, + ViewManagerState, + ViewManagerAction +> = { + manifest, + create: (registry, config) => new ViewManagerPlugin(VIEW_MANAGER_PLUGIN_ID, registry, config), + reducer: viewManagerReducer, + initialState, +}; + +export * from './view-manager-plugin'; +export * from './types'; +export * from './manifest'; +export * from './actions'; diff --git a/packages/plugin-view-manager/src/lib/manifest.ts b/packages/plugin-view-manager/src/lib/manifest.ts new file mode 100644 index 000000000..75b4ba194 --- /dev/null +++ b/packages/plugin-view-manager/src/lib/manifest.ts @@ -0,0 +1,17 @@ +import { PluginManifest } from '@embedpdf/core'; +import { ViewManagerPluginConfig } from './types'; + +export const VIEW_MANAGER_PLUGIN_ID = 'view-manager'; + +export const manifest: PluginManifest = { + id: VIEW_MANAGER_PLUGIN_ID, + name: 'View Manager Plugin', + version: '1.0.0', + provides: ['view-manager'], + requires: [], + optional: ['document-manager'], + defaultConfig: { + enabled: true, + defaultViewCount: 1, + }, +}; diff --git a/packages/plugin-view-manager/src/lib/reducer.ts b/packages/plugin-view-manager/src/lib/reducer.ts new file mode 100644 index 000000000..8d1445299 --- /dev/null +++ b/packages/plugin-view-manager/src/lib/reducer.ts @@ -0,0 +1,198 @@ +import { Reducer } from '@embedpdf/core'; +import { ViewManagerState } from './types'; +import { + ViewManagerAction, + CREATE_VIEW, + REMOVE_VIEW, + SET_FOCUSED_VIEW, + ADD_DOCUMENT_TO_VIEW, + REMOVE_DOCUMENT_FROM_VIEW, + MOVE_DOCUMENT_WITHIN_VIEW, + SET_VIEW_ACTIVE_DOCUMENT, +} from './actions'; + +export const initialState: ViewManagerState = { + views: {}, + viewOrder: [], + focusedViewId: null, +}; + +export const viewManagerReducer: Reducer = ( + state = initialState, + action, +) => { + switch (action.type) { + case CREATE_VIEW: { + const { viewId, createdAt } = action.payload; + + if (state.views[viewId]) { + return state; + } + + return { + ...state, + views: { + ...state.views, + [viewId]: { + id: viewId, + documentIds: [], + activeDocumentId: null, + createdAt, + }, + }, + viewOrder: [...state.viewOrder, viewId], + focusedViewId: state.focusedViewId ?? viewId, + }; + } + + case REMOVE_VIEW: { + const viewId = action.payload; + const { [viewId]: removed, ...remainingViews } = state.views; + + let newFocusedViewId = state.focusedViewId; + if (state.focusedViewId === viewId) { + const remainingIds = state.viewOrder.filter((id) => id !== viewId); + newFocusedViewId = remainingIds.length > 0 ? remainingIds[0] : null; + } + + return { + ...state, + views: remainingViews, + viewOrder: state.viewOrder.filter((id) => id !== viewId), + focusedViewId: newFocusedViewId, + }; + } + + case ADD_DOCUMENT_TO_VIEW: { + const { viewId, documentId, index } = action.payload; + const view = state.views[viewId]; + + if (!view) return state; + + // Remove document from any other view first + const updatedViews = { ...state.views }; + for (const vid in updatedViews) { + if (updatedViews[vid].documentIds.includes(documentId)) { + updatedViews[vid] = { + ...updatedViews[vid], + documentIds: updatedViews[vid].documentIds.filter((id) => id !== documentId), + // If we removed the active document, clear it + activeDocumentId: + updatedViews[vid].activeDocumentId === documentId + ? updatedViews[vid].documentIds.length > 1 + ? updatedViews[vid].documentIds.find((id) => id !== documentId) || null + : null + : updatedViews[vid].activeDocumentId, + }; + } + } + + // Add to target view + const newDocumentIds = [...updatedViews[viewId].documentIds]; + if (index !== undefined && index >= 0 && index <= newDocumentIds.length) { + newDocumentIds.splice(index, 0, documentId); + } else { + newDocumentIds.push(documentId); + } + + updatedViews[viewId] = { + ...updatedViews[viewId], + documentIds: newDocumentIds, + // Auto-set as active if it's the first document or no active document + activeDocumentId: updatedViews[viewId].activeDocumentId || documentId, + }; + + return { + ...state, + views: updatedViews, + }; + } + + case REMOVE_DOCUMENT_FROM_VIEW: { + const { viewId, documentId } = action.payload; + const view = state.views[viewId]; + + if (!view || !view.documentIds.includes(documentId)) return state; + + const newDocumentIds = view.documentIds.filter((id) => id !== documentId); + + // Calculate new active document + let newActiveDocumentId = view.activeDocumentId; + if (view.activeDocumentId === documentId) { + newActiveDocumentId = newDocumentIds.length > 0 ? newDocumentIds[0] : null; + } + + return { + ...state, + views: { + ...state.views, + [viewId]: { + ...view, + documentIds: newDocumentIds, + activeDocumentId: newActiveDocumentId, + }, + }, + }; + } + + case MOVE_DOCUMENT_WITHIN_VIEW: { + const { viewId, documentId, toIndex } = action.payload; + const view = state.views[viewId]; + + if (!view || !view.documentIds.includes(documentId)) return state; + + const fromIndex = view.documentIds.indexOf(documentId); + if (fromIndex === toIndex) return state; + + const newDocumentIds = [...view.documentIds]; + newDocumentIds.splice(fromIndex, 1); + newDocumentIds.splice(toIndex, 0, documentId); + + return { + ...state, + views: { + ...state.views, + [viewId]: { + ...view, + documentIds: newDocumentIds, + }, + }, + }; + } + + case SET_VIEW_ACTIVE_DOCUMENT: { + const { viewId, documentId } = action.payload; + const view = state.views[viewId]; + + if (!view) return state; + if (documentId !== null && !view.documentIds.includes(documentId)) return state; + + return { + ...state, + views: { + ...state.views, + [viewId]: { + ...view, + activeDocumentId: documentId, + }, + }, + }; + } + + case SET_FOCUSED_VIEW: { + const viewId = action.payload; + + if (viewId !== null && !state.views[viewId]) { + return state; + } + + return { + ...state, + focusedViewId: viewId, + }; + } + + default: + return state; + } +}; diff --git a/packages/plugin-view-manager/src/lib/types.ts b/packages/plugin-view-manager/src/lib/types.ts new file mode 100644 index 000000000..9f3c94900 --- /dev/null +++ b/packages/plugin-view-manager/src/lib/types.ts @@ -0,0 +1,90 @@ +import { BasePluginConfig, EventHook, DocumentState } from '@embedpdf/core'; + +export interface ViewManagerPluginConfig extends BasePluginConfig { + // Optional: Default number of views to create on init + defaultViewCount?: number; +} + +export interface View { + id: string; + documentIds: string[]; // Array of documents in this view + activeDocumentId: string | null; // Which document is currently active in this view + createdAt: number; +} + +export interface ViewManagerState { + views: Record; + viewOrder: string[]; + focusedViewId: string | null; +} + +export interface ViewChangeEvent { + previousViewId: string | null; + currentViewId: string | null; +} + +export interface ViewDocumentAddedEvent { + viewId: string; + documentId: string; + index: number; +} + +export interface ViewDocumentRemovedEvent { + viewId: string; + documentId: string; +} + +export interface ViewDocumentReorderedEvent { + viewId: string; + documentId: string; + fromIndex: number; + toIndex: number; +} + +export interface ViewActiveDocumentChangedEvent { + viewId: string; + previousDocumentId: string | null; + currentDocumentId: string | null; +} + +export interface ViewManagerCapability { + // View lifecycle + createView(viewId?: string): string; + removeView(viewId: string): void; + getAllViews(): View[]; + getViewCount(): number; + + // Document management within views + addDocumentToView(viewId: string, documentId: string, index?: number): void; + removeDocumentFromView(viewId: string, documentId: string): void; + moveDocumentWithinView(viewId: string, documentId: string, toIndex: number): void; + moveDocumentBetweenViews( + fromViewId: string, + toViewId: string, + documentId: string, + toIndex?: number, + ): void; + setViewActiveDocument(viewId: string, documentId: string | null): void; + + // Focus management + setFocusedView(viewId: string): void; + getFocusedViewId(): string | null; + getFocusedView(): View | null; + + // Queries + getView(viewId: string): View | null; + getViewDocuments(viewId: string): string[]; + getViewActiveDocument(viewId: string): string | null; + getDocumentView(documentId: string): string | null; // Which view has this doc? + isDocumentInAnyView(documentId: string): boolean; + getUnassignedDocuments(documentStates: DocumentState[]): DocumentState[]; + + // Events + onViewCreated: EventHook; + onViewRemoved: EventHook; + onViewFocusChanged: EventHook; + onDocumentAddedToView: EventHook; + onDocumentRemovedFromView: EventHook; + onDocumentReordered: EventHook; + onViewActiveDocumentChanged: EventHook; +} diff --git a/packages/plugin-view-manager/src/lib/view-manager-plugin.ts b/packages/plugin-view-manager/src/lib/view-manager-plugin.ts new file mode 100644 index 000000000..6c83f16f5 --- /dev/null +++ b/packages/plugin-view-manager/src/lib/view-manager-plugin.ts @@ -0,0 +1,414 @@ +import { BasePlugin, PluginRegistry, createBehaviorEmitter, DocumentState } from '@embedpdf/core'; +import type { + DocumentManagerCapability, + DocumentManagerPlugin, +} from '@embedpdf/plugin-document-manager'; + +import { + ViewManagerPluginConfig, + ViewManagerState, + ViewManagerCapability, + View, + ViewChangeEvent, + ViewActiveDocumentChangedEvent, + ViewDocumentReorderedEvent, + ViewDocumentRemovedEvent, + ViewDocumentAddedEvent, +} from './types'; +import { + ViewManagerAction, + createView as createViewAction, + removeView as removeViewAction, + setFocusedView as setFocusedViewAction, + setViewActiveDocument, + moveDocumentWithinView, + removeDocumentFromView, + addDocumentToView, +} from './actions'; + +export class ViewManagerPlugin extends BasePlugin< + ViewManagerPluginConfig, + ViewManagerCapability, + ViewManagerState, + ViewManagerAction +> { + static readonly id = 'view-manager' as const; + + private readonly viewCreated$ = createBehaviorEmitter(); + private readonly viewRemoved$ = createBehaviorEmitter(); + private readonly viewFocusChanged$ = createBehaviorEmitter(); + private readonly documentAddedToView$ = createBehaviorEmitter(); + private readonly documentRemovedFromView$ = createBehaviorEmitter(); + private readonly documentReordered$ = createBehaviorEmitter(); + private readonly viewActiveDocumentChanged$ = + createBehaviorEmitter(); + + // Optional integration with DocumentManager + private docManagerCapability?: DocumentManagerCapability; + + constructor( + public readonly id: string, + registry: PluginRegistry, + config?: ViewManagerPluginConfig, + ) { + super(id, registry); + } + + protected buildCapability(): ViewManagerCapability { + return { + // View lifecycle + createView: (viewId) => this.createView(viewId), + removeView: (viewId) => this.removeView(viewId), + getAllViews: () => this.getAllViews(), + getViewCount: () => this.getViewCount(), + + // Document management + addDocumentToView: (viewId, documentId, index) => + this.addDocumentToView(viewId, documentId, index), + removeDocumentFromView: (viewId, documentId) => + this.removeDocumentFromView(viewId, documentId), + moveDocumentWithinView: (viewId, documentId, toIndex) => + this.moveDocumentWithinView(viewId, documentId, toIndex), + moveDocumentBetweenViews: (fromViewId, toViewId, documentId, toIndex) => + this.moveDocumentBetweenViews(fromViewId, toViewId, documentId, toIndex), + setViewActiveDocument: (viewId, documentId) => this.setViewActiveDocument(viewId, documentId), + + // Focus management + setFocusedView: (viewId) => this.setFocusedView(viewId), + getFocusedViewId: () => this.state.focusedViewId, + getFocusedView: () => this.getFocusedView(), + + // Queries + getView: (viewId) => this.getView(viewId), + getViewDocuments: (viewId) => this.getViewDocuments(viewId), + getViewActiveDocument: (viewId) => this.getViewActiveDocument(viewId), + getDocumentView: (documentId) => this.getDocumentView(documentId), + isDocumentInAnyView: (documentId) => this.isDocumentInAnyView(documentId), + getUnassignedDocuments: (documentStates) => this.getUnassignedDocuments(documentStates), + + // Events + onViewCreated: this.viewCreated$.on, + onViewRemoved: this.viewRemoved$.on, + onViewFocusChanged: this.viewFocusChanged$.on, + onDocumentAddedToView: this.documentAddedToView$.on, + onDocumentRemovedFromView: this.documentRemovedFromView$.on, + onDocumentReordered: this.documentReordered$.on, + onViewActiveDocumentChanged: this.viewActiveDocumentChanged$.on, + }; + } + + // ───────────────────────────────────────────────────────── + // Plugin Lifecycle + // ───────────────────────────────────────────────────────── + + async initialize(config: ViewManagerPluginConfig): Promise { + // Try to get DocumentManager if it exists (optional dependency) + const docManager = this.registry.getPlugin('document-manager'); + if (docManager && docManager.provides) { + this.docManagerCapability = docManager.provides(); + + this.docManagerCapability.onDocumentClosed((documentId) => { + const viewId = this.getDocumentView(documentId); + if (viewId) { + this.removeDocumentFromView(viewId, documentId); + } + }); + } + + // Create default views if configured + if (config.defaultViewCount && config.defaultViewCount > 0) { + for (let i = 0; i < config.defaultViewCount; i++) { + this.createView(`view-${i + 1}`); + } + } + + this.logger.info('ViewManagerPlugin', 'Initialize', 'View Manager Plugin initialized', { + defaultViewCount: config.defaultViewCount, + hasDocumentManager: !!this.docManagerCapability, + }); + } + + async destroy(): Promise { + // Clear all emitters + this.viewCreated$.clear(); + this.viewRemoved$.clear(); + this.viewFocusChanged$.clear(); + this.documentAddedToView$.clear(); + this.documentRemovedFromView$.clear(); + this.documentReordered$.clear(); + this.viewActiveDocumentChanged$.clear(); + + super.destroy(); + } + + // ───────────────────────────────────────────────────────── + // View Lifecycle + // ───────────────────────────────────────────────────────── + + private createView(viewId?: string): string { + const id = viewId || this.generateViewId(); + + if (this.state.views[id]) { + this.logger.warn('ViewManagerPlugin', 'CreateView', `View ${id} already exists`); + return id; + } + + this.dispatch(createViewAction(id, Date.now())); + this.viewCreated$.emit(id); + + this.logger.info('ViewManagerPlugin', 'CreateView', `View ${id} created`); + + return id; + } + + private removeView(viewId: string): void { + const view = this.state.views[viewId]; + + if (!view) { + this.logger.warn('ViewManagerPlugin', 'RemoveView', `View ${viewId} not found`); + return; + } + + // Don't allow removing the last view + if (this.getViewCount() === 1) { + this.logger.warn('ViewManagerPlugin', 'RemoveView', 'Cannot remove the last view'); + return; + } + + this.dispatch(removeViewAction(viewId)); + this.viewRemoved$.emit(viewId); + + this.logger.info('ViewManagerPlugin', 'RemoveView', `View ${viewId} removed`); + } + + // ───────────────────────────────────────────────────────── + // Document Assignment + // ───────────────────────────────────────────────────────── + + // Document Management Methods + private addDocumentToView(viewId: string, documentId: string, index?: number): void { + const view = this.state.views[viewId]; + if (!view) { + throw new Error(`View ${viewId} not found`); + } + + // Validate document exists if DocumentManager is present + if (this.docManagerCapability) { + if (!this.docManagerCapability.isDocumentOpen(documentId)) { + throw new Error(`Document ${documentId} is not open`); + } + } + + // Check if document is already in another view (before state update) + const previousViewId = this.getDocumentView(documentId); + + const actualIndex = index ?? view.documentIds.length; + + this.dispatch(addDocumentToView(viewId, documentId, actualIndex)); + + // Emit removal event if document was moved from another view + if (previousViewId && previousViewId !== viewId) { + this.documentRemovedFromView$.emit({ + viewId: previousViewId, + documentId, + }); + + this.logger.info( + 'ViewManagerPlugin', + 'AddDocumentToView', + `Document ${documentId} moved from view ${previousViewId}`, + ); + } + + // Emit addition event + this.documentAddedToView$.emit({ + viewId, + documentId, + index: actualIndex, + }); + + this.logger.info( + 'ViewManagerPlugin', + 'AddDocumentToView', + `Document ${documentId} added to view ${viewId} at index ${actualIndex}`, + ); + } + + private removeDocumentFromView(viewId: string, documentId: string): void { + const view = this.state.views[viewId]; + if (!view) { + throw new Error(`View ${viewId} not found`); + } + + if (!view.documentIds.includes(documentId)) { + this.logger.warn( + 'ViewManagerPlugin', + 'RemoveDocumentFromView', + `Document ${documentId} not in view ${viewId}`, + ); + return; + } + + this.dispatch(removeDocumentFromView(viewId, documentId)); + + this.documentRemovedFromView$.emit({ + viewId, + documentId, + }); + + this.logger.info( + 'ViewManagerPlugin', + 'RemoveDocumentFromView', + `Document ${documentId} removed from view ${viewId}`, + ); + } + + private moveDocumentWithinView(viewId: string, documentId: string, toIndex: number): void { + const view = this.state.views[viewId]; + if (!view) { + throw new Error(`View ${viewId} not found`); + } + + const fromIndex = view.documentIds.indexOf(documentId); + if (fromIndex === -1) { + throw new Error(`Document ${documentId} not found in view ${viewId}`); + } + + if (toIndex < 0 || toIndex >= view.documentIds.length) { + throw new Error(`Invalid index ${toIndex}`); + } + + this.dispatch(moveDocumentWithinView(viewId, documentId, toIndex)); + + this.documentReordered$.emit({ + viewId, + documentId, + fromIndex, + toIndex, + }); + } + + private moveDocumentBetweenViews( + fromViewId: string, + toViewId: string, + documentId: string, + toIndex?: number, + ): void { + // Remove from source view + this.removeDocumentFromView(fromViewId, documentId); + + // Add to target view + this.addDocumentToView(toViewId, documentId, toIndex); + } + + private setViewActiveDocument(viewId: string, documentId: string | null): void { + const view = this.state.views[viewId]; + if (!view) { + throw new Error(`View ${viewId} not found`); + } + + if (documentId !== null && !view.documentIds.includes(documentId)) { + throw new Error(`Document ${documentId} not in view ${viewId}`); + } + + const previousDocumentId = view.activeDocumentId; + if (previousDocumentId === documentId) return; + + this.dispatch(setViewActiveDocument(viewId, documentId)); + + this.viewActiveDocumentChanged$.emit({ + viewId, + previousDocumentId, + currentDocumentId: documentId, + }); + } + + // Query Methods + private getViewDocuments(viewId: string): string[] { + return this.state.views[viewId]?.documentIds ?? []; + } + + private getViewActiveDocument(viewId: string): string | null { + return this.state.views[viewId]?.activeDocumentId ?? null; + } + + private getDocumentView(documentId: string): string | null { + for (const viewId in this.state.views) { + if (this.state.views[viewId].documentIds.includes(documentId)) { + return viewId; + } + } + return null; + } + + private isDocumentInAnyView(documentId: string): boolean { + return this.getDocumentView(documentId) !== null; + } + + private getUnassignedDocuments(documentStates: DocumentState[]): DocumentState[] { + const assignedDocIds = new Set(Object.values(this.state.views).flatMap((v) => v.documentIds)); + + return documentStates.filter((doc) => !assignedDocIds.has(doc.id)); + } + + // ───────────────────────────────────────────────────────── + // Focus Management + // ───────────────────────────────────────────────────────── + + private setFocusedView(viewId: string): void { + if (!this.state.views[viewId]) { + throw new Error(`View ${viewId} not found`); + } + + const previousViewId = this.state.focusedViewId; + + if (previousViewId === viewId) { + return; // Already focused + } + + this.dispatch(setFocusedViewAction(viewId)); + + this.viewFocusChanged$.emit({ + previousViewId, + currentViewId: viewId, + }); + + this.logger.info( + 'ViewManagerPlugin', + 'SetFocusedView', + `Focus changed from ${previousViewId} to ${viewId}`, + ); + } + + // ───────────────────────────────────────────────────────── + // Queries + // ───────────────────────────────────────────────────────── + + private getView(viewId: string): View | null { + return this.state.views[viewId] ?? null; + } + + private getFocusedView(): View | null { + const focusedViewId = this.state.focusedViewId; + if (!focusedViewId) return null; + return this.getView(focusedViewId); + } + + private getAllViews(): View[] { + return this.state.viewOrder + .map((viewId) => this.state.views[viewId]) + .filter((view): view is View => view !== undefined); + } + + private getViewCount(): number { + return Object.keys(this.state.views).length; + } + + // ───────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────── + + private generateViewId(): string { + return `view-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/packages/plugin-view-manager/src/preact/adapter.ts b/packages/plugin-view-manager/src/preact/adapter.ts new file mode 100644 index 000000000..d0e439fe4 --- /dev/null +++ b/packages/plugin-view-manager/src/preact/adapter.ts @@ -0,0 +1,8 @@ +export { Fragment } from 'preact'; +export { useEffect, useRef, useState, useCallback, useMemo, useLayoutEffect } from 'preact/hooks'; +export type { ComponentChildren as ReactNode } from 'preact'; + +export type CSSProperties = import('preact').JSX.CSSProperties; +export type HTMLAttributes = import('preact').JSX.HTMLAttributes< + T extends EventTarget ? T : never +>; diff --git a/packages/plugin-view-manager/src/preact/core.ts b/packages/plugin-view-manager/src/preact/core.ts new file mode 100644 index 000000000..a1403497b --- /dev/null +++ b/packages/plugin-view-manager/src/preact/core.ts @@ -0,0 +1 @@ +export * from '@embedpdf/core/preact'; diff --git a/packages/plugin-view-manager/src/preact/index.ts b/packages/plugin-view-manager/src/preact/index.ts new file mode 100644 index 000000000..90a217667 --- /dev/null +++ b/packages/plugin-view-manager/src/preact/index.ts @@ -0,0 +1 @@ +export * from '../shared'; diff --git a/packages/plugin-view-manager/src/preact/tsconfig.preact.json b/packages/plugin-view-manager/src/preact/tsconfig.preact.json new file mode 100644 index 000000000..b28547ae8 --- /dev/null +++ b/packages/plugin-view-manager/src/preact/tsconfig.preact.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "baseUrl": "../..", + "paths": { + "@framework": ["src/preact/adapter.ts"], + "@embedpdf/core/@framework": ["src/preact/core.ts"], + "@embedpdf/plugin-view-manager": ["src/lib/index.ts"] + } + }, + "include": ["../shared/**/*.ts", "../shared/**/*.tsx", "./**/*.ts", "./**/*.tsx"] +} diff --git a/packages/plugin-view-manager/src/react/adapter.ts b/packages/plugin-view-manager/src/react/adapter.ts new file mode 100644 index 000000000..12ff24ecc --- /dev/null +++ b/packages/plugin-view-manager/src/react/adapter.ts @@ -0,0 +1,10 @@ +export { + Fragment, + useEffect, + useRef, + useState, + useCallback, + useMemo, + useLayoutEffect, +} from 'react'; +export type { ReactNode, HTMLAttributes, CSSProperties } from 'react'; diff --git a/packages/plugin-view-manager/src/react/core.ts b/packages/plugin-view-manager/src/react/core.ts new file mode 100644 index 000000000..60aefb42b --- /dev/null +++ b/packages/plugin-view-manager/src/react/core.ts @@ -0,0 +1 @@ +export * from '@embedpdf/core/react'; diff --git a/packages/plugin-view-manager/src/react/index.ts b/packages/plugin-view-manager/src/react/index.ts new file mode 100644 index 000000000..90a217667 --- /dev/null +++ b/packages/plugin-view-manager/src/react/index.ts @@ -0,0 +1 @@ +export * from '../shared'; diff --git a/packages/plugin-view-manager/src/react/tsconfig.react.json b/packages/plugin-view-manager/src/react/tsconfig.react.json new file mode 100644 index 000000000..76b78377d --- /dev/null +++ b/packages/plugin-view-manager/src/react/tsconfig.react.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "baseUrl": "../..", + "paths": { + "@framework": ["src/react/adapter.ts"], + "@embedpdf/core/@framework": ["src/react/core.ts"], + "@embedpdf/plugin-view-manager": ["src/lib/index.ts"] + } + }, + "include": ["../shared/**/*.ts", "../shared/**/*.tsx", "./**/*.ts", "./**/*.tsx"] +} diff --git a/packages/plugin-view-manager/src/shared/components/index.ts b/packages/plugin-view-manager/src/shared/components/index.ts new file mode 100644 index 000000000..32010578f --- /dev/null +++ b/packages/plugin-view-manager/src/shared/components/index.ts @@ -0,0 +1 @@ +export * from './view-context'; diff --git a/packages/plugin-view-manager/src/shared/components/view-context.tsx b/packages/plugin-view-manager/src/shared/components/view-context.tsx new file mode 100644 index 000000000..0a1e3f7ed --- /dev/null +++ b/packages/plugin-view-manager/src/shared/components/view-context.tsx @@ -0,0 +1,92 @@ +import { ReactNode, useEffect, useState } from '@framework'; +import { useViewManagerCapability } from '../hooks'; +import { View } from '@embedpdf/plugin-view-manager'; + +export interface ViewContextRenderProps { + view: View; + documentIds: string[]; + activeDocumentId: string | null; + isFocused: boolean; + addDocument: (documentId: string, index?: number) => void; + removeDocument: (documentId: string) => void; + setActiveDocument: (documentId: string | null) => void; + moveDocumentWithinView: (documentId: string, index: number) => void; + focus: () => void; +} + +interface ViewContextProps { + viewId: string; + autoCreate?: boolean; + children: (props: ViewContextRenderProps) => ReactNode; +} + +/** + * Headless component for managing a single view with multiple documents + */ +export function ViewContext({ viewId, autoCreate = true, children }: ViewContextProps) { + const { provides } = useViewManagerCapability(); + const [view, setView] = useState(null); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (!provides) return; + + // Get or create view + let v = provides.getView(viewId); + if (!v && autoCreate) { + provides.createView(viewId); + v = provides.getView(viewId); + } + setView(v); + setIsFocused(provides.getFocusedViewId() === viewId); + + const unsubFocus = provides.onViewFocusChanged((event) => { + setIsFocused(event.currentViewId === viewId); + }); + + const unsubAdded = provides.onDocumentAddedToView((event) => { + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + const unsubRemoved = provides.onDocumentRemovedFromView((event) => { + console.log('document removed from view', event); + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + const unsubActiveChanged = provides.onViewActiveDocumentChanged((event) => { + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + return () => { + unsubFocus(); + unsubAdded(); + unsubRemoved(); + unsubActiveChanged(); + }; + }, [viewId, autoCreate, provides]); + + if (!view) return null; + + return ( + <> + {children({ + view, + documentIds: view.documentIds, + activeDocumentId: view.activeDocumentId, + isFocused, + addDocument: (docId, index) => provides?.addDocumentToView(viewId, docId, index), + removeDocument: (docId) => provides?.removeDocumentFromView(viewId, docId), + setActiveDocument: (docId) => provides?.setViewActiveDocument(viewId, docId), + moveDocumentWithinView: (docId, index) => + provides?.moveDocumentWithinView(viewId, docId, index), + focus: () => provides?.setFocusedView(viewId), + })} + + ); +} diff --git a/packages/plugin-view-manager/src/shared/hooks/index.ts b/packages/plugin-view-manager/src/shared/hooks/index.ts new file mode 100644 index 000000000..7ec2dbf31 --- /dev/null +++ b/packages/plugin-view-manager/src/shared/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-view-manager'; diff --git a/packages/plugin-view-manager/src/shared/hooks/use-view-manager.ts b/packages/plugin-view-manager/src/shared/hooks/use-view-manager.ts new file mode 100644 index 000000000..be90469a2 --- /dev/null +++ b/packages/plugin-view-manager/src/shared/hooks/use-view-manager.ts @@ -0,0 +1,113 @@ +import { useEffect, useState } from '@framework'; +import { useCapability, usePlugin } from '@embedpdf/core/@framework'; +import { ViewManagerPlugin } from '@embedpdf/plugin-view-manager'; +import { View } from '@embedpdf/plugin-view-manager'; + +export const useViewManagerPlugin = () => usePlugin(ViewManagerPlugin.id); +export const useViewManagerCapability = () => + useCapability(ViewManagerPlugin.id); + +/** + * Hook for a specific view's state + */ +export const useView = (viewId: string) => { + const { provides } = useViewManagerCapability(); + const [view, setView] = useState(null); + + useEffect(() => { + if (!provides) return; + + // Get initial view + setView(provides.getView(viewId)); + + // Subscribe to all document events for this view + const unsubAdded = provides.onDocumentAddedToView((event) => { + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + const unsubRemoved = provides.onDocumentRemovedFromView((event) => { + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + const unsubReordered = provides.onDocumentReordered((event) => { + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + const unsubActiveChanged = provides.onViewActiveDocumentChanged((event) => { + if (event.viewId === viewId) { + setView(provides.getView(viewId)); + } + }); + + return () => { + unsubAdded(); + unsubRemoved(); + unsubReordered(); + unsubActiveChanged(); + }; + }, [viewId, provides]); + + return view; +}; + +/** + * Hook for focused view state + */ +export const useFocusedView = () => { + const { provides } = useViewManagerCapability(); + const [focusedViewId, setFocusedViewId] = useState(null); + + useEffect(() => { + if (!provides) return; + + setFocusedViewId(provides.getFocusedViewId()); + + return provides.onViewFocusChanged((event) => { + setFocusedViewId(event.currentViewId); + }); + }, [provides]); + + return { focusedViewId }; +}; + +/** + * Hook for all views + */ +export const useAllViews = () => { + const { provides } = useViewManagerCapability(); + const [views, setViews] = useState([]); + + useEffect(() => { + if (!provides) return; + + const updateViews = () => { + setViews(provides.getAllViews()); + }; + + updateViews(); + + const unsubCreated = provides.onViewCreated(updateViews); + const unsubRemoved = provides.onViewRemoved(updateViews); + const unsubAdded = provides.onDocumentAddedToView(updateViews); + const unsubRemovedDoc = provides.onDocumentRemovedFromView(updateViews); + const unsubReordered = provides.onDocumentReordered(updateViews); + const unsubActiveChanged = provides.onViewActiveDocumentChanged(updateViews); + + return () => { + unsubCreated(); + unsubRemoved(); + unsubAdded(); + unsubRemovedDoc(); + unsubReordered(); + unsubActiveChanged(); + }; + }, [provides]); + + return views; +}; diff --git a/packages/plugin-view-manager/src/shared/index.ts b/packages/plugin-view-manager/src/shared/index.ts new file mode 100644 index 000000000..a2174bcb8 --- /dev/null +++ b/packages/plugin-view-manager/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './components'; +export * from './hooks'; +export * from '@embedpdf/plugin-view-manager'; diff --git a/packages/plugin-view-manager/tsconfig.json b/packages/plugin-view-manager/tsconfig.json new file mode 100644 index 000000000..79c3b39d7 --- /dev/null +++ b/packages/plugin-view-manager/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "react", + "rootDir": "src", + "paths": { + "@framework": ["./src/react/adapter.ts"], + "@embedpdf/core/@framework": ["./src/react/core.ts"], + "@embedpdf/plugin-view-manager": ["./src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/plugin-view-manager/vite.config.ts b/packages/plugin-view-manager/vite.config.ts new file mode 100644 index 000000000..827dc56ec --- /dev/null +++ b/packages/plugin-view-manager/vite.config.ts @@ -0,0 +1,2 @@ +import { defineLibrary } from '@embedpdf/build/vite'; +export default defineLibrary(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 793138ddf..dec8915ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: '@embedpdf/plugin-tiling': specifier: workspace:* version: link:../../packages/plugin-tiling + '@embedpdf/plugin-view-manager': + specifier: workspace:* + version: link:../../packages/plugin-view-manager '@embedpdf/plugin-viewport': specifier: workspace:* version: link:../../packages/plugin-viewport @@ -1453,6 +1456,40 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/plugin-view-manager: + dependencies: + '@embedpdf/models': + specifier: workspace:* + version: link:../models + preact: + specifier: ^10.26.4 + version: 10.27.2 + react: + specifier: '>=16.8.0' + version: 18.3.1 + react-dom: + specifier: '>=16.8.0' + version: 18.3.1(react@18.3.1) + vue: + specifier: '>=3.2.0' + version: 3.5.22(typescript@5.9.3) + devDependencies: + '@embedpdf/build': + specifier: workspace:* + version: link:../build + '@embedpdf/core': + specifier: workspace:* + version: link:../core + '@embedpdf/plugin-document-manager': + specifier: workspace:* + version: link:../plugin-document-manager + '@types/react': + specifier: ^18.2.0 + version: 18.3.26 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/plugin-viewport: dependencies: '@embedpdf/models': From 62cfac89290aa686b8236f0bb4845a07b6945b74 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 12:18:10 -0700 Subject: [PATCH 054/225] update example wip --- examples/svelte-tailwind/src/app.css | 7 + .../components/AnnotationSelectionMenu.svelte | 49 ++ .../lib/components/AnnotationToolbar.svelte | 154 ++++++ .../src/lib/components/PageSettings.svelte | 2 +- .../src/lib/components/Toolbar.svelte | 496 ++++++++++-------- .../src/lib/components/ZoomToolbar.svelte | 10 +- .../src/lib/components/index.ts | 2 + .../svelte-tailwind/src/routes/+page.svelte | 50 +- 8 files changed, 528 insertions(+), 242 deletions(-) create mode 100644 examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte create mode 100644 examples/svelte-tailwind/src/lib/components/AnnotationToolbar.svelte diff --git a/examples/svelte-tailwind/src/app.css b/examples/svelte-tailwind/src/app.css index d4b507858..77950ae6a 100644 --- a/examples/svelte-tailwind/src/app.css +++ b/examples/svelte-tailwind/src/app.css @@ -1 +1,8 @@ @import 'tailwindcss'; + +@layer base { + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } +} diff --git a/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte b/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte new file mode 100644 index 000000000..f71a7f6c8 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte @@ -0,0 +1,49 @@ + + + + +{#if anchorEl} +
+ +
+{/if} diff --git a/examples/svelte-tailwind/src/lib/components/AnnotationToolbar.svelte b/examples/svelte-tailwind/src/lib/components/AnnotationToolbar.svelte new file mode 100644 index 000000000..b348be245 --- /dev/null +++ b/examples/svelte-tailwind/src/lib/components/AnnotationToolbar.svelte @@ -0,0 +1,154 @@ + + +
+ + + + + + + + + + + + + + + + + +
diff --git a/examples/svelte-tailwind/src/lib/components/PageSettings.svelte b/examples/svelte-tailwind/src/lib/components/PageSettings.svelte index a7a771f06..4605fea1d 100644 --- a/examples/svelte-tailwind/src/lib/components/PageSettings.svelte +++ b/examples/svelte-tailwind/src/lib/components/PageSettings.svelte @@ -35,7 +35,7 @@
+ + + + + + + - {#if isMenuOpen} -
-
- - - + - + + Print + + +
-
- {/if} -
+ {/if} +
-
+
- - + + + + + + -
+
- -
- + +
+ -
+
- - + + + + + + + + - - + + + + + -
+
+ + + +
- - + + + + + +
+ + {#if mode === 'annotate'} + + {/if} diff --git a/examples/svelte-tailwind/src/lib/components/ZoomToolbar.svelte b/examples/svelte-tailwind/src/lib/components/ZoomToolbar.svelte index 90f24d6e1..f900d438b 100644 --- a/examples/svelte-tailwind/src/lib/components/ZoomToolbar.svelte +++ b/examples/svelte-tailwind/src/lib/components/ZoomToolbar.svelte @@ -72,7 +72,7 @@
{:else}
- +
Date: Wed, 5 Nov 2025 12:18:30 -0700 Subject: [PATCH 055/225] Delete temp page --- .../src/routes/temp/+page.svelte | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 examples/svelte-tailwind/src/routes/temp/+page.svelte diff --git a/examples/svelte-tailwind/src/routes/temp/+page.svelte b/examples/svelte-tailwind/src/routes/temp/+page.svelte deleted file mode 100644 index 7a94c300c..000000000 --- a/examples/svelte-tailwind/src/routes/temp/+page.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#if pdfEngine.isLoading || !pdfEngine.engine} -
Loading PDF Engine...
-{:else} - - - -{/if} \ No newline at end of file From 67223fb5bd0c079df10f2ea3c3ef7432891f40f5 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Wed, 5 Nov 2025 21:22:37 +0200 Subject: [PATCH 056/225] Add per-key debounce/throttle support to event control Introduces KeyedEventControl and related options to allow independent debouncing/throttling for events based on a key (e.g., documentId). Updates eventing utilities and zoom plugin to use per-key debounce for viewport resize events, improving event handling granularity. --- packages/core/src/lib/utils/event-control.ts | 79 +++++++++++++++++++- packages/core/src/lib/utils/eventing.ts | 31 +++++--- packages/plugin-zoom/src/lib/zoom-plugin.ts | 6 +- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/packages/core/src/lib/utils/event-control.ts b/packages/core/src/lib/utils/event-control.ts index cdd5f73d7..04d231f79 100644 --- a/packages/core/src/lib/utils/event-control.ts +++ b/packages/core/src/lib/utils/event-control.ts @@ -13,7 +13,22 @@ export interface ThrottleOptions extends BaseEventControlOptions { throttleMode?: 'leading-trailing' | 'trailing'; } -export type EventControlOptions = DebounceOptions | ThrottleOptions; +export interface KeyedDebounceOptions extends BaseEventControlOptions { + mode: 'debounce'; + keyExtractor: (data: T) => string | number; +} + +export interface KeyedThrottleOptions extends BaseEventControlOptions { + mode: 'throttle'; + throttleMode?: 'leading-trailing' | 'trailing'; + keyExtractor: (data: T) => string | number; +} + +export type EventControlOptions = + | DebounceOptions + | ThrottleOptions + | KeyedDebounceOptions + | KeyedThrottleOptions; export class EventControl { private timeoutId?: number; @@ -21,7 +36,7 @@ export class EventControl { constructor( private handler: EventHandler, - private options: EventControlOptions, + private options: DebounceOptions | ThrottleOptions, ) {} handle = (data: T): void => { @@ -77,3 +92,63 @@ export class EventControl { } } } + +/** + * Event control with independent debouncing/throttling per key. + * Useful when events carry a discriminator (like documentId) and + * you want to debounce/throttle each key's events independently. + * + * @example + * // Debounce viewport resize events independently per document + * const control = new KeyedEventControl( + * (event) => recalcZoom(event.documentId), + * { mode: 'debounce', wait: 150, keyExtractor: (e) => e.documentId } + * ); + * control.handle(event); // Each documentId gets its own 150ms debounce + */ +export class KeyedEventControl { + private controls = new Map>(); + private readonly baseOptions: DebounceOptions | ThrottleOptions; + + constructor( + private handler: EventHandler, + private options: KeyedDebounceOptions | KeyedThrottleOptions, + ) { + // Extract base options without keyExtractor for individual EventControls + this.baseOptions = { + mode: options.mode, + wait: options.wait, + ...(options.mode === 'throttle' && 'throttleMode' in options + ? { throttleMode: options.throttleMode } + : {}), + } as DebounceOptions | ThrottleOptions; + } + + handle = (data: T): void => { + const key = String(this.options.keyExtractor(data)); + + let control = this.controls.get(key); + if (!control) { + control = new EventControl(this.handler, this.baseOptions); + this.controls.set(key, control); + } + + control.handle(data); + }; + + destroy(): void { + for (const control of this.controls.values()) { + control.destroy(); + } + this.controls.clear(); + } +} + +/** + * Type guard to check if options are keyed + */ +export function isKeyedOptions( + options: EventControlOptions, +): options is KeyedDebounceOptions | KeyedThrottleOptions { + return 'keyExtractor' in options; +} diff --git a/packages/core/src/lib/utils/eventing.ts b/packages/core/src/lib/utils/eventing.ts index 268983c22..56235ca98 100644 --- a/packages/core/src/lib/utils/eventing.ts +++ b/packages/core/src/lib/utils/eventing.ts @@ -1,4 +1,9 @@ -import { EventControl, EventControlOptions } from './event-control'; +import { + EventControl, + EventControlOptions, + KeyedEventControl, + isKeyedOptions, +} from './event-control'; import { arePropsEqual } from './math'; /* ------------------------------------------------------------------ */ @@ -12,14 +17,15 @@ export type Unsubscribe = () => void; /* ------------------------------------------------------------------ */ export type EventListener = | ((listener: Listener) => Unsubscribe) - | ((listener: Listener, options?: EventControlOptions) => Unsubscribe); + | ((listener: Listener, options?: EventControlOptions) => Unsubscribe); /* ------------------------------------------------------------ */ /* helpers for typing `.on()` with an optional second argument */ /* ------------------------------------------------------------ */ export type EventHook = EventListener; + /* ------------------------------------------------------------------ */ -/* minimal “dumb” emitter (no value cache, no equality) */ +/* minimal "dumb" emitter (no value cache, no equality) */ /* ------------------------------------------------------------------ */ export interface Emitter { emit(value?: T): void; @@ -68,15 +74,22 @@ export function createBehaviorEmitter( /* -------------- helpers ----------------------------------- */ const notify = (v: T) => listeners.forEach((l) => l(v)); - const baseOn: EventHook = (listener: Listener, options?: EventControlOptions) => { + const baseOn: EventHook = (listener: Listener, options?: EventControlOptions) => { /* wrap & remember if we have control options ------------------ */ let realListener = listener; let destroy = () => {}; if (options) { - const ctl = new EventControl(listener, options); - realListener = ctl.handle as Listener; - destroy = () => ctl.destroy(); + // Check if it's keyed options + if (isKeyedOptions(options)) { + const ctl = new KeyedEventControl(listener, options); + realListener = ctl.handle as Listener; + destroy = () => ctl.destroy(); + } else { + const ctl = new EventControl(listener, options); + realListener = ctl.handle as Listener; + destroy = () => ctl.destroy(); + } proxyMap.set(listener, { wrapped: realListener, destroy }); } @@ -127,7 +140,7 @@ export function createBehaviorEmitter( /* derived hook --------------------------------------------- */ select(selector: (v: T) => U, eq: (a: U, b: U) => boolean = arePropsEqual): EventHook { - return (listener: Listener, options?: EventControlOptions) => { + return (listener: Listener, options?: EventControlOptions) => { let prev: U | undefined; /* replay */ @@ -146,7 +159,7 @@ export function createBehaviorEmitter( listener(mapped); } }, - options as EventControlOptions | undefined, + options as EventControlOptions | undefined, ); // pass control opts straight through }; }, diff --git a/packages/plugin-zoom/src/lib/zoom-plugin.ts b/packages/plugin-zoom/src/lib/zoom-plugin.ts index f2a6a4bfa..278223316 100644 --- a/packages/plugin-zoom/src/lib/zoom-plugin.ts +++ b/packages/plugin-zoom/src/lib/zoom-plugin.ts @@ -86,7 +86,11 @@ export class ZoomPlugin extends BasePlugin< // Keep automatic modes up to date per document this.viewport.onViewportResize( (event) => this.recalcAuto(event.documentId, VerticalZoomFocus.Top), - { mode: 'debounce', wait: 150 }, + { + mode: 'debounce', + wait: 150, + keyExtractor: (event) => event.documentId, + }, ); // Subscribe to spread changes From 80b1f2ef62819a74d3936bc26f301ec16606f9a4 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 12:38:55 -0700 Subject: [PATCH 057/225] fix free text --- .../src/svelte/components/annotations/FreeText.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-annotation/src/svelte/components/annotations/FreeText.svelte b/packages/plugin-annotation/src/svelte/components/annotations/FreeText.svelte index b99e99beb..1d66b3747 100644 --- a/packages/plugin-annotation/src/svelte/components/annotations/FreeText.svelte +++ b/packages/plugin-annotation/src/svelte/components/annotations/FreeText.svelte @@ -110,7 +110,7 @@ Date: Wed, 5 Nov 2025 13:00:47 -0700 Subject: [PATCH 058/225] delete showing --- .../components/AnnotationSelectionMenu.svelte | 38 ++++++++++++++----- .../svelte-tailwind/src/routes/+page.svelte | 4 +- .../components/CounterRotateContainer.svelte | 23 +++++------ packages/utils/src/svelte/components/index.ts | 3 +- packages/utils/src/svelte/components/types.ts | 4 ++ 5 files changed, 46 insertions(+), 26 deletions(-) create mode 100644 packages/utils/src/svelte/components/types.ts diff --git a/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte b/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte index f71a7f6c8..27f7ea02e 100644 --- a/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte +++ b/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte @@ -1,18 +1,35 @@ - - -{#if anchorEl} +
-{/if} +
diff --git a/examples/svelte-tailwind/src/routes/+page.svelte b/examples/svelte-tailwind/src/routes/+page.svelte index 40452c42c..3a40b4802 100644 --- a/examples/svelte-tailwind/src/routes/+page.svelte +++ b/examples/svelte-tailwind/src/routes/+page.svelte @@ -138,9 +138,9 @@ pageHeight={height} rotation={rotation} > - {#snippet selectionMenu({ annotation, selected, menuWrapperProps })} + {#snippet selectionMenu({ annotation, selected, menuWrapperProps, rect })} {#if selected} - + {/if} {/snippet} diff --git a/packages/utils/src/svelte/components/CounterRotateContainer.svelte b/packages/utils/src/svelte/components/CounterRotateContainer.svelte index a75db40eb..8a5f28097 100644 --- a/packages/utils/src/svelte/components/CounterRotateContainer.svelte +++ b/packages/utils/src/svelte/components/CounterRotateContainer.svelte @@ -2,17 +2,13 @@ import type { Snippet } from "svelte"; import {getCounterRotation} from "@embedpdf/utils"; import type {Rect, Rotation} from "@embedpdf/models"; +import type { MenuWrapperProps } from "./types"; interface CounterRotateProps { rect: Rect; rotation: Rotation; } -interface MenuWrapperProps { - style: Record; - ref: (el: HTMLDivElement | null) => void; -} - interface $$Props extends CounterRotateProps { children?: Snippet<[{ matrix: string; @@ -24,7 +20,7 @@ interface $$Props extends CounterRotateProps { let { rect, rotation, children } = $props(); const counterRotation = $derived(getCounterRotation(rect, rotation)); -let elementRef = $state(null); +let elementRef = $state(null); // Use native event listeners with capture phase to prevent event propagation $effect(() => { @@ -59,26 +55,26 @@ $effect(() => { const menuWrapperStyle = $derived({ position: 'absolute', - left: rect.origin.x, - top: rect.origin.y, + left: `${rect.origin.x}px`, + top: `${rect.origin.y}px`, transform: counterRotation.matrix, transformOrigin: '0 0', - width: counterRotation.width, - height:counterRotation.height, + width: `${counterRotation.width}px`, + height: `${counterRotation.height}px`, pointerEvents: 'none', - zIndex: 3, + zIndex: '3', }); const menuWrapperProps: MenuWrapperProps = $derived({ style: menuWrapperStyle, - ref: (el: HTMLDivElement | null) => { + ref: (el: HTMLElement | null) => { elementRef = el; }, }); - +{#if children} {@render children({ menuWrapperProps, matrix : counterRotation.matrix, @@ -87,6 +83,7 @@ const menuWrapperProps: MenuWrapperProps = $derived({ size: { width: counterRotation.width, height: counterRotation.height }, }, })} +{/if} diff --git a/packages/utils/src/svelte/components/index.ts b/packages/utils/src/svelte/components/index.ts index b6674b063..2e91578f3 100644 --- a/packages/utils/src/svelte/components/index.ts +++ b/packages/utils/src/svelte/components/index.ts @@ -1 +1,2 @@ -export { default as CounterRotate } from './CounterRotateContainer.svelte'; \ No newline at end of file +export { default as CounterRotate } from './CounterRotateContainer.svelte'; +export type { MenuWrapperProps } from './types'; \ No newline at end of file diff --git a/packages/utils/src/svelte/components/types.ts b/packages/utils/src/svelte/components/types.ts new file mode 100644 index 000000000..adad38d7f --- /dev/null +++ b/packages/utils/src/svelte/components/types.ts @@ -0,0 +1,4 @@ +export interface MenuWrapperProps { + style: Record; + ref: (el: HTMLElement | null) => void; +} From a537e80f109f820567257addbca94444a2ce5d2f Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 5 Nov 2025 13:03:13 -0700 Subject: [PATCH 059/225] fix delete positioning --- .../src/lib/components/AnnotationSelectionMenu.svelte | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte b/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte index 27f7ea02e..5a2c4ed77 100644 --- a/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte +++ b/examples/svelte-tailwind/src/lib/components/AnnotationSelectionMenu.svelte @@ -1,7 +1,7 @@ diff --git a/packages/plugin-redaction/src/vue/components/redaction-layer.vue b/packages/plugin-redaction/src/vue/components/redaction-layer.vue index 77c1393f4..a8d1f4444 100644 --- a/packages/plugin-redaction/src/vue/components/redaction-layer.vue +++ b/packages/plugin-redaction/src/vue/components/redaction-layer.vue @@ -1,33 +1,53 @@ diff --git a/packages/plugin-redaction/src/vue/components/selection-redact.vue b/packages/plugin-redaction/src/vue/components/selection-redact.vue index 70198ce7f..8ac1a5dcb 100644 --- a/packages/plugin-redaction/src/vue/components/selection-redact.vue +++ b/packages/plugin-redaction/src/vue/components/selection-redact.vue @@ -19,12 +19,13 @@ diff --git a/packages/plugin-redaction/src/vue/hooks/use-redaction.ts b/packages/plugin-redaction/src/vue/hooks/use-redaction.ts index b9920e9e5..aeb599407 100644 --- a/packages/plugin-redaction/src/vue/hooks/use-redaction.ts +++ b/packages/plugin-redaction/src/vue/hooks/use-redaction.ts @@ -1,25 +1,63 @@ -import { ref, watchEffect, readonly } from 'vue'; +import { ref, watch, computed, toValue, type MaybeRefOrGetter, ComputedRef, Ref } from 'vue'; import { useCapability, usePlugin } from '@embedpdf/core/vue'; -import { RedactionPlugin, initialState, RedactionState } from '@embedpdf/plugin-redaction'; +import { + RedactionPlugin, + initialDocumentState, + RedactionDocumentState, + RedactionScope, +} from '@embedpdf/plugin-redaction'; export const useRedactionPlugin = () => usePlugin(RedactionPlugin.id); export const useRedactionCapability = () => useCapability(RedactionPlugin.id); -export const useRedaction = () => { +/** + * Hook for redaction state for a specific document + * @param documentId Document ID (can be ref, computed, getter, or plain value) + */ +export const useRedaction = ( + documentId: MaybeRefOrGetter, +): { + state: Readonly>; + provides: ComputedRef; +} => { const { provides } = useRedactionCapability(); - const state = ref(initialState); + const state = ref(initialDocumentState); - watchEffect((onCleanup) => { - if (!provides.value) return; + watch( + [provides, () => toValue(documentId)], + ([providesValue, docId], _, onCleanup) => { + if (!providesValue) { + state.value = initialDocumentState; + return; + } - const unsubscribe = provides.value.onStateChange((newState) => { - state.value = newState; - }); - onCleanup(unsubscribe); + const scope = providesValue.forDocument(docId); + + // Set initial state + try { + state.value = scope.getState(); + } catch (e) { + // Handle case where state might not be ready + state.value = initialDocumentState; + } + + // Subscribe to changes + const unsubscribe = scope.onStateChange((newState) => { + state.value = newState; + }); + + onCleanup(unsubscribe); + }, + { immediate: true }, + ); + + const scopedProvides = computed(() => { + const docId = toValue(documentId); + return provides.value?.forDocument(docId) ?? null; }); return { - state: readonly(state), - provides, + state, + provides: scopedProvides, }; }; diff --git a/packages/plugin-render/src/vue/components/render-layer.vue b/packages/plugin-render/src/vue/components/render-layer.vue index 1c7a7ad44..3a57a64f9 100644 --- a/packages/plugin-render/src/vue/components/render-layer.vue +++ b/packages/plugin-render/src/vue/components/render-layer.vue @@ -1,107 +1,111 @@