Skip to content

Commit 3166fbd

Browse files
committed
Add scoped event emitter and refactor ThumbnailPlugin
Introduces a generic scoped event emitter utility for key-based event scoping. Refactors ThumbnailPlugin to use scoped emitters for window and scroll events, improving per-document event handling and cleanup. Updates event API usage to leverage scoped hooks and emission.
1 parent c8b1d15 commit 3166fbd

3 files changed

Lines changed: 297 additions & 22 deletions

File tree

packages/core/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './store/initial-state';
1010
export * from './store/selectors';
1111
export * from './utils/event-control';
1212
export * from './utils/eventing';
13+
export * from './utils/scoped-eventing';
1314
export * from './utils/math';
1415
export * from './utils/typed-object';
1516
export * from './plugin/builder';
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import {
2+
EventControl,
3+
EventControlOptions,
4+
KeyedEventControl,
5+
isKeyedOptions,
6+
} from './event-control';
7+
import { EventHook, Listener, Unsubscribe } from './eventing';
8+
import { arePropsEqual } from './math';
9+
10+
/* ------------------------------------------------------------------ */
11+
/* Scoped Emitter - Generic Key-Based Event Scoping */
12+
/* ------------------------------------------------------------------ */
13+
14+
/**
15+
* A scoped behavior emitter that maintains separate cached values
16+
* and listener sets per scope key.
17+
*
18+
* @typeParam TData - The scoped data type (without key context)
19+
* @typeParam TGlobalEvent - The global event type (includes key context)
20+
* @typeParam TKey - The key type (string, number, or both)
21+
*/
22+
export interface ScopedEmitter<
23+
TData = any,
24+
TGlobalEvent = { key: string; data: TData },
25+
TKey extends string | number = string | number,
26+
> {
27+
/**
28+
* Emit an event for a specific scope key.
29+
*/
30+
emit(key: TKey, data: TData): void;
31+
32+
/**
33+
* Get a scoped event hook that only receives events for this key.
34+
*/
35+
forScope(key: TKey): EventHook<TData>;
36+
37+
/**
38+
* Global event hook that receives events from all scopes.
39+
*/
40+
readonly onGlobal: EventHook<TGlobalEvent>;
41+
42+
/**
43+
* Clear all scopes' caches and listeners
44+
*/
45+
clear(): void;
46+
47+
/**
48+
* Clear a specific scope's cache and listeners
49+
*/
50+
clearScope(key: TKey): void;
51+
52+
/**
53+
* Get the current cached value for a specific scope
54+
*/
55+
getValue(key: TKey): TData | undefined;
56+
57+
/**
58+
* Get all active scope keys
59+
*/
60+
getScopes(): TKey[];
61+
}
62+
63+
/**
64+
* Creates a scoped behavior emitter with global event support.
65+
*
66+
* @param toGlobalEvent - Transform function to convert (key, data) into a global event
67+
* @param equality - Optional equality function for per-scope caching (default: arePropsEqual)
68+
*
69+
* @example
70+
* ```typescript
71+
* // Document-scoped (string keys)
72+
* const window$ = createScopedEmitter<WindowState, WindowChangeEvent, string>(
73+
* (documentId, window) => ({ documentId, window })
74+
* );
75+
*
76+
* // User-scoped (number keys)
77+
* const presence$ = createScopedEmitter<UserPresence, PresenceEvent, number>(
78+
* (userId, presence) => ({ userId, presence })
79+
* );
80+
*
81+
* // Flexible (string or number)
82+
* const data$ = createScopedEmitter<Data, DataEvent>(
83+
* (id, data) => ({ id, data })
84+
* );
85+
* ```
86+
*/
87+
export function createScopedEmitter<
88+
TData = any,
89+
TGlobalEvent = { key: string; data: TData },
90+
TKey extends string | number = string | number,
91+
>(
92+
toGlobalEvent: (key: TKey, data: TData) => TGlobalEvent,
93+
equality: (a: TData, b: TData) => boolean = arePropsEqual,
94+
): ScopedEmitter<TData, TGlobalEvent, TKey> {
95+
// Per-scope state (normalized keys as strings)
96+
const scopeCaches = new Map<string, TData>();
97+
const scopeListeners = new Map<string, Set<Listener<TData>>>();
98+
const scopeProxyMaps = new Map<
99+
string,
100+
Map<Listener<TData>, { wrapped: Listener<TData>; destroy: () => void }>
101+
>();
102+
103+
// Global listeners (no caching - only for new emissions)
104+
const globalListeners = new Set<Listener<TGlobalEvent>>();
105+
const globalProxyMap = new Map<
106+
Listener<TGlobalEvent>,
107+
{ wrapped: Listener<TGlobalEvent>; destroy: () => void }
108+
>();
109+
110+
const normalizeKey = (key: TKey): string => String(key);
111+
112+
const getOrCreateListeners = (key: string): Set<Listener<TData>> => {
113+
let listeners = scopeListeners.get(key);
114+
if (!listeners) {
115+
listeners = new Set();
116+
scopeListeners.set(key, listeners);
117+
}
118+
return listeners;
119+
};
120+
121+
const getOrCreateProxyMap = (
122+
key: string,
123+
): Map<Listener<TData>, { wrapped: Listener<TData>; destroy: () => void }> => {
124+
let proxyMap = scopeProxyMaps.get(key);
125+
if (!proxyMap) {
126+
proxyMap = new Map();
127+
scopeProxyMaps.set(key, proxyMap);
128+
}
129+
return proxyMap;
130+
};
131+
132+
const onGlobal: EventHook<TGlobalEvent> = (
133+
listener: Listener<TGlobalEvent>,
134+
options?: EventControlOptions<TGlobalEvent>,
135+
): Unsubscribe => {
136+
let realListener = listener;
137+
let destroy = () => {};
138+
139+
if (options) {
140+
if (isKeyedOptions(options)) {
141+
const ctl = new KeyedEventControl(listener, options);
142+
realListener = ctl.handle as Listener<TGlobalEvent>;
143+
destroy = () => ctl.destroy();
144+
} else {
145+
const ctl = new EventControl(listener, options);
146+
realListener = ctl.handle as Listener<TGlobalEvent>;
147+
destroy = () => ctl.destroy();
148+
}
149+
globalProxyMap.set(listener, { wrapped: realListener, destroy });
150+
}
151+
152+
globalListeners.add(realListener);
153+
154+
return () => {
155+
globalListeners.delete(realListener);
156+
destroy();
157+
globalProxyMap.delete(listener);
158+
};
159+
};
160+
161+
return {
162+
emit(key: TKey, data: TData) {
163+
const normalizedKey = normalizeKey(key);
164+
const cached = scopeCaches.get(normalizedKey);
165+
166+
// Only process if changed or first emission
167+
if (cached === undefined || !equality(cached, data)) {
168+
scopeCaches.set(normalizedKey, data);
169+
170+
// Notify per-scope listeners
171+
const listeners = scopeListeners.get(normalizedKey);
172+
if (listeners) {
173+
listeners.forEach((l) => l(data));
174+
}
175+
176+
// Notify global listeners with key context
177+
const globalEvent = toGlobalEvent(key, data);
178+
globalListeners.forEach((l) => l(globalEvent));
179+
}
180+
},
181+
182+
forScope(key: TKey): EventHook<TData> {
183+
const normalizedKey = normalizeKey(key);
184+
185+
return (listener: Listener<TData>, options?: EventControlOptions<TData>): Unsubscribe => {
186+
const listeners = getOrCreateListeners(normalizedKey);
187+
const proxyMap = getOrCreateProxyMap(normalizedKey);
188+
189+
let realListener = listener;
190+
let destroy = () => {};
191+
192+
if (options) {
193+
if (isKeyedOptions(options)) {
194+
const ctl = new KeyedEventControl(listener, options);
195+
realListener = ctl.handle as Listener<TData>;
196+
destroy = () => ctl.destroy();
197+
} else {
198+
const ctl = new EventControl(listener, options);
199+
realListener = ctl.handle as Listener<TData>;
200+
destroy = () => ctl.destroy();
201+
}
202+
proxyMap.set(listener, { wrapped: realListener, destroy });
203+
}
204+
205+
// Replay cached value for this scope
206+
const cached = scopeCaches.get(normalizedKey);
207+
if (cached !== undefined) {
208+
realListener(cached);
209+
}
210+
211+
listeners.add(realListener);
212+
213+
return () => {
214+
listeners.delete(realListener);
215+
destroy();
216+
proxyMap.delete(listener);
217+
218+
// Cleanup empty collections
219+
if (listeners.size === 0) {
220+
scopeListeners.delete(normalizedKey);
221+
}
222+
if (proxyMap.size === 0) {
223+
scopeProxyMaps.delete(normalizedKey);
224+
}
225+
};
226+
};
227+
},
228+
229+
onGlobal,
230+
231+
getValue(key: TKey): TData | undefined {
232+
return scopeCaches.get(normalizeKey(key));
233+
},
234+
235+
getScopes(): TKey[] {
236+
// Cast back to TKey array (safe because we only store what was emitted)
237+
return Array.from(scopeCaches.keys()) as TKey[];
238+
},
239+
240+
clearScope(key: TKey): void {
241+
const normalizedKey = normalizeKey(key);
242+
243+
scopeCaches.delete(normalizedKey);
244+
245+
const listeners = scopeListeners.get(normalizedKey);
246+
if (listeners) {
247+
listeners.clear();
248+
scopeListeners.delete(normalizedKey);
249+
}
250+
251+
const proxyMap = scopeProxyMaps.get(normalizedKey);
252+
if (proxyMap) {
253+
proxyMap.forEach((p) => p.destroy());
254+
proxyMap.clear();
255+
scopeProxyMaps.delete(normalizedKey);
256+
}
257+
},
258+
259+
clear(): void {
260+
scopeCaches.clear();
261+
scopeListeners.forEach((set) => set.clear());
262+
scopeListeners.clear();
263+
scopeProxyMaps.forEach((map) => {
264+
map.forEach((p) => p.destroy());
265+
map.clear();
266+
});
267+
scopeProxyMaps.clear();
268+
269+
globalListeners.clear();
270+
globalProxyMap.forEach((p) => p.destroy());
271+
globalProxyMap.clear();
272+
},
273+
};
274+
}

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

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BasePlugin,
33
createBehaviorEmitter,
44
createEmitter,
5+
createScopedEmitter,
56
Listener,
67
PluginRegistry,
78
REFRESH_PAGES,
@@ -48,9 +49,13 @@ export class ThumbnailPlugin extends BasePlugin<
4849
// Per-document auto-scroll tracking
4950
private readonly canAutoScroll = new Map<string, boolean>();
5051

51-
private readonly window$ = createBehaviorEmitter<WindowChangeEvent>();
52+
private readonly window$ = createScopedEmitter<WindowState, WindowChangeEvent, string>(
53+
(documentId, window) => ({ documentId, window }),
54+
);
55+
private readonly scrollTo$ = createScopedEmitter<ScrollToOptions, ScrollToEvent, string>(
56+
(documentId, options) => ({ documentId, options }),
57+
);
5258
private readonly refreshPages$ = createEmitter<RefreshPagesEvent>();
53-
private readonly scrollTo$ = createBehaviorEmitter<ScrollToEvent>();
5459

5560
constructor(
5661
id: string,
@@ -138,7 +143,8 @@ export class ThumbnailPlugin extends BasePlugin<
138143
}
139144

140145
this.canAutoScroll.delete(documentId);
141-
146+
this.window$.clearScope(documentId);
147+
this.scrollTo$.clearScope(documentId);
142148
this.logger.debug(
143149
'ThumbnailPlugin',
144150
'DocumentClosed',
@@ -167,8 +173,8 @@ export class ThumbnailPlugin extends BasePlugin<
167173
forDocument: (documentId: string) => this.createThumbnailScope(documentId),
168174

169175
// Events
170-
onWindow: this.window$.on,
171-
onScrollTo: this.scrollTo$.on,
176+
onWindow: this.window$.onGlobal,
177+
onScrollTo: this.scrollTo$.onGlobal,
172178
onRefreshPages: this.refreshPages$.on,
173179
};
174180
}
@@ -183,14 +189,8 @@ export class ThumbnailPlugin extends BasePlugin<
183189
renderThumb: (idx, dpr) => this.renderThumb(idx, dpr, documentId),
184190
updateWindow: (scrollY, viewportH) => this.updateWindow(scrollY, viewportH, documentId),
185191
getWindow: () => this.getWindow(documentId),
186-
onWindow: (listener: Listener<WindowState | null>) =>
187-
this.window$.on((event) => {
188-
if (event.documentId === documentId) listener(event.window);
189-
}),
190-
onScrollTo: (listener: Listener<ScrollToOptions>) =>
191-
this.scrollTo$.on((event) => {
192-
if (event.documentId === documentId) listener(event.options);
193-
}),
192+
onWindow: this.window$.forScope(documentId),
193+
onScrollTo: this.scrollTo$.forScope(documentId),
194194
onRefreshPages: (listener: Listener<number[]>) =>
195195
this.refreshPages$.on((event) => {
196196
if (event.documentId === documentId) listener(event.pages);
@@ -266,7 +266,7 @@ export class ThumbnailPlugin extends BasePlugin<
266266
if (docState.viewportH > 0) {
267267
this.updateWindow(docState.scrollY, docState.viewportH, documentId);
268268
} else {
269-
this.window$.emit({ documentId, window });
269+
this.window$.emit(documentId, window);
270270
}
271271
}
272272

@@ -311,7 +311,7 @@ export class ThumbnailPlugin extends BasePlugin<
311311
};
312312

313313
this.dispatch(setWindowState(id, newWindow));
314-
this.window$.emit({ documentId: id, window: newWindow });
314+
this.window$.emit(id, newWindow);
315315
}
316316

317317
private getWindow(documentId?: string): WindowState | null {
@@ -333,7 +333,7 @@ export class ThumbnailPlugin extends BasePlugin<
333333
if (docState.viewportH <= 0) {
334334
// Center the thumbnail in the viewport
335335
const top = Math.max(PADDING_Y, item.top - item.wrapperHeight);
336-
this.scrollTo$.emit({ documentId: id, options: { top, behavior } });
336+
this.scrollTo$.emit(id, { top, behavior });
337337
return;
338338
}
339339

@@ -345,14 +345,14 @@ export class ThumbnailPlugin extends BasePlugin<
345345
const needsDown = bottom > docState.scrollY + docState.viewportH - margin;
346346

347347
if (needsUp) {
348-
this.scrollTo$.emit({
349-
documentId: id,
350-
options: { top: Math.max(0, top - PADDING_Y), behavior },
348+
this.scrollTo$.emit(id, {
349+
top: Math.max(0, top - PADDING_Y),
350+
behavior,
351351
});
352352
} else if (needsDown) {
353-
this.scrollTo$.emit({
354-
documentId: id,
355-
options: { top: Math.max(0, bottom - docState.viewportH + PADDING_Y), behavior },
353+
this.scrollTo$.emit(id, {
354+
top: Math.max(0, bottom - docState.viewportH + PADDING_Y),
355+
behavior,
356356
});
357357
}
358358
}

0 commit comments

Comments
 (0)