Skip to content

Commit fd140b9

Browse files
committed
Add eviction to cache to safe memory on document with many pages
1 parent cf83ccd commit fd140b9

4 files changed

Lines changed: 106 additions & 1 deletion

File tree

packages/plugin-selection/src/lib/actions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const END_SELECTION = 'SELECTION/END_SELECTION';
1111
export const CLEAR_SELECTION = 'SELECTION/CLEAR_SELECTION';
1212
export const SET_RECTS = 'SELECTION/SET_RECTS';
1313
export const SET_SLICES = 'SELECTION/SET_SLICES';
14+
export const EVICT_PAGE_GEOMETRY = 'SELECTION/EVICT_PAGE_GEOMETRY';
1415
export const RESET = 'SELECTION/RESET'; // This might be obsolete, but we'll keep it for now
1516

1617
export interface InitSelectionStateAction extends Action {
@@ -60,6 +61,11 @@ export interface SetSlicesAction extends Action {
6061
payload: { documentId: string; slices: Record<number, { start: number; count: number }> };
6162
}
6263

64+
export interface EvictPageGeometryAction extends Action {
65+
type: typeof EVICT_PAGE_GEOMETRY;
66+
payload: { documentId: string; pages: number[] };
67+
}
68+
6369
export interface ResetAction extends Action {
6470
type: typeof RESET;
6571
payload: { documentId: string };
@@ -75,6 +81,7 @@ export type SelectionAction =
7581
| ClearSelectionAction
7682
| SetRectsAction
7783
| SetSlicesAction
84+
| EvictPageGeometryAction
7885
| ResetAction;
7986

8087
export const initSelectionState = (
@@ -132,6 +139,14 @@ export const setSlices = (
132139
slices: Record<number, { start: number; count: number }>,
133140
): SetSlicesAction => ({ type: SET_SLICES, payload: { documentId, slices } });
134141

142+
export const evictPageGeometry = (
143+
documentId: string,
144+
pages: number[],
145+
): EvictPageGeometryAction => ({
146+
type: EVICT_PAGE_GEOMETRY,
147+
payload: { documentId, pages },
148+
});
149+
135150
export const reset = (documentId: string): ResetAction => ({
136151
type: RESET,
137152
payload: { documentId },

packages/plugin-selection/src/lib/reducer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SET_RECTS,
1212
INIT_SELECTION_STATE,
1313
CLEANUP_SELECTION_STATE,
14+
EVICT_PAGE_GEOMETRY,
1415
} from './actions';
1516

1617
export const initialSelectionDocumentState: SelectionDocumentState = {
@@ -121,6 +122,21 @@ export const selectionReducer = (state = initialState, action: SelectionAction):
121122
return updateDocState(state, documentId, { ...docState, slices });
122123
}
123124

125+
case EVICT_PAGE_GEOMETRY: {
126+
const { documentId, pages } = action.payload;
127+
const docState = state.documents[documentId];
128+
if (!docState) return state;
129+
const geometry = { ...docState.geometry };
130+
const rects = { ...docState.rects };
131+
const slices = { ...docState.slices };
132+
for (const p of pages) {
133+
delete geometry[p];
134+
delete rects[p];
135+
delete slices[p];
136+
}
137+
return updateDocState(state, documentId, { ...docState, geometry, rects, slices });
138+
}
139+
124140
case RESET: {
125141
const { documentId } = action.payload;
126142
const docState = state.documents[documentId];

packages/plugin-selection/src/lib/selection-plugin.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ScrollCapability, ScrollPlugin } from '@embedpdf/plugin-scroll';
2828

2929
import {
3030
cachePageGeometry,
31+
evictPageGeometry,
3132
setSelection,
3233
SelectionAction,
3334
endSelection,
@@ -93,6 +94,9 @@ export class SelectionPlugin extends BasePlugin<
9394
/** Page callbacks for rect updates, per document */
9495
private pageCallbacks = new Map<string, Map<number, (data: SelectionRectsCallback) => void>>();
9596

97+
/** LRU access order for geometry cache, per document (oldest first) */
98+
private geoAccessOrder = new Map<string, number[]>();
99+
96100
private readonly menuPlacement$ = createScopedEmitter<
97101
SelectionMenuPlacement | null,
98102
SelectionMenuPlacementEvent,
@@ -229,6 +233,7 @@ export class SelectionPlugin extends BasePlugin<
229233
]),
230234
);
231235
this.pageCallbacks.set(documentId, new Map());
236+
this.geoAccessOrder.set(documentId, []);
232237
this.selecting.set(documentId, false);
233238
this.anchor.set(documentId, undefined);
234239
this.hasTextAnchor.set(documentId, false);
@@ -238,6 +243,7 @@ export class SelectionPlugin extends BasePlugin<
238243
this.dispatch(cleanupSelectionState(documentId));
239244
this.enabledModesPerDoc.delete(documentId);
240245
this.pageCallbacks.delete(documentId);
246+
this.geoAccessOrder.delete(documentId);
241247
this.selecting.delete(documentId);
242248
this.hasTextAnchor.delete(documentId);
243249
this.anchor.delete(documentId);
@@ -395,6 +401,27 @@ export class SelectionPlugin extends BasePlugin<
395401
boundingRect: selector.selectBoundingRectForPage(docState, pageIndex),
396402
});
397403

404+
// When geometry arrives (possibly re-fetched after eviction), recompute
405+
// rects for this page if it falls within an active selection.
406+
geoTask.wait((geo) => {
407+
const currentState = this.getDocumentState(documentId);
408+
const sel = currentState.selection;
409+
if (!sel || pageIndex < sel.start.page || pageIndex > sel.end.page) return;
410+
411+
const sb = sliceBounds(sel, geo, pageIndex);
412+
if (!sb) return;
413+
414+
const pageRects = rectsWithinSlice(geo, sb.from, sb.to);
415+
this.dispatch(setRects(documentId, { ...currentState.rects, [pageIndex]: pageRects }));
416+
this.dispatch(
417+
setSlices(documentId, {
418+
...currentState.slices,
419+
[pageIndex]: { start: sb.from, count: sb.to - sb.from + 1 },
420+
}),
421+
);
422+
this.notifyPage(documentId, pageIndex);
423+
}, ignore);
424+
398425
// Create text selection handler
399426
const textHandler = createTextSelectionHandler({
400427
getGeometry: () => this.getDocumentState(documentId).geometry[pageIndex],
@@ -690,18 +717,59 @@ export class SelectionPlugin extends BasePlugin<
690717
const task = this.engine.getPageGeometry(coreDoc.document, page);
691718
task.wait((geo) => {
692719
this.dispatch(cachePageGeometry(documentId, pageIdx, geo));
720+
this.touchGeometry(documentId, pageIdx);
693721
}, ignore);
694722
return task;
695723
}
696724

697725
/* ── geometry cache ───────────────────────────────────── */
698726
private getOrLoadGeometry(documentId: string, pageIdx: number): PdfTask<PdfPageGeometry> {
699727
const cached = this.getDocumentState(documentId).geometry[pageIdx];
700-
if (cached) return PdfTaskHelper.resolve(cached);
728+
if (cached) {
729+
this.touchGeometry(documentId, pageIdx);
730+
return PdfTaskHelper.resolve(cached);
731+
}
701732

702733
return this.getNewPageGeometryAndCache(documentId, pageIdx);
703734
}
704735

736+
/* ── geometry LRU eviction ──────────────────────────────── */
737+
738+
private touchGeometry(documentId: string, pageIdx: number): void {
739+
const order = this.geoAccessOrder.get(documentId);
740+
if (!order) return;
741+
742+
const idx = order.indexOf(pageIdx);
743+
if (idx > -1) order.splice(idx, 1);
744+
order.push(pageIdx);
745+
746+
this.evictGeometryIfNeeded(documentId);
747+
}
748+
749+
private evictGeometryIfNeeded(documentId: string): void {
750+
const max = this.config.maxCachedGeometries ?? 50;
751+
const order = this.geoAccessOrder.get(documentId);
752+
if (!order || order.length <= max) return;
753+
754+
const pinned = this.pageCallbacks.get(documentId);
755+
const toEvict: number[] = [];
756+
757+
while (order.length - toEvict.length > max) {
758+
const candidate = order.find((p) => !toEvict.includes(p) && !pinned?.has(p));
759+
if (candidate === undefined) break;
760+
toEvict.push(candidate);
761+
}
762+
763+
if (toEvict.length === 0) return;
764+
765+
for (const p of toEvict) {
766+
const idx = order.indexOf(p);
767+
if (idx > -1) order.splice(idx, 1);
768+
}
769+
770+
this.dispatch(evictPageGeometry(documentId, toEvict));
771+
}
772+
705773
/* ── selection state updates ───────────────────────────── */
706774
private beginSelection(documentId: string, page: number, index: number, modeId: string) {
707775
this.selecting.set(documentId, true);

packages/plugin-selection/src/lib/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export interface SelectionPluginConfig extends BasePluginConfig {
3131
* @default 3
3232
*/
3333
minSelectionDragDistance?: number;
34+
/**
35+
* Maximum number of pages whose geometry data is kept in memory per document.
36+
* Oldest unused pages are evicted when this limit is exceeded.
37+
* @default 50
38+
*/
39+
maxCachedGeometries?: number;
3440
}
3541

3642
export interface SelectionMenuPlacement {

0 commit comments

Comments
 (0)