canvasengine 2.0.0-beta.42 → 2.0.0-beta.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{DebugRenderer-BosXI2Pd.js → DebugRenderer-K2IZBznP.js} +2 -2
- package/dist/{DebugRenderer-BosXI2Pd.js.map → DebugRenderer-K2IZBznP.js.map} +1 -1
- package/dist/components/Button.d.ts +3 -0
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/DOMElement.d.ts.map +1 -1
- package/dist/components/Graphic.d.ts +1 -1
- package/dist/components/Graphic.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/types/DisplayObject.d.ts +12 -16
- package/dist/components/types/DisplayObject.d.ts.map +1 -1
- package/dist/directives/FocusNavigation.d.ts +71 -0
- package/dist/directives/FocusNavigation.d.ts.map +1 -0
- package/dist/directives/KeyboardControls.d.ts.map +1 -1
- package/dist/directives/ViewportFollow.d.ts.map +1 -1
- package/dist/engine/FocusManager.d.ts +174 -0
- package/dist/engine/FocusManager.d.ts.map +1 -0
- package/dist/engine/reactive.d.ts.map +1 -1
- package/dist/hooks/useFocus.d.ts +61 -0
- package/dist/hooks/useFocus.d.ts.map +1 -0
- package/dist/{index-DNwqVzaq.js → index-B4hYyfVE.js} +5469 -4676
- package/dist/index-B4hYyfVE.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.global.js +7 -7
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +66 -60
- package/package.json +2 -2
- package/src/components/Button.ts +7 -4
- package/src/components/Canvas.ts +1 -1
- package/src/components/DOMContainer.ts +27 -2
- package/src/components/DOMElement.ts +37 -29
- package/src/components/DisplayObject.ts +15 -3
- package/src/components/FocusContainer.ts +372 -0
- package/src/components/Graphic.ts +43 -48
- package/src/components/Sprite.ts +4 -2
- package/src/components/Viewport.ts +65 -26
- package/src/components/index.ts +2 -1
- package/src/components/types/DisplayObject.ts +7 -4
- package/src/directives/Controls.ts +1 -1
- package/src/directives/ControlsBase.ts +1 -1
- package/src/directives/FocusNavigation.ts +252 -0
- package/src/directives/KeyboardControls.ts +12 -8
- package/src/directives/ViewportFollow.ts +8 -5
- package/src/engine/FocusManager.ts +495 -0
- package/src/engine/reactive.ts +20 -19
- package/src/hooks/useFocus.ts +94 -0
- package/src/index.ts +2 -0
- package/dist/index-DNwqVzaq.js.map +0 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { isSignal, signal, Signal } from "@signe/reactive";
|
|
2
|
+
import { Element, isElementFrozen } from "./reactive";
|
|
3
|
+
import { CanvasViewport } from "../components/Viewport";
|
|
4
|
+
import { SignalOrPrimitive } from "../components/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for scroll behavior when navigating to focused elements
|
|
8
|
+
*
|
|
9
|
+
* @property padding - Padding around the element in pixels (default: 0)
|
|
10
|
+
* @property smooth - Enable smooth scrolling animation (default: false)
|
|
11
|
+
* @property center - Center the element in the viewport (default: true)
|
|
12
|
+
* @property duration - Animation duration in ms if smooth=true (default: 300)
|
|
13
|
+
*/
|
|
14
|
+
export interface ScrollOptions {
|
|
15
|
+
padding?: number;
|
|
16
|
+
smooth?: boolean;
|
|
17
|
+
center?: boolean;
|
|
18
|
+
duration?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Data structure for a focus container
|
|
23
|
+
*/
|
|
24
|
+
interface FocusContainerData {
|
|
25
|
+
id: string;
|
|
26
|
+
element?: Element;
|
|
27
|
+
focusables: Map<number, Element>;
|
|
28
|
+
currentIndex: Signal<number | null>;
|
|
29
|
+
focusedElement: Signal<Element | null>;
|
|
30
|
+
onFocusChange?: (index: number, element: Element | null) => void;
|
|
31
|
+
autoScroll?: boolean | ScrollOptions;
|
|
32
|
+
viewport?: CanvasViewport;
|
|
33
|
+
throttle?: number;
|
|
34
|
+
lastNavigateTime?: number;
|
|
35
|
+
tabindex?: SignalOrPrimitive<number>;
|
|
36
|
+
tabindexSubscription?: any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Central manager for focus navigation system
|
|
41
|
+
*
|
|
42
|
+
* Manages focusable elements within containers, handles navigation,
|
|
43
|
+
* and provides scroll integration with Viewport.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* const manager = FocusManager.getInstance();
|
|
48
|
+
* manager.registerContainer('menu', containerData);
|
|
49
|
+
* manager.navigate('menu', 'next');
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export class FocusManager {
|
|
53
|
+
private static instance: FocusManager | null = null;
|
|
54
|
+
private containers: Map<string, FocusContainerData> = new Map();
|
|
55
|
+
private scrollAnimations: Map<string, { startTime: number; startX: number; startY: number; targetX: number; targetY: number; duration: number }> = new Map();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the singleton instance of FocusManager
|
|
59
|
+
*
|
|
60
|
+
* @returns The FocusManager instance
|
|
61
|
+
*/
|
|
62
|
+
static getInstance(): FocusManager {
|
|
63
|
+
if (!FocusManager.instance) {
|
|
64
|
+
FocusManager.instance = new FocusManager();
|
|
65
|
+
}
|
|
66
|
+
return FocusManager.instance;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Register a focus container
|
|
71
|
+
*
|
|
72
|
+
* @param id - Unique identifier for the container
|
|
73
|
+
* @param data - Container data including signals and callbacks
|
|
74
|
+
*/
|
|
75
|
+
registerContainer(id: string, data: Omit<FocusContainerData, 'id'>): void {
|
|
76
|
+
this.containers.set(id, { ...data, id });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Update a focus container's data
|
|
81
|
+
*
|
|
82
|
+
* @param id - Container identifier
|
|
83
|
+
* @param data - Partial container data to update
|
|
84
|
+
*/
|
|
85
|
+
updateContainer(id: string, data: Partial<Omit<FocusContainerData, 'id'>>): void {
|
|
86
|
+
const container = this.containers.get(id);
|
|
87
|
+
if (container) {
|
|
88
|
+
this.containers.set(id, { ...container, ...data });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setTabindex(id: string, tabindex: SignalOrPrimitive<number>): void {
|
|
93
|
+
const container = this.containers.get(id);
|
|
94
|
+
if (!container) return;
|
|
95
|
+
|
|
96
|
+
// Cleanup previous subscription
|
|
97
|
+
if (container.tabindexSubscription) {
|
|
98
|
+
container.tabindexSubscription.unsubscribe();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
container.tabindex = tabindex;
|
|
102
|
+
|
|
103
|
+
if (isSignal(tabindex)) {
|
|
104
|
+
container.tabindexSubscription = (tabindex as Signal<number>).observable.subscribe((value: any) => {
|
|
105
|
+
if (value !== null && value !== container.currentIndex()) {
|
|
106
|
+
this.setIndex(id, value);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Unregister a focus container
|
|
114
|
+
*
|
|
115
|
+
* @param id - Container identifier to remove
|
|
116
|
+
*/
|
|
117
|
+
unregisterContainer(id: string): void {
|
|
118
|
+
this.containers.delete(id);
|
|
119
|
+
this.scrollAnimations.delete(id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Register a focusable element in a container
|
|
124
|
+
*
|
|
125
|
+
* @param containerId - Container identifier
|
|
126
|
+
* @param element - Element to register
|
|
127
|
+
* @param index - Focus index for this element
|
|
128
|
+
*/
|
|
129
|
+
registerFocusable(containerId: string, element: Element, index: number): void {
|
|
130
|
+
const container = this.containers.get(containerId);
|
|
131
|
+
if (!container) {
|
|
132
|
+
console.warn(`FocusContainer with id "${containerId}" not found`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
container.focusables.set(index, element);
|
|
136
|
+
|
|
137
|
+
// If this is the index we are supposed to be at, set it now
|
|
138
|
+
const currentTabindex = isSignal(container.tabindex) ? (container.tabindex as Signal<number>)() : container.tabindex;
|
|
139
|
+
if (currentTabindex === index && container.currentIndex() === null) {
|
|
140
|
+
this.setIndex(containerId, index);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Unregister a focusable element from a container
|
|
146
|
+
*
|
|
147
|
+
* @param containerId - Container identifier
|
|
148
|
+
* @param index - Focus index to remove
|
|
149
|
+
*/
|
|
150
|
+
unregisterFocusable(containerId: string, index: number): void {
|
|
151
|
+
const container = this.containers.get(containerId);
|
|
152
|
+
if (!container) return;
|
|
153
|
+
container.focusables.delete(index);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Navigate to next or previous focusable element
|
|
158
|
+
*
|
|
159
|
+
* @param containerId - Container identifier
|
|
160
|
+
* @param direction - Navigation direction ('next' or 'previous')
|
|
161
|
+
*/
|
|
162
|
+
navigate(containerId: string, direction: 'next' | 'previous'): void {
|
|
163
|
+
const container = this.containers.get(containerId);
|
|
164
|
+
if (!container) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if container is frozen (including parent containers)
|
|
169
|
+
if (container.element && isElementFrozen(container.element)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle throttling
|
|
174
|
+
if (container.throttle) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const lastTime = container.lastNavigateTime || 0;
|
|
177
|
+
if (now - lastTime < container.throttle) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
container.lastNavigateTime = now;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const currentIndex = container.currentIndex();
|
|
184
|
+
const focusableIndices = Array.from(container.focusables.keys()).sort((a, b) => a - b);
|
|
185
|
+
|
|
186
|
+
if (focusableIndices.length === 0) return;
|
|
187
|
+
|
|
188
|
+
let newIndex: number | null = null;
|
|
189
|
+
|
|
190
|
+
if (currentIndex === null) {
|
|
191
|
+
// No current focus, go to first or last
|
|
192
|
+
newIndex = direction === 'next' ? focusableIndices[0] : focusableIndices[focusableIndices.length - 1];
|
|
193
|
+
} else {
|
|
194
|
+
const currentIndexPos = focusableIndices.indexOf(currentIndex);
|
|
195
|
+
if (direction === 'next') {
|
|
196
|
+
if (currentIndexPos < focusableIndices.length - 1) {
|
|
197
|
+
newIndex = focusableIndices[currentIndexPos + 1];
|
|
198
|
+
} else {
|
|
199
|
+
// Wrap around to first
|
|
200
|
+
newIndex = focusableIndices[0];
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
if (currentIndexPos > 0) {
|
|
204
|
+
newIndex = focusableIndices[currentIndexPos - 1];
|
|
205
|
+
} else {
|
|
206
|
+
// Wrap around to last
|
|
207
|
+
newIndex = focusableIndices[focusableIndices.length - 1];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (newIndex !== null) {
|
|
213
|
+
const tabindex = container.tabindex;
|
|
214
|
+
if (isSignal(tabindex)) {
|
|
215
|
+
(tabindex as any).set(newIndex);
|
|
216
|
+
} else {
|
|
217
|
+
this.setIndex(containerId, newIndex);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Set the focus index for a container
|
|
224
|
+
*
|
|
225
|
+
* @param containerId - Container identifier
|
|
226
|
+
* @param index - Focus index to set
|
|
227
|
+
*/
|
|
228
|
+
setIndex(containerId: string, index: number): void {
|
|
229
|
+
const container = this.containers.get(containerId);
|
|
230
|
+
if (!container) return;
|
|
231
|
+
|
|
232
|
+
const element = container.focusables.get(index);
|
|
233
|
+
if (!element) {
|
|
234
|
+
console.warn(`No focusable element at index ${index} in container "${containerId}"`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
container.currentIndex.set(index);
|
|
239
|
+
container.focusedElement.set(element);
|
|
240
|
+
|
|
241
|
+
// Sync back to tabindex signal if it exists
|
|
242
|
+
const tabindex = container.tabindex;
|
|
243
|
+
if (isSignal(tabindex) && (tabindex as any)() !== index) {
|
|
244
|
+
(tabindex as any).set(index);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Trigger callback
|
|
248
|
+
if (container.onFocusChange) {
|
|
249
|
+
container.onFocusChange(index, element);
|
|
250
|
+
}
|
|
251
|
+
// Handle DOM focus and scrolling
|
|
252
|
+
const instance = element.componentInstance as any;
|
|
253
|
+
if (instance && instance.element && typeof instance.element.focus === 'function') {
|
|
254
|
+
const domElement = instance.element as HTMLElement;
|
|
255
|
+
// Focus the native DOM element so :focus styles apply
|
|
256
|
+
domElement.focus();
|
|
257
|
+
|
|
258
|
+
// Scroll the element into view, centering it in the scrollable parent
|
|
259
|
+
if (typeof domElement.scrollIntoView === 'function') {
|
|
260
|
+
domElement.scrollIntoView({
|
|
261
|
+
block: 'center',
|
|
262
|
+
behavior: 'smooth'
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Handle auto-scroll if enabled
|
|
268
|
+
if (container.autoScroll) {
|
|
269
|
+
const viewport = container.viewport;
|
|
270
|
+
if (viewport) {
|
|
271
|
+
const options: ScrollOptions = typeof container.autoScroll === 'boolean'
|
|
272
|
+
? { center: true }
|
|
273
|
+
: container.autoScroll;
|
|
274
|
+
this.scrollToElement(containerId, index, viewport, options);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Get the element at a specific index
|
|
281
|
+
*
|
|
282
|
+
* @param containerId - Container identifier
|
|
283
|
+
* @param index - Focus index
|
|
284
|
+
* @returns Element at index or null
|
|
285
|
+
*/
|
|
286
|
+
getElement(containerId: string, index: number): Element | null {
|
|
287
|
+
const container = this.containers.get(containerId);
|
|
288
|
+
if (!container) return null;
|
|
289
|
+
return container.focusables.get(index) || null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get current focus index for a container
|
|
294
|
+
*
|
|
295
|
+
* @param containerId - Container identifier
|
|
296
|
+
* @returns Current index signal
|
|
297
|
+
*/
|
|
298
|
+
getCurrentIndexSignal(containerId: string): Signal<number | null> | null {
|
|
299
|
+
const container = this.containers.get(containerId);
|
|
300
|
+
return container ? container.currentIndex : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get current focused element signal for a container
|
|
305
|
+
*
|
|
306
|
+
* @param containerId - Container identifier
|
|
307
|
+
* @returns Current element signal
|
|
308
|
+
*/
|
|
309
|
+
getFocusedElementSignal(containerId: string): Signal<Element | null> | null {
|
|
310
|
+
const container = this.containers.get(containerId);
|
|
311
|
+
return container ? container.focusedElement : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check if an element is visible in the viewport
|
|
316
|
+
*
|
|
317
|
+
* @param element - Element to check
|
|
318
|
+
* @param viewport - Viewport to check against (optional)
|
|
319
|
+
* @returns True if element is visible
|
|
320
|
+
*/
|
|
321
|
+
isElementVisible(element: Element, viewport?: CanvasViewport): boolean {
|
|
322
|
+
if (!viewport) return true;
|
|
323
|
+
|
|
324
|
+
const bounds = this.getElementBounds(element);
|
|
325
|
+
const visibleBounds = viewport.getVisibleBounds();
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
bounds.x < visibleBounds.right &&
|
|
329
|
+
bounds.x + bounds.width > visibleBounds.left &&
|
|
330
|
+
bounds.y < visibleBounds.bottom &&
|
|
331
|
+
bounds.y + bounds.height > visibleBounds.top
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Get global bounds of an element
|
|
337
|
+
*
|
|
338
|
+
* @param element - Element to get bounds for
|
|
339
|
+
* @returns Bounds object with x, y, width, height
|
|
340
|
+
*/
|
|
341
|
+
getElementBounds(element: Element): { x: number; y: number; width: number; height: number } {
|
|
342
|
+
const instance = element.componentInstance;
|
|
343
|
+
if (!instance) {
|
|
344
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Get local bounds
|
|
348
|
+
const localBounds = instance.getLocalBounds();
|
|
349
|
+
|
|
350
|
+
// Get global position
|
|
351
|
+
const globalPos = instance.getGlobalPosition();
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
x: globalPos.x,
|
|
355
|
+
y: globalPos.y,
|
|
356
|
+
width: localBounds.width,
|
|
357
|
+
height: localBounds.height
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Scroll viewport to show an element
|
|
363
|
+
*
|
|
364
|
+
* @param containerId - Container identifier
|
|
365
|
+
* @param index - Focus index of element to scroll to
|
|
366
|
+
* @param viewport - Viewport instance (optional, uses container's viewport if not provided)
|
|
367
|
+
* @param options - Scroll options
|
|
368
|
+
*/
|
|
369
|
+
scrollToElement(
|
|
370
|
+
containerId: string,
|
|
371
|
+
index: number,
|
|
372
|
+
viewport?: CanvasViewport,
|
|
373
|
+
options: ScrollOptions = {}
|
|
374
|
+
): void {
|
|
375
|
+
const container = this.containers.get(containerId);
|
|
376
|
+
if (!container) return;
|
|
377
|
+
|
|
378
|
+
const element = container.focusables.get(index);
|
|
379
|
+
if (!element) return;
|
|
380
|
+
|
|
381
|
+
const targetViewport = viewport || container.viewport;
|
|
382
|
+
if (!targetViewport) return;
|
|
383
|
+
|
|
384
|
+
const bounds = this.getElementBounds(element);
|
|
385
|
+
const visibleBounds = targetViewport.getVisibleBounds();
|
|
386
|
+
const padding = options.padding || 0;
|
|
387
|
+
const center = options.center !== false; // Default to true
|
|
388
|
+
const smooth = options.smooth || false;
|
|
389
|
+
const duration = options.duration || 300;
|
|
390
|
+
|
|
391
|
+
// Check if element is already visible
|
|
392
|
+
if (this.isElementVisible(element, targetViewport)) {
|
|
393
|
+
// Element is visible, but check if we need to center it
|
|
394
|
+
if (center) {
|
|
395
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
396
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
397
|
+
|
|
398
|
+
if (smooth) {
|
|
399
|
+
this.animateScroll(containerId, targetViewport, centerX, centerY, duration);
|
|
400
|
+
} else {
|
|
401
|
+
targetViewport.moveCenter(centerX, centerY);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Element is not visible, scroll to it
|
|
408
|
+
if (center) {
|
|
409
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
410
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
411
|
+
|
|
412
|
+
if (smooth) {
|
|
413
|
+
this.animateScroll(containerId, targetViewport, centerX, centerY, duration);
|
|
414
|
+
} else {
|
|
415
|
+
targetViewport.moveCenter(centerX, centerY);
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
// Scroll to make element visible with padding
|
|
419
|
+
const targetX = bounds.x - padding;
|
|
420
|
+
const targetY = bounds.y - padding;
|
|
421
|
+
const targetWidth = bounds.width + padding * 2;
|
|
422
|
+
const targetHeight = bounds.height + padding * 2;
|
|
423
|
+
|
|
424
|
+
if (smooth) {
|
|
425
|
+
// For smooth fit, we'll animate to center
|
|
426
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
427
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
428
|
+
this.animateScroll(containerId, targetViewport, centerX, centerY, duration);
|
|
429
|
+
} else {
|
|
430
|
+
targetViewport.fit(targetX, targetY, targetWidth, targetHeight, padding);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Animate smooth scrolling
|
|
437
|
+
*
|
|
438
|
+
* @param containerId - Container identifier
|
|
439
|
+
* @param viewport - Viewport instance
|
|
440
|
+
* @param targetX - Target X position
|
|
441
|
+
* @param targetY - Target Y position
|
|
442
|
+
* @param duration - Animation duration in ms
|
|
443
|
+
*/
|
|
444
|
+
private animateScroll(
|
|
445
|
+
containerId: string,
|
|
446
|
+
viewport: CanvasViewport,
|
|
447
|
+
targetX: number,
|
|
448
|
+
targetY: number,
|
|
449
|
+
duration: number
|
|
450
|
+
): void {
|
|
451
|
+
const currentCenter = viewport.center;
|
|
452
|
+
const startX = currentCenter.x;
|
|
453
|
+
const startY = currentCenter.y;
|
|
454
|
+
|
|
455
|
+
const animation = {
|
|
456
|
+
startTime: Date.now(),
|
|
457
|
+
startX,
|
|
458
|
+
startY,
|
|
459
|
+
targetX,
|
|
460
|
+
targetY,
|
|
461
|
+
duration
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
this.scrollAnimations.set(containerId, animation);
|
|
465
|
+
|
|
466
|
+
// Use requestAnimationFrame for smooth animation
|
|
467
|
+
const animate = () => {
|
|
468
|
+
const anim = this.scrollAnimations.get(containerId);
|
|
469
|
+
if (!anim) return;
|
|
470
|
+
|
|
471
|
+
const elapsed = Date.now() - anim.startTime;
|
|
472
|
+
const progress = Math.min(elapsed / anim.duration, 1);
|
|
473
|
+
|
|
474
|
+
// Easing function (ease-out)
|
|
475
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
476
|
+
|
|
477
|
+
const currentX = anim.startX + (anim.targetX - anim.startX) * eased;
|
|
478
|
+
const currentY = anim.startY + (anim.targetY - anim.startY) * eased;
|
|
479
|
+
|
|
480
|
+
viewport.moveCenter(currentX, currentY);
|
|
481
|
+
|
|
482
|
+
if (progress < 1) {
|
|
483
|
+
requestAnimationFrame(animate);
|
|
484
|
+
} else {
|
|
485
|
+
this.scrollAnimations.delete(containerId);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
requestAnimationFrame(animate);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Export singleton instance
|
|
494
|
+
export const focusManager = FocusManager.getInstance();
|
|
495
|
+
|
package/src/engine/reactive.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
map,
|
|
10
10
|
of,
|
|
11
11
|
share,
|
|
12
|
+
shareReplay,
|
|
12
13
|
switchMap,
|
|
13
14
|
debounceTime,
|
|
14
15
|
distinctUntilChanged,
|
|
@@ -110,7 +111,7 @@ export function registerAllComponents() {
|
|
|
110
111
|
if (componentsRegistered) {
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
|
-
|
|
114
|
+
|
|
114
115
|
// Components are registered when their modules are imported
|
|
115
116
|
// Since bootstrap.ts imports all components, they should already be registered
|
|
116
117
|
// when bootstrapCanvas() is called. This function just marks that registration
|
|
@@ -129,10 +130,10 @@ export function registerAllComponents() {
|
|
|
129
130
|
*/
|
|
130
131
|
export function isElementFrozen(element: Element): boolean {
|
|
131
132
|
if (!element) return false;
|
|
132
|
-
|
|
133
|
+
|
|
133
134
|
// Check if this element itself is frozen
|
|
134
135
|
const freezeProp = element.propObservables?.freeze ?? element.props?.freeze;
|
|
135
|
-
|
|
136
|
+
|
|
136
137
|
if (freezeProp !== undefined && freezeProp !== null) {
|
|
137
138
|
// Handle Signal<boolean>
|
|
138
139
|
if (isSignal(freezeProp)) {
|
|
@@ -144,12 +145,12 @@ export function isElementFrozen(element: Element): boolean {
|
|
|
144
145
|
return true;
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
|
-
|
|
148
|
+
|
|
148
149
|
// Check if any parent is frozen (recursive check)
|
|
149
150
|
if (element.parent) {
|
|
150
151
|
return isElementFrozen(element.parent);
|
|
151
152
|
}
|
|
152
|
-
|
|
153
|
+
|
|
153
154
|
return false;
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -161,7 +162,7 @@ export function isElementFrozen(element: Element): boolean {
|
|
|
161
162
|
*/
|
|
162
163
|
function handleAnimatedSignalsFreeze(element: Element, shouldPause: boolean) {
|
|
163
164
|
if (!element.propObservables) return;
|
|
164
|
-
|
|
165
|
+
|
|
165
166
|
const processValue = (value: any) => {
|
|
166
167
|
if (isSignal(value) && isAnimatedSignal(value as any)) {
|
|
167
168
|
const animatedSig = value as unknown as AnimatedSignal<any>;
|
|
@@ -175,7 +176,7 @@ function handleAnimatedSignalsFreeze(element: Element, shouldPause: boolean) {
|
|
|
175
176
|
Object.values(value).forEach(processValue);
|
|
176
177
|
}
|
|
177
178
|
};
|
|
178
|
-
|
|
179
|
+
|
|
179
180
|
Object.values(element.propObservables).forEach(processValue);
|
|
180
181
|
}
|
|
181
182
|
|
|
@@ -272,19 +273,19 @@ export function createComponent(tag: string, props?: Props): Element {
|
|
|
272
273
|
}
|
|
273
274
|
return;
|
|
274
275
|
}
|
|
275
|
-
|
|
276
|
+
|
|
276
277
|
// Handle freeze prop as signal
|
|
277
278
|
if (key === "freeze") {
|
|
278
279
|
element.isFrozen = _value() === true;
|
|
279
|
-
|
|
280
|
+
|
|
280
281
|
// Pause/resume animatedSignals based on initial freeze state
|
|
281
282
|
handleAnimatedSignalsFreeze(element, element.isFrozen);
|
|
282
|
-
|
|
283
|
+
|
|
283
284
|
element.propSubscriptions.push(
|
|
284
285
|
_value.observable.subscribe((freezeValue) => {
|
|
285
286
|
const wasFrozen = element.isFrozen;
|
|
286
287
|
element.isFrozen = freezeValue === true;
|
|
287
|
-
|
|
288
|
+
|
|
288
289
|
// Handle animatedSignal pause/resume when freeze state changes
|
|
289
290
|
if (wasFrozen !== element.isFrozen) {
|
|
290
291
|
handleAnimatedSignalsFreeze(element, element.isFrozen);
|
|
@@ -293,7 +294,7 @@ export function createComponent(tag: string, props?: Props): Element {
|
|
|
293
294
|
);
|
|
294
295
|
return;
|
|
295
296
|
}
|
|
296
|
-
|
|
297
|
+
|
|
297
298
|
element.propSubscriptions.push(
|
|
298
299
|
_value.observable.subscribe((value) => {
|
|
299
300
|
// Block updates if element is frozen
|
|
@@ -304,12 +305,12 @@ export function createComponent(tag: string, props?: Props): Element {
|
|
|
304
305
|
}
|
|
305
306
|
return;
|
|
306
307
|
}
|
|
307
|
-
|
|
308
|
+
|
|
308
309
|
// Resume animatedSignal if it was paused
|
|
309
310
|
if (isAnimatedSignal(_value as any)) {
|
|
310
311
|
(_value as unknown as AnimatedSignal<any>).resume();
|
|
311
312
|
}
|
|
312
|
-
|
|
313
|
+
|
|
313
314
|
_set(path, key, value);
|
|
314
315
|
if (element.directives[key]) {
|
|
315
316
|
element.directives[key].onUpdate?.(value, element);
|
|
@@ -324,8 +325,8 @@ export function createComponent(tag: string, props?: Props): Element {
|
|
|
324
325
|
instance.onUpdate?.(
|
|
325
326
|
path == ""
|
|
326
327
|
? {
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
[key]: value,
|
|
329
|
+
}
|
|
329
330
|
: set({}, path + "." + key, value)
|
|
330
331
|
);
|
|
331
332
|
})
|
|
@@ -334,7 +335,7 @@ export function createComponent(tag: string, props?: Props): Element {
|
|
|
334
335
|
// Handle freeze prop as direct boolean
|
|
335
336
|
if (key === "freeze") {
|
|
336
337
|
element.isFrozen = value === true;
|
|
337
|
-
|
|
338
|
+
|
|
338
339
|
// Pause/resume animatedSignals based on freeze state
|
|
339
340
|
handleAnimatedSignalsFreeze(element, element.isFrozen);
|
|
340
341
|
}
|
|
@@ -457,7 +458,7 @@ export function createComponent(tag: string, props?: Props): Element {
|
|
|
457
458
|
|
|
458
459
|
element.props.context = actualParent.props.context;
|
|
459
460
|
element.parent = actualParent;
|
|
460
|
-
|
|
461
|
+
|
|
461
462
|
// Inherit freeze state from parent if element doesn't have its own freeze prop
|
|
462
463
|
if (!element.propObservables?.freeze && !element.props?.freeze && isElementFrozen(actualParent)) {
|
|
463
464
|
element.isFrozen = true;
|
|
@@ -847,7 +848,7 @@ export function loop<T>(
|
|
|
847
848
|
elements.forEach(el => destroyElement(el));
|
|
848
849
|
};
|
|
849
850
|
});
|
|
850
|
-
});
|
|
851
|
+
}).pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
|
851
852
|
}
|
|
852
853
|
|
|
853
854
|
/**
|