@rohal12/spindle 0.19.0 → 0.20.0
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/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/components/Passage.tsx +3 -1
- package/src/components/macros/PassageDisplay.tsx +255 -7
- package/src/store.ts +30 -0
- package/src/story-api.ts +11 -0
- package/src/styles.css +43 -1
- package/src/transition.ts +69 -0
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ export function renderPassageContent(passage: PassageData) {
|
|
|
14
14
|
|
|
15
15
|
interface PassageProps {
|
|
16
16
|
passage: PassageData;
|
|
17
|
+
dataTransition?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const CODE_PASSAGES = new Set([
|
|
@@ -23,7 +24,7 @@ const CODE_PASSAGES = new Set([
|
|
|
23
24
|
'PassageDone',
|
|
24
25
|
]);
|
|
25
26
|
|
|
26
|
-
export function Passage({ passage }: PassageProps) {
|
|
27
|
+
export function Passage({ passage, dataTransition }: PassageProps) {
|
|
27
28
|
const storyData = useStoryStore((s) => s.storyData);
|
|
28
29
|
const isCodePassage = CODE_PASSAGES.has(passage.name);
|
|
29
30
|
const [doneReady, setDoneReady] = useState(false);
|
|
@@ -92,6 +93,7 @@ export function Passage({ passage }: PassageProps) {
|
|
|
92
93
|
class="passage"
|
|
93
94
|
data-passage={passage.name}
|
|
94
95
|
data-tags={passage.tags.join(' ')}
|
|
96
|
+
data-transition={dataTransition}
|
|
95
97
|
>
|
|
96
98
|
{headerContent && <div class="passage-header">{headerContent}</div>}
|
|
97
99
|
{content}
|
|
@@ -1,6 +1,69 @@
|
|
|
1
|
+
import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
|
|
1
2
|
import { useStoryStore } from '../../store';
|
|
2
3
|
import { Passage, renderPassageContent } from '../Passage';
|
|
3
4
|
import { defineMacro } from '../../define-macro';
|
|
5
|
+
import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a snapshot clone of a .passage element for outgoing transitions.
|
|
9
|
+
* - Strips `id` attributes from the clone tree
|
|
10
|
+
* - Disables interaction (pointer-events, user-select)
|
|
11
|
+
* - Pauses/mutes any <audio>/<video>
|
|
12
|
+
* - Adds `.passage-snapshot` class and `data-transition` attribute
|
|
13
|
+
*/
|
|
14
|
+
function createSnapshot(
|
|
15
|
+
passageEl: Element,
|
|
16
|
+
transitionType: string,
|
|
17
|
+
): HTMLElement {
|
|
18
|
+
const clone = passageEl.cloneNode(true) as HTMLElement;
|
|
19
|
+
|
|
20
|
+
// Strip id attributes to avoid duplicates
|
|
21
|
+
clone.removeAttribute('id');
|
|
22
|
+
const idEls = clone.querySelectorAll('[id]');
|
|
23
|
+
for (let i = 0; i < idEls.length; i++) {
|
|
24
|
+
idEls[i]!.removeAttribute('id');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Disable interaction
|
|
28
|
+
clone.style.pointerEvents = 'none';
|
|
29
|
+
clone.style.userSelect = 'none';
|
|
30
|
+
|
|
31
|
+
// Pause/mute media
|
|
32
|
+
const mediaEls = clone.querySelectorAll('audio, video');
|
|
33
|
+
for (let i = 0; i < mediaEls.length; i++) {
|
|
34
|
+
const media = mediaEls[i] as HTMLMediaElement;
|
|
35
|
+
media.pause?.();
|
|
36
|
+
media.muted = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Mark as snapshot
|
|
40
|
+
clone.classList.remove('passage');
|
|
41
|
+
clone.classList.add('passage-snapshot');
|
|
42
|
+
clone.setAttribute('data-transition', transitionType);
|
|
43
|
+
|
|
44
|
+
return clone;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Set CSS custom properties for transition durations on the container element.
|
|
49
|
+
*/
|
|
50
|
+
function setCSSProperties(el: HTMLElement, config: ResolvedTransition): void {
|
|
51
|
+
el.style.setProperty('--passage-in-duration', `${config.duration / 1000}s`);
|
|
52
|
+
el.style.setProperty('--passage-out-duration', `${config.duration / 1000}s`);
|
|
53
|
+
el.style.setProperty('--passage-pause', `${config.pause / 1000}s`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove all snapshot elements and crossfading class from a container.
|
|
58
|
+
*/
|
|
59
|
+
function cleanupSnapshots(containerEl: Element | null): void {
|
|
60
|
+
if (!containerEl) return;
|
|
61
|
+
containerEl.classList.remove('passage-container--crossfading');
|
|
62
|
+
const snapshots = containerEl.querySelectorAll('.passage-snapshot');
|
|
63
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
64
|
+
snapshots[i]!.remove();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
4
67
|
|
|
5
68
|
defineMacro({
|
|
6
69
|
name: 'passage',
|
|
@@ -9,11 +72,188 @@ defineMacro({
|
|
|
9
72
|
const currentPassage = useStoryStore((s) => s.currentPassage);
|
|
10
73
|
const storyData = useStoryStore((s) => s.storyData);
|
|
11
74
|
|
|
12
|
-
|
|
13
|
-
|
|
75
|
+
// Render-gating: displayedPassage controls which passage is visually shown.
|
|
76
|
+
// The store updates immediately, but the visual swap is deferred by the
|
|
77
|
+
// transition state machine.
|
|
78
|
+
const [displayedPassage, setDisplayedPassage] = useState(currentPassage);
|
|
79
|
+
|
|
80
|
+
// Track the resolved transition type for the data-transition prop
|
|
81
|
+
const resolvedTypeRef = useRef<string>('fade');
|
|
82
|
+
|
|
83
|
+
// Track previous history length to detect restart/load
|
|
84
|
+
const prevHistoryLenRef = useRef(0);
|
|
85
|
+
|
|
86
|
+
// Container ref for snapshot insertion
|
|
87
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
88
|
+
|
|
89
|
+
// Track in-progress transition timeouts for cancellation
|
|
90
|
+
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
91
|
+
|
|
92
|
+
/** Cancel any in-progress transition. */
|
|
93
|
+
const cancelTransition = useCallback(() => {
|
|
94
|
+
for (const t of timeoutsRef.current) clearTimeout(t);
|
|
95
|
+
timeoutsRef.current = [];
|
|
96
|
+
cleanupSnapshots(containerRef.current);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// State machine effect — triggers on passage change
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const store = useStoryStore.getState();
|
|
102
|
+
const { history, historyIndex, transitionConfig } = store;
|
|
103
|
+
|
|
104
|
+
// Always consume the next transition (one-shot), regardless of what we do
|
|
105
|
+
const next = store.consumeNextTransition();
|
|
106
|
+
|
|
107
|
+
// Determine if this is a first load, restart, or save-load
|
|
108
|
+
const prevLen = prevHistoryLenRef.current;
|
|
109
|
+
prevHistoryLenRef.current = history.length;
|
|
110
|
+
|
|
111
|
+
const isFirstLoad = !displayedPassage;
|
|
112
|
+
const isRestart =
|
|
113
|
+
history.length === 1 && historyIndex === 0 && prevLen > 1;
|
|
114
|
+
const isLoad =
|
|
115
|
+
history.length > 1 &&
|
|
116
|
+
historyIndex === history.length - 1 &&
|
|
117
|
+
prevLen > 1 &&
|
|
118
|
+
Math.abs(history.length - prevLen) > 1;
|
|
119
|
+
|
|
120
|
+
// If this is the same passage (initial render), just set it
|
|
121
|
+
if (currentPassage === displayedPassage) {
|
|
122
|
+
// Update prevHistoryLen on initial render
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Cancel any in-progress transition
|
|
127
|
+
cancelTransition();
|
|
128
|
+
|
|
129
|
+
// Get the target passage
|
|
130
|
+
const targetPassage = storyData?.passages.get(currentPassage);
|
|
131
|
+
if (!targetPassage) {
|
|
132
|
+
// Passage not found — show immediately
|
|
133
|
+
resolvedTypeRef.current = 'none';
|
|
134
|
+
setDisplayedPassage(currentPassage);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// First load, restart, or load → use simple fade (no outgoing phase)
|
|
139
|
+
if (isFirstLoad || isRestart || isLoad) {
|
|
140
|
+
resolvedTypeRef.current = 'fade';
|
|
141
|
+
setDisplayedPassage(currentPassage);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve transition from tags → nextTransition → store default → built-in
|
|
146
|
+
const config = resolveTransition(
|
|
147
|
+
targetPassage.tags,
|
|
148
|
+
next,
|
|
149
|
+
transitionConfig,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
resolvedTypeRef.current = config.type;
|
|
153
|
+
|
|
154
|
+
const containerEl = containerRef.current;
|
|
155
|
+
|
|
156
|
+
// ---- State machine by type ----
|
|
157
|
+
|
|
158
|
+
if (config.type === 'none') {
|
|
159
|
+
// idle → mount new (no animation) → idle
|
|
160
|
+
setDisplayedPassage(currentPassage);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (config.type === 'fade') {
|
|
165
|
+
// idle → mount new → incoming (CSS fade in) → idle
|
|
166
|
+
if (containerEl) setCSSProperties(containerEl, config);
|
|
167
|
+
setDisplayedPassage(currentPassage);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (config.type === 'fade-through') {
|
|
172
|
+
// idle → outgoing (fade out snapshot) → paused → incoming (mount new, fade in) → idle
|
|
173
|
+
if (!containerEl) {
|
|
174
|
+
setDisplayedPassage(currentPassage);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setCSSProperties(containerEl, config);
|
|
179
|
+
|
|
180
|
+
// Take snapshot of current passage
|
|
181
|
+
const passageEl = containerEl.querySelector('.passage');
|
|
182
|
+
if (!passageEl) {
|
|
183
|
+
setDisplayedPassage(currentPassage);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const snapshot = createSnapshot(passageEl, config.type);
|
|
188
|
+
containerEl.appendChild(snapshot);
|
|
189
|
+
|
|
190
|
+
// Hide the current passage during outgoing phase by setting empty key
|
|
191
|
+
setDisplayedPassage('');
|
|
192
|
+
|
|
193
|
+
// After outgoing duration + pause, mount the new passage
|
|
194
|
+
const incomingDelay = config.duration + config.pause;
|
|
195
|
+
const t1 = setTimeout(() => {
|
|
196
|
+
setDisplayedPassage(currentPassage);
|
|
197
|
+
}, incomingDelay);
|
|
198
|
+
|
|
199
|
+
// After full transition, clean up snapshot
|
|
200
|
+
const cleanupDelay = incomingDelay + config.duration;
|
|
201
|
+
const t2 = setTimeout(() => {
|
|
202
|
+
cleanupSnapshots(containerEl);
|
|
203
|
+
}, cleanupDelay);
|
|
204
|
+
|
|
205
|
+
timeoutsRef.current = [t1, t2];
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (config.type === 'crossfade') {
|
|
210
|
+
// idle → crossfading (mount new + fade in, simultaneously fade out snapshot) → idle
|
|
211
|
+
if (!containerEl) {
|
|
212
|
+
setDisplayedPassage(currentPassage);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setCSSProperties(containerEl, config);
|
|
217
|
+
|
|
218
|
+
// Take snapshot of current passage
|
|
219
|
+
const passageEl = containerEl.querySelector('.passage');
|
|
220
|
+
if (!passageEl) {
|
|
221
|
+
setDisplayedPassage(currentPassage);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const snapshot = createSnapshot(passageEl, config.type);
|
|
226
|
+
|
|
227
|
+
// Use CSS grid stacking for crossfade
|
|
228
|
+
containerEl.classList.add('passage-container--crossfading');
|
|
229
|
+
containerEl.appendChild(snapshot);
|
|
230
|
+
|
|
231
|
+
// Mount new passage immediately alongside snapshot
|
|
232
|
+
setDisplayedPassage(currentPassage);
|
|
233
|
+
|
|
234
|
+
// After duration, clean up snapshot and remove crossfading class
|
|
235
|
+
const t1 = setTimeout(() => {
|
|
236
|
+
cleanupSnapshots(containerEl);
|
|
237
|
+
}, config.duration);
|
|
238
|
+
|
|
239
|
+
timeoutsRef.current = [t1];
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Fallback: unknown type, just mount
|
|
244
|
+
setDisplayedPassage(currentPassage);
|
|
245
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
246
|
+
}, [currentPassage]);
|
|
247
|
+
|
|
248
|
+
// Resolve the passage to display (or show nothing during outgoing phase)
|
|
249
|
+
const passage = displayedPassage
|
|
250
|
+
? storyData?.passages.get(displayedPassage)
|
|
251
|
+
: null;
|
|
252
|
+
|
|
253
|
+
if (!passage && displayedPassage) {
|
|
14
254
|
return (
|
|
15
255
|
<div class="error">
|
|
16
|
-
Error: Passage “{
|
|
256
|
+
Error: Passage “{displayedPassage}” not found.
|
|
17
257
|
</div>
|
|
18
258
|
);
|
|
19
259
|
}
|
|
@@ -33,10 +273,18 @@ defineMacro({
|
|
|
33
273
|
{renderPassageContent(readyPassage)}
|
|
34
274
|
</div>
|
|
35
275
|
)}
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
276
|
+
<div
|
|
277
|
+
class="passage-container"
|
|
278
|
+
ref={containerRef}
|
|
279
|
+
>
|
|
280
|
+
{passage && (
|
|
281
|
+
<Passage
|
|
282
|
+
passage={passage}
|
|
283
|
+
key={displayedPassage}
|
|
284
|
+
dataTransition={resolvedTypeRef.current}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
40
288
|
</div>
|
|
41
289
|
);
|
|
42
290
|
},
|
package/src/store.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type Patch,
|
|
8
8
|
} from 'immer';
|
|
9
9
|
import type { StoryData } from './parser';
|
|
10
|
+
import type { TransitionConfig } from './transition';
|
|
10
11
|
import type { SavePayload, SaveHistoryMoment } from './saves/types';
|
|
11
12
|
import { executeStoryInit } from './story-init';
|
|
12
13
|
import { resetTriggers } from './triggers';
|
|
@@ -202,6 +203,8 @@ export interface StoryState {
|
|
|
202
203
|
maxHistory: number;
|
|
203
204
|
saveError: string | null;
|
|
204
205
|
loadError: string | null;
|
|
206
|
+
transitionConfig: TransitionConfig | null;
|
|
207
|
+
nextTransition: TransitionConfig | null;
|
|
205
208
|
|
|
206
209
|
setMaxHistory: (limit: number) => void;
|
|
207
210
|
init: (
|
|
@@ -223,6 +226,9 @@ export interface StoryState {
|
|
|
223
226
|
getSavePayload: () => SavePayload;
|
|
224
227
|
loadFromPayload: (payload: SavePayload) => void;
|
|
225
228
|
getHistoryVariables: (index: number) => Record<string, unknown>;
|
|
229
|
+
setTransition: (config: TransitionConfig | null) => void;
|
|
230
|
+
setNextTransition: (config: TransitionConfig | null) => void;
|
|
231
|
+
consumeNextTransition: () => TransitionConfig | null;
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
export const useStoryStore = create<StoryState>()(
|
|
@@ -241,6 +247,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
241
247
|
maxHistory: 40,
|
|
242
248
|
saveError: null,
|
|
243
249
|
loadError: null,
|
|
250
|
+
transitionConfig: null,
|
|
251
|
+
nextTransition: null,
|
|
244
252
|
|
|
245
253
|
setMaxHistory: (limit: number) => {
|
|
246
254
|
set((state) => {
|
|
@@ -616,5 +624,27 @@ export const useStoryStore = create<StoryState>()(
|
|
|
616
624
|
getHistoryVariables: (index: number): Record<string, unknown> => {
|
|
617
625
|
return deepClone(reconstructVarsAt(index));
|
|
618
626
|
},
|
|
627
|
+
|
|
628
|
+
setTransition: (config: TransitionConfig | null) => {
|
|
629
|
+
set((state) => {
|
|
630
|
+
state.transitionConfig = config as TransitionConfig | null;
|
|
631
|
+
});
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
setNextTransition: (config: TransitionConfig | null) => {
|
|
635
|
+
set((state) => {
|
|
636
|
+
state.nextTransition = config as TransitionConfig | null;
|
|
637
|
+
});
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
consumeNextTransition: (): TransitionConfig | null => {
|
|
641
|
+
const current = get().nextTransition;
|
|
642
|
+
if (current !== null) {
|
|
643
|
+
set((state) => {
|
|
644
|
+
state.nextTransition = null;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return current;
|
|
648
|
+
},
|
|
619
649
|
})),
|
|
620
650
|
);
|
package/src/story-api.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from './prng';
|
|
24
24
|
import { addTrigger, removeTrigger } from './triggers';
|
|
25
25
|
import type { WatchOptions } from './triggers';
|
|
26
|
+
import type { TransitionConfig } from './transition';
|
|
26
27
|
|
|
27
28
|
export type { StoryAction };
|
|
28
29
|
|
|
@@ -72,6 +73,8 @@ export interface StoryAPI {
|
|
|
72
73
|
callbackOrOptions: (() => void) | WatchOptions,
|
|
73
74
|
): () => void;
|
|
74
75
|
unwatch(name: string): void;
|
|
76
|
+
setTransition(config: TransitionConfig | null): void;
|
|
77
|
+
setNextTransition(config: TransitionConfig | null): void;
|
|
75
78
|
random(): number;
|
|
76
79
|
randomInt(min: number, max: number): number;
|
|
77
80
|
readonly config: {
|
|
@@ -281,6 +284,14 @@ function createStoryAPI(): StoryAPI {
|
|
|
281
284
|
removeTrigger(name);
|
|
282
285
|
},
|
|
283
286
|
|
|
287
|
+
setTransition(config: TransitionConfig | null): void {
|
|
288
|
+
useStoryStore.getState().setTransition(config);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
setNextTransition(config: TransitionConfig | null): void {
|
|
292
|
+
useStoryStore.getState().setNextTransition(config);
|
|
293
|
+
},
|
|
294
|
+
|
|
284
295
|
random(): number {
|
|
285
296
|
return random();
|
|
286
297
|
},
|
package/src/styles.css
CHANGED
|
@@ -29,8 +29,32 @@ tw-storydata {
|
|
|
29
29
|
padding: 2em 1.5em;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/* Passage transitions */
|
|
33
|
+
|
|
34
|
+
.passage-container {
|
|
35
|
+
position: relative;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.passage-container--crossfading {
|
|
39
|
+
display: grid;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.passage-container--crossfading > * {
|
|
43
|
+
grid-area: 1 / 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
.passage {
|
|
33
|
-
animation: passage-fade-in 0.3s ease
|
|
47
|
+
animation: passage-fade-in var(--passage-in-duration, 0.3s) ease;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.passage[data-transition='none'] {
|
|
51
|
+
animation: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.passage-snapshot {
|
|
55
|
+
animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
user-select: none;
|
|
34
58
|
}
|
|
35
59
|
|
|
36
60
|
@keyframes passage-fade-in {
|
|
@@ -44,6 +68,24 @@ tw-storydata {
|
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
70
|
|
|
71
|
+
@keyframes passage-fade-out {
|
|
72
|
+
from {
|
|
73
|
+
opacity: 1;
|
|
74
|
+
transform: translateY(0);
|
|
75
|
+
}
|
|
76
|
+
to {
|
|
77
|
+
opacity: 0;
|
|
78
|
+
transform: translateY(-8px);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@media (prefers-reduced-motion: reduce) {
|
|
83
|
+
.passage,
|
|
84
|
+
.passage-snapshot {
|
|
85
|
+
animation-duration: 0.01s !important;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
47
89
|
.macro-button {
|
|
48
90
|
background: rgba(100, 181, 246, 0.15);
|
|
49
91
|
color: #64b5f6;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade';
|
|
2
|
+
|
|
3
|
+
export interface TransitionConfig {
|
|
4
|
+
type: TransitionType;
|
|
5
|
+
duration?: number;
|
|
6
|
+
pause?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ResolvedTransition = Required<TransitionConfig>;
|
|
10
|
+
|
|
11
|
+
const TRANSITION_TYPES = new Set<TransitionType>([
|
|
12
|
+
'none',
|
|
13
|
+
'fade',
|
|
14
|
+
'fade-through',
|
|
15
|
+
'crossfade',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const BUILT_IN_DEFAULT: ResolvedTransition = {
|
|
19
|
+
type: 'fade-through',
|
|
20
|
+
duration: 300,
|
|
21
|
+
pause: 50,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function resolveTransitionFromTags(
|
|
25
|
+
tags: string[],
|
|
26
|
+
): TransitionConfig | null {
|
|
27
|
+
const typeTag = tags.find((t) => t.startsWith('transition:'));
|
|
28
|
+
if (!typeTag) return null;
|
|
29
|
+
|
|
30
|
+
const rawType = typeTag.slice('transition:'.length);
|
|
31
|
+
if (!TRANSITION_TYPES.has(rawType as TransitionType)) {
|
|
32
|
+
console.warn(`Unknown transition type: "${rawType}"`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const config: TransitionConfig = { type: rawType as TransitionType };
|
|
37
|
+
|
|
38
|
+
for (const tag of tags) {
|
|
39
|
+
if (tag.startsWith('duration:')) {
|
|
40
|
+
const n = Number(tag.slice('duration:'.length));
|
|
41
|
+
if (!Number.isNaN(n)) config.duration = n;
|
|
42
|
+
} else if (tag.startsWith('pause:')) {
|
|
43
|
+
const n = Number(tag.slice('pause:'.length));
|
|
44
|
+
if (!Number.isNaN(n)) config.pause = n;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function fillDefaults(partial: TransitionConfig): ResolvedTransition {
|
|
52
|
+
return {
|
|
53
|
+
type: partial.type,
|
|
54
|
+
duration: partial.duration ?? BUILT_IN_DEFAULT.duration,
|
|
55
|
+
pause: partial.pause ?? BUILT_IN_DEFAULT.pause,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveTransition(
|
|
60
|
+
targetTags: string[],
|
|
61
|
+
nextTransition: TransitionConfig | null,
|
|
62
|
+
storeDefault: TransitionConfig | null,
|
|
63
|
+
): ResolvedTransition {
|
|
64
|
+
const fromTags = resolveTransitionFromTags(targetTags);
|
|
65
|
+
if (fromTags) return fillDefaults(fromTags);
|
|
66
|
+
if (nextTransition) return fillDefaults(nextTransition);
|
|
67
|
+
if (storeDefault) return fillDefaults(storeDefault);
|
|
68
|
+
return { ...BUILT_IN_DEFAULT };
|
|
69
|
+
}
|