Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/annotation-marquee-mode-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@embedpdf/plugin-annotation': minor
---

Added `modeId` filtering to marquee end event handler so annotation selection only triggers during `pointerMode`, preventing interference with redaction marquees. Added page activity claims (`annotation-selection` topic) when selecting/deselecting annotations for scroll plugin page elevation.
5 changes: 5 additions & 0 deletions .changeset/interaction-manager-page-activity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@embedpdf/plugin-interaction-manager': minor
---

Added topic-based page activity tracking system. New methods `claimPageActivity`, `releasePageActivity`, and `hasPageActivity` on both `InteractionManagerCapability` and `InteractionManagerScope`. New `onPageActivityChange` event and `PageActivityChangeEvent` type. Topics are named strings (e.g. 'annotation-selection', 'selection-menu') that can be active on one page at a time per document, automatically moving when re-claimed on a different page.
5 changes: 5 additions & 0 deletions .changeset/redaction-empty-space-deselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@embedpdf/plugin-redaction': minor
---

Subscribe to selection plugin's `onEmptySpaceClick` event to deselect pending redactions when the user clicks on empty page space. Restores background-click-to-deselect behavior that was lost during the marquee unification.
5 changes: 5 additions & 0 deletions .changeset/redaction-unified-marquee.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@embedpdf/plugin-redaction': minor
---

Unified marquee redaction with the selection plugin's marquee infrastructure. Removed standalone `createMarqueeHandler`, `registerMarqueeOnPage`, `RegisterMarqueeOnPageOptions`, and `MarqueeRedactCallback`. Marquee redaction now subscribes to selection plugin's `onMarqueeChange` and `onMarqueeEnd` events and forwards them via new `onRedactionMarqueeChange` method. Enabled marquee for `RedactionMode.Redact` and `RedactionMode.MarqueeRedact` modes via `enableForMode`. Added page activity claims (`redaction-selection` topic) in legacy mode for scroll plugin page elevation.
5 changes: 5 additions & 0 deletions .changeset/scroll-page-elevation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@embedpdf/plugin-scroll': minor
---

Added page elevation support driven by interaction manager page activity. New `elevated` boolean on `PageLayout` interface. Scroll plugin subscribes to `onPageActivityChange` and tracks elevated pages per document. Scroller components (React, Svelte, Vue) apply `zIndex: 1` and `position: relative` on page containers when `layout.elevated` is true. Added optional dependency on `@embedpdf/plugin-interaction-manager`.
5 changes: 5 additions & 0 deletions .changeset/selection-empty-space-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@embedpdf/plugin-selection': minor
---

Added `onEmptySpaceClick` event to `SelectionScope` and `SelectionCapability`. Fires when the user clicks directly on the page background (empty space) rather than on a child element. Detection runs before mode-gating so it fires for all modes regardless of whether text or marquee selection is enabled. New `EmptySpaceClickEvent` and `EmptySpaceClickScopeEvent` type exports.
7 changes: 7 additions & 0 deletions .changeset/selection-mode-marquee-unification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@embedpdf/plugin-selection': minor
---

Unified text selection and marquee selection under the `enableForMode` API. Extended `EnableForModeOptions` with `enableSelection`, `showSelectionRects`, `enableMarquee`, and `showMarqueeRects` options. Deprecated `showRects` (use `showSelectionRects`), `setMarqueeEnabled`, and `isMarqueeEnabled` (use `enableForMode` with `enableMarquee`). Added `modeId` to `SelectionChangeEvent`, `BeginSelectionEvent`, `EndSelectionEvent`, `MarqueeChangeEvent`, `MarqueeEndEvent`, and their scoped counterparts. Marquee handler now uses `registerAlways` so any plugin can enable marquee for their mode. Removed `stopImmediatePropagation` from text selection handler in favor of `isTextSelecting` coordination.

Refactored `SelectionLayer` into a thin orchestrator that composes the new `TextSelection` component and existing `MarqueeSelection` component. Consumers no longer need to render `MarqueeSelection` separately -- `SelectionLayer` now includes both text and marquee selection. Added new `TextSelection` export for advanced standalone usage. Added `textStyle` and `marqueeStyle` props to `SelectionLayer` for consistent CSS-standard styling (`background`, `borderColor`, `borderStyle`). `MarqueeSelection` updated with CSS-standard props (`background`, `borderColor`, `borderStyle`); old `stroke` and `fill` props deprecated. New `TextSelectionStyle` and `MarqueeSelectionStyle` type exports.
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/svelte';
import { ExportPluginPackage } from '@embedpdf/plugin-export/svelte';
import { PrintPluginPackage } from '@embedpdf/plugin-print/svelte';
import {
SelectionLayer,
SelectionPluginPackage,
MarqueeSelection,
} from '@embedpdf/plugin-selection/svelte';
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/svelte';
import { SearchLayer, SearchPluginPackage } from '@embedpdf/plugin-search/svelte';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/svelte';
import { MarqueeCapture, CapturePluginPackage } from '@embedpdf/plugin-capture/svelte';
Expand Down Expand Up @@ -324,7 +320,6 @@
selectionMenu={annotationMenu.renderFn}
groupSelectionMenu={groupAnnotationMenu.renderFn}
/>
<MarqueeSelection {documentId} pageIndex={page.pageIndex} />
</PagePointerProvider>
</Rotate>
{/snippet}
Expand Down
3 changes: 1 addition & 2 deletions examples/vue-tailwind/src/components/ViewerSchemaLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
:selectionMenu="annotationMenu"
:groupSelectionMenu="groupAnnotationMenu"
/>
<MarqueeSelection :documentId="documentId" :pageIndex="page.pageIndex" />
</PagePointerProvider>
</Rotate>
</Scroller>
Expand Down Expand Up @@ -96,7 +95,7 @@ import { TilingLayer } from '@embedpdf/plugin-tiling/vue';
import { SearchLayer } from '@embedpdf/plugin-search/vue';
import { MarqueeZoom, ZoomGestureWrapper } from '@embedpdf/plugin-zoom/vue';
import { MarqueeCapture } from '@embedpdf/plugin-capture/vue';
import { SelectionLayer, MarqueeSelection } from '@embedpdf/plugin-selection/vue';
import { SelectionLayer } from '@embedpdf/plugin-selection/vue';
import { RedactionLayer } from '@embedpdf/plugin-redaction/vue';
import { AnnotationLayer } from '@embedpdf/plugin-annotation/vue';
import LoadingSpinner from './LoadingSpinner.vue';
Expand Down
41 changes: 39 additions & 2 deletions packages/plugin-annotation/src/lib/annotation-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,11 @@ export class AnnotationPlugin extends BasePlugin<
for (const tool of this.state.tools) {
if (tool.interaction.textSelection) {
// Text markup tools render their own highlight preview, so suppress selection layer rects
this.selection.enableForMode(tool.interaction.mode ?? tool.id, { showRects: false });
this.selection.enableForMode(tool.interaction.mode ?? tool.id, {
showSelectionRects: false,
enableSelection: true,
enableMarquee: false,
});
}
}
}
Expand Down Expand Up @@ -241,7 +245,10 @@ export class AnnotationPlugin extends BasePlugin<

// Subscribe to marquee selection end events from the selection plugin
// When a marquee selection completes, find and select intersecting annotations
this.selection?.onMarqueeEnd(({ documentId, pageIndex, rect }) => {
this.selection?.onMarqueeEnd(({ documentId, pageIndex, rect, modeId }) => {
// Only select annotations during pointer mode marquee, not during redaction etc.
if (modeId !== 'pointerMode') return;

const docState = this.state.documents[documentId];
if (!docState) return;

Expand Down Expand Up @@ -470,6 +477,11 @@ export class AnnotationPlugin extends BasePlugin<
tool: this.getActiveTool(documentId),
});
}

// Update page activity when selection changes
if (prevDoc?.selectedUids !== nextDoc.selectedUids) {
this.updateAnnotationSelectionActivity(documentId, nextDoc);
}
}
}

Expand Down Expand Up @@ -946,11 +958,36 @@ export class AnnotationPlugin extends BasePlugin<
// Normal single selection
this.dispatch(selectAnnotation(docId, pageIndex, id));
}

// Page activity is managed centrally in onStoreUpdated via updateAnnotationSelectionActivity
}

private deselectAnnotation(documentId?: string) {
const docId = documentId ?? this.getActiveDocumentId();
this.dispatch(deselectAnnotation(docId));
// Page activity is managed centrally in onStoreUpdated via updateAnnotationSelectionActivity
}

/**
* Derive page activity from the current annotation selection.
* Called from onStoreUpdated whenever selectedUids changes,
* so ALL selection code paths are covered automatically.
*/
private updateAnnotationSelectionActivity(docId: string, docState: AnnotationDocumentState) {
if (docState.selectedUids.length === 0) {
this.interactionManager?.releasePageActivity(docId, 'annotation-selection');
return;
}
// Claim for the page of the first selected annotation
const firstUid = docState.selectedUids[0];
const ta = docState.byUid[firstUid];
if (ta) {
this.interactionManager?.claimPageActivity(
docId,
'annotation-selection',
ta.object.pageIndex,
);
}
}

// ─────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ModeChangeEvent,
CursorChangeEvent,
StateChangeEvent,
PageActivityChangeEvent,
InteractionDocumentState,
InteractionManagerScope,
} from './types';
Expand Down Expand Up @@ -78,11 +79,15 @@ export class InteractionManagerPlugin extends BasePlugin<
private alwaysGlobal = new Map<string, Set<PointerEventHandlersWithLifecycle>>();
private alwaysPage = new Map<string, Map<number, Set<PointerEventHandlersWithLifecycle>>>();

// Per-document page activities: documentId -> Map<topic, pageIndex>
private pageActivities = new Map<string, Map<string, number>>();

// Event emitters
private readonly onModeChange$ = createEmitter<ModeChangeEvent>();
private readonly onHandlerChange$ = createEmitter<InteractionManagerState>();
private readonly onCursorChange$ = createEmitter<CursorChangeEvent>();
private readonly onStateChange$ = createBehaviorEmitter<StateChangeEvent>();
private readonly onPageActivityChange$ = createEmitter<PageActivityChangeEvent>();

constructor(id: string, registry: PluginRegistry, config: InteractionManagerPluginConfig) {
super(id, registry);
Expand Down Expand Up @@ -120,6 +125,7 @@ export class InteractionManagerPlugin extends BasePlugin<
this.buckets.set(documentId, new Map());
this.alwaysGlobal.set(documentId, new Set());
this.alwaysPage.set(documentId, new Map());
this.pageActivities.set(documentId, new Map());

// Initialize buckets for all registered modes
const docBuckets = this.buckets.get(documentId)!;
Expand All @@ -135,11 +141,23 @@ export class InteractionManagerPlugin extends BasePlugin<
}

protected override onDocumentClosed(documentId: string): void {
// Emit release events for any remaining page activities
const topics = this.pageActivities.get(documentId);
if (topics) {
// Collect unique pages that have topics
const activePages = new Set(topics.values());
topics.clear();
for (const pageIndex of activePages) {
this.onPageActivityChange$.emit({ documentId, pageIndex, hasActivity: false });
}
}

// Cleanup per-document data structures
this.cursorClaims.delete(documentId);
this.buckets.delete(documentId);
this.alwaysGlobal.delete(documentId);
this.alwaysPage.delete(documentId);
this.pageActivities.delete(documentId);

// Cleanup state
this.dispatch(cleanupInteractionState(documentId));
Expand Down Expand Up @@ -192,11 +210,20 @@ export class InteractionManagerPlugin extends BasePlugin<
removeExclusionAttribute: (attribute: string) =>
this.dispatch(removeExclusionAttribute(attribute)),

// Page activity
claimPageActivity: (documentId: string, topic: string, pageIndex: number) =>
this.claimPageActivity(documentId, topic, pageIndex),
releasePageActivity: (documentId: string, topic: string) =>
this.releasePageActivity(documentId, topic),
hasPageActivity: (documentId: string, pageIndex: number) =>
this.hasPageActivity(documentId, pageIndex),

// Events
onModeChange: this.onModeChange$.on,
onCursorChange: this.onCursorChange$.on,
onHandlerChange: this.onHandlerChange$.on,
onStateChange: this.onStateChange$.on,
onPageActivityChange: this.onPageActivityChange$.on,
};
}

Expand All @@ -220,6 +247,10 @@ export class InteractionManagerPlugin extends BasePlugin<
resume: () => this.resume(documentId),
isPaused: () => this.isPaused(documentId),
getState: () => this.getDocumentStateOrThrow(documentId),
claimPageActivity: (topic: string, pageIndex: number) =>
this.claimPageActivity(documentId, topic, pageIndex),
releasePageActivity: (topic: string) => this.releasePageActivity(documentId, topic),
hasPageActivity: (pageIndex: number) => this.hasPageActivity(documentId, pageIndex),
onModeChange: (listener: Listener<string>) =>
this.onModeChange$.on((event) => {
if (event.documentId === documentId) listener(event.activeMode);
Expand All @@ -232,6 +263,11 @@ export class InteractionManagerPlugin extends BasePlugin<
this.onStateChange$.on((event) => {
if (event.documentId === documentId) listener(event.state);
}),
onPageActivityChange: (listener: Listener<{ pageIndex: number; hasActivity: boolean }>) =>
this.onPageActivityChange$.on((event) => {
if (event.documentId === documentId)
listener({ pageIndex: event.pageIndex, hasActivity: event.hasActivity });
}),
};
}

Expand Down Expand Up @@ -505,6 +541,76 @@ export class InteractionManagerPlugin extends BasePlugin<
}
}

// ─────────────────────────────────────────────────────────
// Page Activity Management
// ─────────────────────────────────────────────────────────

private claimPageActivity(documentId: string, topic: string, pageIndex: number): void {
let topics = this.pageActivities.get(documentId);
if (!topics) {
topics = new Map();
this.pageActivities.set(documentId, topics);
}

const oldPage = topics.get(topic);

// No-op if already on the same page
if (oldPage === pageIndex) return;

// Set new page
topics.set(topic, pageIndex);

// Release old page if it existed and now has no topics
if (oldPage !== undefined && !this.pageHasAnyTopic(documentId, oldPage)) {
this.onPageActivityChange$.emit({ documentId, pageIndex: oldPage, hasActivity: false });
}

// Emit for new page if it just gained its first topic
if (this.countTopicsOnPage(documentId, pageIndex) === 1) {
this.onPageActivityChange$.emit({ documentId, pageIndex, hasActivity: true });
}
}

private releasePageActivity(documentId: string, topic: string): void {
const topics = this.pageActivities.get(documentId);
if (!topics) return;

const page = topics.get(topic);
if (page === undefined) return;

topics.delete(topic);

// If page has no more topics, emit
if (!this.pageHasAnyTopic(documentId, page)) {
this.onPageActivityChange$.emit({ documentId, pageIndex: page, hasActivity: false });
}
}

private hasPageActivity(documentId: string, pageIndex: number): boolean {
return this.pageHasAnyTopic(documentId, pageIndex);
}

/** Helper: does any topic point to this page? */
private pageHasAnyTopic(documentId: string, pageIndex: number): boolean {
const topics = this.pageActivities.get(documentId);
if (!topics) return false;
for (const p of topics.values()) {
if (p === pageIndex) return true;
}
return false;
}

/** Helper: count topics on a page */
private countTopicsOnPage(documentId: string, pageIndex: number): number {
const topics = this.pageActivities.get(documentId);
if (!topics) return 0;
let count = 0;
for (const p of topics.values()) {
if (p === pageIndex) count++;
}
return count;
}

// ─────────────────────────────────────────────────────────
// Handler Lifecycle Notifications
// ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -616,10 +722,12 @@ export class InteractionManagerPlugin extends BasePlugin<
}

async destroy(): Promise<void> {
this.pageActivities.clear();
this.onModeChange$.clear();
this.onCursorChange$.clear();
this.onHandlerChange$.clear();
this.onStateChange$.clear();
this.onPageActivityChange$.clear();
await super.destroy();
}
}
17 changes: 17 additions & 0 deletions packages/plugin-interaction-manager/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ export interface StateChangeEvent {
state: InteractionDocumentState;
}

export interface PageActivityChangeEvent {
documentId: string;
pageIndex: number;
/** Whether this page now has any active topics */
hasActivity: boolean;
}

// Scoped interaction capability
export interface InteractionManagerScope {
getActiveMode(): string;
Expand All @@ -169,9 +176,13 @@ export interface InteractionManagerScope {
resume(): void;
isPaused(): boolean;
getState(): InteractionDocumentState;
claimPageActivity(topic: string, pageIndex: number): void;
releasePageActivity(topic: string): void;
hasPageActivity(pageIndex: number): boolean;
onModeChange: EventHook<string>;
onCursorChange: EventHook<string>;
onStateChange: EventHook<InteractionDocumentState>;
onPageActivityChange: EventHook<{ pageIndex: number; hasActivity: boolean }>;
}

export interface InteractionManagerCapability {
Expand Down Expand Up @@ -206,9 +217,15 @@ export interface InteractionManagerCapability {
addExclusionAttribute(attribute: string): void;
removeExclusionAttribute(attribute: string): void;

// Page activity
claimPageActivity(documentId: string, topic: string, pageIndex: number): void;
releasePageActivity(documentId: string, topic: string): void;
hasPageActivity(documentId: string, pageIndex: number): boolean;

// Events (all include documentId)
onModeChange: EventHook<ModeChangeEvent>;
onCursorChange: EventHook<CursorChangeEvent>;
onHandlerChange: EventHook<InteractionManagerState>;
onStateChange: EventHook<StateChangeEvent>;
onPageActivityChange: EventHook<PageActivityChangeEvent>;
}
3 changes: 2 additions & 1 deletion packages/plugin-redaction/src/lib/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './marquee-redact.handler';
// handlers barrel - marquee-redact.handler.ts has been removed
// (marquee redaction is now handled via the selection plugin's marquee infrastructure)
Loading