@hkdigital/lib-core 0.5.57 → 0.5.59
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/logging/internal/logger/Logger.js +1 -1
- package/dist/state/machines/page-machine/PageMachine.svelte.d.ts +146 -138
- package/dist/state/machines/page-machine/PageMachine.svelte.js +263 -445
- package/dist/state/machines/page-machine/PageMachine.svelte.js__ +708 -0
- package/dist/state/machines/page-machine/README.md +121 -69
- package/package.json +1 -1
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for page state machines with URL route mapping
|
|
3
|
+
*
|
|
4
|
+
* Simple state tracker that maps states to URL routes.
|
|
5
|
+
* Does NOT enforce FSM transitions - allows free navigation
|
|
6
|
+
* (because users can navigate to any URL via browser).
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - State-to-route mapping and sync
|
|
10
|
+
* - Start path management
|
|
11
|
+
* - Data properties for business/domain state
|
|
12
|
+
* - Visited states tracking
|
|
13
|
+
* - onEnter hooks with abort/complete handlers for animations
|
|
14
|
+
*
|
|
15
|
+
* Basic usage:
|
|
16
|
+
* ```javascript
|
|
17
|
+
* const machine = new PageMachine({
|
|
18
|
+
* startPath: '/intro/start',
|
|
19
|
+
* routeMap: {
|
|
20
|
+
* [STATE_START]: '/intro/start',
|
|
21
|
+
* [STATE_PROFILE]: '/intro/profile'
|
|
22
|
+
* }
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Sync machine state with URL changes
|
|
26
|
+
* $effect(() => {
|
|
27
|
+
* machine.syncFromPath($page.url.pathname);
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* With onEnter hooks (for animations):
|
|
32
|
+
* ```javascript
|
|
33
|
+
* const machine = new PageMachine({
|
|
34
|
+
* startPath: '/game/animate',
|
|
35
|
+
* routeMap: {
|
|
36
|
+
* [STATE_ANIMATE]: '/game/animate',
|
|
37
|
+
* [STATE_PLAY]: '/game/play'
|
|
38
|
+
* },
|
|
39
|
+
* onEnterHooks: {
|
|
40
|
+
* [STATE_ANIMATE]: (done) => {
|
|
41
|
+
* const animation = playAnimation(1000);
|
|
42
|
+
* animation.finished.then(() => done(STATE_PLAY));
|
|
43
|
+
*
|
|
44
|
+
* return {
|
|
45
|
+
* abort: () => animation.cancel(),
|
|
46
|
+
* complete: () => animation.finish()
|
|
47
|
+
* };
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* // Fast-forward animation
|
|
53
|
+
* machine.completeTransitions();
|
|
54
|
+
*
|
|
55
|
+
* // Cancel animation
|
|
56
|
+
* machine.abortTransitions();
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
import { switchToPage } from '$lib/util/sveltekit.js';
|
|
60
|
+
|
|
61
|
+
export default class PageMachine {
|
|
62
|
+
/**
|
|
63
|
+
* Logger instance for state machine
|
|
64
|
+
* @type {import('$lib/logging/client.js').Logger}
|
|
65
|
+
*/
|
|
66
|
+
logger;
|
|
67
|
+
/**
|
|
68
|
+
* Current state
|
|
69
|
+
* @type {string}
|
|
70
|
+
*/
|
|
71
|
+
// @ts-ignore
|
|
72
|
+
#current = $state();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Start path for this page machine
|
|
76
|
+
* @type {string}
|
|
77
|
+
*/
|
|
78
|
+
#startPath = '';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Initial/start state (derived from startPath)
|
|
82
|
+
* @type {string}
|
|
83
|
+
*/
|
|
84
|
+
#startState = '';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map of states to route paths
|
|
88
|
+
* @type {Record<string, string>}
|
|
89
|
+
*/
|
|
90
|
+
#routeMap = {};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reverse map of route paths to states
|
|
94
|
+
* @type {Record<string, string>}
|
|
95
|
+
*/
|
|
96
|
+
#pathToStateMap = {};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Data properties for business/domain state
|
|
100
|
+
* Can be initialized from server and synced back
|
|
101
|
+
* @type {Record<string, any>}
|
|
102
|
+
*/
|
|
103
|
+
#data = $state({});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Track which states have been visited during this session
|
|
107
|
+
* Useful for showing first-time hints/tips
|
|
108
|
+
* @type {Set<string>}
|
|
109
|
+
*/
|
|
110
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
111
|
+
#visitedStates = new Set();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Revision counter for triggering reactivity
|
|
115
|
+
* @type {number}
|
|
116
|
+
*/
|
|
117
|
+
#revision = $state(0);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map of state names to onEnter hook configurations
|
|
121
|
+
* @type {Record<string, {onEnter: Function}>}
|
|
122
|
+
*/
|
|
123
|
+
#onEnterHooks = {};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Current state's onEnter handler (abort/complete functions)
|
|
127
|
+
* @type {{abort?: Function, complete?: Function} | null}
|
|
128
|
+
*/
|
|
129
|
+
#currentOnEnterHandler = null;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Current state's done callback
|
|
133
|
+
* @type {Function | null}
|
|
134
|
+
*/
|
|
135
|
+
#currentOnEnterDone = null;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Flag to prevent concurrent state transitions
|
|
139
|
+
* @type {boolean}
|
|
140
|
+
*/
|
|
141
|
+
#isTransitioning = false;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Constructor
|
|
145
|
+
*
|
|
146
|
+
* @param {Object} config - Configuration object
|
|
147
|
+
* @param {string} config.startPath
|
|
148
|
+
* Start path for this route group (e.g., '/game/play')
|
|
149
|
+
* @param {Record<string, string>} [config.routeMap={}]
|
|
150
|
+
* Map of states to route paths
|
|
151
|
+
* @param {Record<string, any>} [config.initialData={}]
|
|
152
|
+
* Initial data properties (from server)
|
|
153
|
+
* @param {Record<string, Function>} [config.onEnterHooks={}]
|
|
154
|
+
* Map of states to onEnter hook functions
|
|
155
|
+
* @param {import('$lib/logging/client.js').Logger} [config.logger]
|
|
156
|
+
* Logger instance (optional, if not provided no logging occurs)
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```javascript
|
|
160
|
+
* const machine = new PageMachine({
|
|
161
|
+
* startPath: '/intro/start',
|
|
162
|
+
* routeMap: {
|
|
163
|
+
* [STATE_START]: '/intro/start',
|
|
164
|
+
* [STATE_ANIMATE]: '/intro/animate'
|
|
165
|
+
* },
|
|
166
|
+
* initialData: {
|
|
167
|
+
* INTRO_COMPLETED: false
|
|
168
|
+
* },
|
|
169
|
+
* onEnterHooks: {
|
|
170
|
+
* [STATE_ANIMATE]: (done) => {
|
|
171
|
+
* setTimeout(() => done(STATE_START), 1000);
|
|
172
|
+
* return {
|
|
173
|
+
* abort: () => clearTimeout(...),
|
|
174
|
+
* complete: () => done(STATE_START)
|
|
175
|
+
* };
|
|
176
|
+
* }
|
|
177
|
+
* }
|
|
178
|
+
* });
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
constructor({
|
|
182
|
+
startPath,
|
|
183
|
+
routeMap = {},
|
|
184
|
+
initialData = {},
|
|
185
|
+
onEnterHooks = {},
|
|
186
|
+
logger = null
|
|
187
|
+
}) {
|
|
188
|
+
if (!startPath) {
|
|
189
|
+
throw new Error('PageMachine requires startPath parameter');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.logger = logger;
|
|
193
|
+
this.#startPath = startPath;
|
|
194
|
+
this.#routeMap = routeMap;
|
|
195
|
+
this.#data = initialData;
|
|
196
|
+
this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
|
|
197
|
+
|
|
198
|
+
// Build reverse map (path -> state) and validate no duplicates
|
|
199
|
+
for (const [state, path] of Object.entries(routeMap)) {
|
|
200
|
+
// Check if this path is already mapped to a different state
|
|
201
|
+
const existingState = this.#pathToStateMap[path];
|
|
202
|
+
if (existingState && existingState !== state) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`PageMachine: Duplicate route mapping detected. ` +
|
|
205
|
+
`Path "${path}" is mapped to both "${existingState}" and "${state}". ` +
|
|
206
|
+
`Each route path must map to exactly one state.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
this.#pathToStateMap[path] = state;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Derive initial state from startPath
|
|
213
|
+
const initialState = this.#pathToStateMap[startPath];
|
|
214
|
+
if (!initialState) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`PageMachine: startPath "${startPath}" not found in routeMap`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.#startState = initialState;
|
|
221
|
+
this.#current = initialState;
|
|
222
|
+
|
|
223
|
+
// Mark initial state as visited
|
|
224
|
+
this.#visitedStates.add(initialState);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Normalize onEnterHooks to ensure consistent format
|
|
229
|
+
* Converts function to {onEnter: function} object
|
|
230
|
+
*
|
|
231
|
+
* @param {Record<string, Function|{onEnter: Function}>} hooks - Raw hooks configuration
|
|
232
|
+
* @returns {Record<string, {onEnter: Function}>} Normalized hooks
|
|
233
|
+
*/
|
|
234
|
+
#normalizeOnEnterHooks(hooks) {
|
|
235
|
+
/** @type {Record<string, {onEnter: Function} */
|
|
236
|
+
const normalized = {};
|
|
237
|
+
|
|
238
|
+
for (const [state, hook] of Object.entries(hooks)) {
|
|
239
|
+
if (typeof hook === 'function') {
|
|
240
|
+
// Simple function -> wrap in object
|
|
241
|
+
normalized[state] = { onEnter: hook };
|
|
242
|
+
} else if (hook?.onEnter) {
|
|
243
|
+
// Already an object with onEnter
|
|
244
|
+
normalized[state] = hook;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return normalized;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Synchronize machine state with URL path
|
|
253
|
+
*
|
|
254
|
+
* Call this in a $effect that watches $page.url.pathname
|
|
255
|
+
* Automatically tracks visited states and triggers onEnter hooks
|
|
256
|
+
*
|
|
257
|
+
* @param {string} currentPath - Current URL pathname
|
|
258
|
+
*
|
|
259
|
+
* @returns {boolean} True if state was changed
|
|
260
|
+
*/
|
|
261
|
+
syncFromPath(currentPath) {
|
|
262
|
+
const targetState = this.#getStateFromPath(currentPath);
|
|
263
|
+
|
|
264
|
+
if (targetState && targetState !== this.#current) {
|
|
265
|
+
// Log state transition from URL sync
|
|
266
|
+
this.logger?.debug(
|
|
267
|
+
`syncFromPath: ${currentPath} → targetState: ${targetState}`
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Use #setState to handle onEnter hooks
|
|
271
|
+
this.#setState(targetState);
|
|
272
|
+
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Set the current state directly (internal use only)
|
|
281
|
+
* Handles onEnter hooks and auto-transitions
|
|
282
|
+
*
|
|
283
|
+
* Note: This is private to enforce URL-first navigation.
|
|
284
|
+
* To change state, navigate to the URL using switchToPage()
|
|
285
|
+
* and let syncFromPath() update the state.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} newState - Target state
|
|
288
|
+
*/
|
|
289
|
+
async #setState(newState) {
|
|
290
|
+
if (newState === this.#current || this.#isTransitioning) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Abort previous state's onEnter handler
|
|
295
|
+
if (this.#currentOnEnterHandler?.abort) {
|
|
296
|
+
this.#currentOnEnterHandler.abort();
|
|
297
|
+
}
|
|
298
|
+
this.#currentOnEnterHandler = null;
|
|
299
|
+
this.#currentOnEnterDone = null;
|
|
300
|
+
|
|
301
|
+
const oldState = this.#current;
|
|
302
|
+
this.#isTransitioning = true;
|
|
303
|
+
this.#current = newState;
|
|
304
|
+
this.#visitedStates.add(newState);
|
|
305
|
+
|
|
306
|
+
// Log state transition
|
|
307
|
+
this.logger?.debug(`setState: ${oldState} → ${newState}`);
|
|
308
|
+
|
|
309
|
+
// Check if this state has an onEnter hook
|
|
310
|
+
const hookConfig = this.#onEnterHooks[newState];
|
|
311
|
+
if (hookConfig?.onEnter) {
|
|
312
|
+
// Create done callback for auto-transition
|
|
313
|
+
let doneCalled = false;
|
|
314
|
+
|
|
315
|
+
const done = (/** @type {string} */ nextState) => {
|
|
316
|
+
if (!doneCalled && nextState && nextState !== newState) {
|
|
317
|
+
doneCalled = true;
|
|
318
|
+
this.#isTransitioning = false;
|
|
319
|
+
this.#setState(nextState);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
this.#currentOnEnterDone = done;
|
|
324
|
+
|
|
325
|
+
// Call the onEnter hook
|
|
326
|
+
try {
|
|
327
|
+
const handler = hookConfig.onEnter(done);
|
|
328
|
+
|
|
329
|
+
// Store abort/complete handlers if provided
|
|
330
|
+
if (handler && typeof handler === 'object') {
|
|
331
|
+
if (handler.abort || handler.complete) {
|
|
332
|
+
this.#currentOnEnterHandler = {
|
|
333
|
+
abort: handler.abort,
|
|
334
|
+
complete: handler.complete
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// If hook returned a promise, await it
|
|
340
|
+
if (handler?.then) {
|
|
341
|
+
await handler;
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
const logger = this.logger ?? console;
|
|
345
|
+
logger.error(`Error in onEnter hook for state ${newState}:`, error);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
this.#isTransitioning = false;
|
|
350
|
+
this.#revision++;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get state name from URL path
|
|
355
|
+
*
|
|
356
|
+
* @param {string} path - URL pathname
|
|
357
|
+
*
|
|
358
|
+
* @returns {string|null} State name or null
|
|
359
|
+
*/
|
|
360
|
+
#getStateFromPath(path) {
|
|
361
|
+
// Try exact match first
|
|
362
|
+
if (this.#pathToStateMap[path]) {
|
|
363
|
+
return this.#pathToStateMap[path];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Try partial match (path includes route)
|
|
367
|
+
for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
|
|
368
|
+
if (path.includes(routePath)) {
|
|
369
|
+
return state;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Get route path for a given state
|
|
378
|
+
*
|
|
379
|
+
* @param {string} state - State name
|
|
380
|
+
*
|
|
381
|
+
* @returns {string} Route path or null if no mapping
|
|
382
|
+
*/
|
|
383
|
+
getPathForState(state) {
|
|
384
|
+
const path = this.#routeMap[state];
|
|
385
|
+
|
|
386
|
+
if (!path) {
|
|
387
|
+
throw new Error(`No path found for state [${state}]`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return path;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Navigate to the route path for a given state
|
|
395
|
+
*
|
|
396
|
+
* @param {string} state - State name
|
|
397
|
+
*/
|
|
398
|
+
navigateToState(state) {
|
|
399
|
+
const path = this.getPathForState(state);
|
|
400
|
+
switchToPage(path);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get route path for current state
|
|
405
|
+
*
|
|
406
|
+
* @returns {string|null} Route path or null if no mapping
|
|
407
|
+
*/
|
|
408
|
+
getCurrentPath() {
|
|
409
|
+
return this.getPathForState(this.#current);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Get current state
|
|
414
|
+
*
|
|
415
|
+
* @returns {string} Current state name
|
|
416
|
+
*/
|
|
417
|
+
get current() {
|
|
418
|
+
return this.#current;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get the route map
|
|
423
|
+
*
|
|
424
|
+
* @returns {Record<string, string>} Copy of route map
|
|
425
|
+
*/
|
|
426
|
+
get routeMap() {
|
|
427
|
+
return { ...this.#routeMap };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* ===== Data Properties (Business/Domain State) ===== */
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Set a data property value
|
|
434
|
+
*
|
|
435
|
+
* @param {string} key - Property key
|
|
436
|
+
* @param {any} value - Property value
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* ```javascript
|
|
440
|
+
* machine.setData('HAS_STRONG_PROFILE', true);
|
|
441
|
+
* machine.setData('PROFILE_SCORE', 85);
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
setData(key, value) {
|
|
445
|
+
this.#data[key] = value;
|
|
446
|
+
this.#revision++;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get a data property value
|
|
451
|
+
*
|
|
452
|
+
* @param {string} key - Property key
|
|
453
|
+
*
|
|
454
|
+
* @returns {any} Property value or undefined
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* ```javascript
|
|
458
|
+
* const hasProfile = machine.getData('HAS_STRONG_PROFILE');
|
|
459
|
+
* const score = machine.getData('PROFILE_SCORE');
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
getData(key) {
|
|
463
|
+
// Access revision to ensure reactivity
|
|
464
|
+
this.#revision;
|
|
465
|
+
return this.#data[key];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Get all data properties
|
|
470
|
+
*
|
|
471
|
+
* @returns {Record<string, any>} Copy of all data
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```javascript
|
|
475
|
+
* const allData = machine.getAllData();
|
|
476
|
+
* await playerService.saveData(allData);
|
|
477
|
+
* ```
|
|
478
|
+
*/
|
|
479
|
+
getAllData() {
|
|
480
|
+
// Access revision to ensure reactivity
|
|
481
|
+
this.#revision;
|
|
482
|
+
return { ...this.#data };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Update multiple data properties at once
|
|
487
|
+
*
|
|
488
|
+
* @param {Record<string, any>} dataUpdates - Object with key-value pairs
|
|
489
|
+
*
|
|
490
|
+
* @example
|
|
491
|
+
* ```javascript
|
|
492
|
+
* machine.updateData({
|
|
493
|
+
* HAS_STRONG_PROFILE: true,
|
|
494
|
+
* PROFILE_SCORE: 85,
|
|
495
|
+
* MATCHED_SECTOR: 'technology'
|
|
496
|
+
* });
|
|
497
|
+
* ```
|
|
498
|
+
*/
|
|
499
|
+
updateData(dataUpdates) {
|
|
500
|
+
for (const [key, value] of Object.entries(dataUpdates)) {
|
|
501
|
+
this.#data[key] = value;
|
|
502
|
+
}
|
|
503
|
+
this.#revision++;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ===== Visited States Tracking ===== */
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Check if a state has been visited
|
|
510
|
+
*
|
|
511
|
+
* @param {string} state - State name to check
|
|
512
|
+
*
|
|
513
|
+
* @returns {boolean} True if the state has been visited
|
|
514
|
+
*
|
|
515
|
+
* @example
|
|
516
|
+
* ```javascript
|
|
517
|
+
* if (machine.hasVisited(STATE_PROFILE)) {
|
|
518
|
+
* // User has seen profile page, skip intro
|
|
519
|
+
* }
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
hasVisited(state) {
|
|
523
|
+
// Access revision to ensure reactivity
|
|
524
|
+
this.#revision;
|
|
525
|
+
return this.#visitedStates.has(state);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Check if the start state has been visited
|
|
530
|
+
*
|
|
531
|
+
* @returns {boolean} True if the start state has been visited
|
|
532
|
+
*
|
|
533
|
+
* @example
|
|
534
|
+
* ```javascript
|
|
535
|
+
* if (machine.hasVisitedStart) {
|
|
536
|
+
* // User has been to the start page
|
|
537
|
+
* }
|
|
538
|
+
* ```
|
|
539
|
+
*/
|
|
540
|
+
get hasVisitedStart() {
|
|
541
|
+
return this.hasVisited(this.#startState);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Get all visited states
|
|
546
|
+
*
|
|
547
|
+
* @returns {string[]} Array of visited state names
|
|
548
|
+
*/
|
|
549
|
+
getVisitedStates() {
|
|
550
|
+
// Access revision to ensure reactivity
|
|
551
|
+
this.#revision;
|
|
552
|
+
return Array.from(this.#visitedStates);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Reset visited states tracking
|
|
557
|
+
* Useful for testing or resetting experience
|
|
558
|
+
*/
|
|
559
|
+
resetVisitedStates() {
|
|
560
|
+
this.#visitedStates.clear();
|
|
561
|
+
this.#visitedStates.add(this.#current);
|
|
562
|
+
this.#revision++;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* ===== Start Path Methods ===== */
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Get the start path
|
|
569
|
+
*
|
|
570
|
+
* @returns {string} Start path
|
|
571
|
+
*/
|
|
572
|
+
get startPath() {
|
|
573
|
+
return this.#startPath;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Get the start state
|
|
578
|
+
*
|
|
579
|
+
* @returns {string} Start state name
|
|
580
|
+
*/
|
|
581
|
+
get startState() {
|
|
582
|
+
return this.#startState;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Check if the supplied path matches the start path
|
|
587
|
+
*
|
|
588
|
+
* @param {string} path - Path to check
|
|
589
|
+
*
|
|
590
|
+
* @returns {boolean} True if path matches start path
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* ```javascript
|
|
594
|
+
* if (machine.isStartPath('/game/play')) {
|
|
595
|
+
* // User is on the start page
|
|
596
|
+
* }
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
isStartPath(path) {
|
|
600
|
+
return path === this.#startPath;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Check if currently on the start state
|
|
605
|
+
*
|
|
606
|
+
* @returns {boolean} True if current state is the start state
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* ```javascript
|
|
610
|
+
* if (machine.isOnStartState) {
|
|
611
|
+
* // Show onboarding
|
|
612
|
+
* }
|
|
613
|
+
* ```
|
|
614
|
+
*/
|
|
615
|
+
get isOnStartState() {
|
|
616
|
+
return this.#current === this.#startState;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Navigate to the start path
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
* ```javascript
|
|
624
|
+
* // Redirect user to start
|
|
625
|
+
* machine.redirectToStartPath();
|
|
626
|
+
* ```
|
|
627
|
+
*/
|
|
628
|
+
redirectToStartPath() {
|
|
629
|
+
// Import dynamically to avoid circular dependencies
|
|
630
|
+
import('$src/lib/util/sveltekit.js').then(({ switchToPage }) => {
|
|
631
|
+
switchToPage(this.#startPath);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/* ===== Transition Control Methods ===== */
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Abort current state's transitions
|
|
639
|
+
* Cancels animations/operations immediately (incomplete state)
|
|
640
|
+
*
|
|
641
|
+
* @example
|
|
642
|
+
* ```javascript
|
|
643
|
+
* // User clicks "Cancel" button
|
|
644
|
+
* machine.abortTransitions();
|
|
645
|
+
* ```
|
|
646
|
+
*/
|
|
647
|
+
abortTransitions() {
|
|
648
|
+
if (this.#currentOnEnterHandler?.abort) {
|
|
649
|
+
this.#currentOnEnterHandler.abort();
|
|
650
|
+
this.#currentOnEnterHandler = null;
|
|
651
|
+
this.#currentOnEnterDone = null;
|
|
652
|
+
this.#revision++;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Complete current state's transitions immediately
|
|
658
|
+
* Fast-forwards animations/operations to completion (complete state)
|
|
659
|
+
*
|
|
660
|
+
* @example
|
|
661
|
+
* ```javascript
|
|
662
|
+
* // User clicks "Skip" or "Next" button
|
|
663
|
+
* machine.completeTransitions();
|
|
664
|
+
* ```
|
|
665
|
+
*/
|
|
666
|
+
completeTransitions() {
|
|
667
|
+
if (this.#currentOnEnterHandler?.complete) {
|
|
668
|
+
this.#currentOnEnterHandler.complete();
|
|
669
|
+
this.#currentOnEnterHandler = null;
|
|
670
|
+
this.#currentOnEnterDone = null;
|
|
671
|
+
this.#revision++;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Check if current state has transitions that can be completed
|
|
677
|
+
*
|
|
678
|
+
* @returns {boolean} True if completeTransitions() can be called
|
|
679
|
+
*
|
|
680
|
+
* @example
|
|
681
|
+
* ```svelte
|
|
682
|
+
* {#if machine.canCompleteTransitions}
|
|
683
|
+
* <button onclick={() => machine.completeTransitions()}>Skip</button>
|
|
684
|
+
* {/if}
|
|
685
|
+
* ```
|
|
686
|
+
*/
|
|
687
|
+
get canCompleteTransitions() {
|
|
688
|
+
this.#revision; // Ensure reactivity
|
|
689
|
+
return !!this.#currentOnEnterHandler?.complete;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Check if current state has transitions that can be aborted
|
|
694
|
+
*
|
|
695
|
+
* @returns {boolean} True if abortTransitions() can be called
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* ```svelte
|
|
699
|
+
* {#if machine.canAbortTransitions}
|
|
700
|
+
* <button onclick={() => machine.abortTransitions()}>Cancel</button>
|
|
701
|
+
* {/if}
|
|
702
|
+
* ```
|
|
703
|
+
*/
|
|
704
|
+
get canAbortTransitions() {
|
|
705
|
+
this.#revision; // Ensure reactivity
|
|
706
|
+
return !!this.#currentOnEnterHandler?.abort;
|
|
707
|
+
}
|
|
708
|
+
}
|