flowsvg 0.0.0-stage → 0.1.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/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/PATTERNS.md +101 -0
- package/README.md +524 -2
- package/README.zh-CN.md +525 -0
- package/dist/browser.d.ts +69 -0
- package/dist/browser.js +353 -0
- package/dist/core.d.ts +306 -0
- package/dist/core.js +1149 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node-content-paint.d.ts +10 -0
- package/dist/node-content-paint.js +54 -0
- package/dist/node-content.d.ts +184 -0
- package/dist/node-content.js +554 -0
- package/dist/player-accessibility.d.ts +16 -0
- package/dist/player-accessibility.js +36 -0
- package/dist/player-controls.d.ts +41 -0
- package/dist/player-controls.js +80 -0
- package/dist/player-lifecycle.d.ts +6 -0
- package/dist/player-lifecycle.js +31 -0
- package/dist/react.d.ts +48 -0
- package/dist/react.js +126 -0
- package/dist/styles.css +47 -0
- package/dist/svg-dom.d.ts +6 -0
- package/dist/svg-dom.js +74 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +6 -0
- package/dist/timeline.d.ts +58 -0
- package/dist/timeline.js +164 -0
- package/dist/vue.d.ts +132 -0
- package/dist/vue.js +161 -0
- package/dist/web-component.d.ts +103 -0
- package/dist/web-component.js +396 -0
- package/package.json +87 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const DEFAULT_SPEEDS = [0.5, 1, 1.5, 2];
|
|
2
|
+
/**
|
|
3
|
+
* Persistent control DOM translates input to commands; render synchronizes labels, progress and disabled states.
|
|
4
|
+
* Own no clock or independent position; time-related controls are disabled for static documents.
|
|
5
|
+
*/
|
|
6
|
+
export class PlayerControls {
|
|
7
|
+
element;
|
|
8
|
+
play;
|
|
9
|
+
previous;
|
|
10
|
+
next;
|
|
11
|
+
progress;
|
|
12
|
+
scene;
|
|
13
|
+
speed;
|
|
14
|
+
sceneChoices;
|
|
15
|
+
constructor(owner, actions) {
|
|
16
|
+
this.element = owner.createElement("div");
|
|
17
|
+
this.element.className = "flowsvg-controls";
|
|
18
|
+
this.play = createButton(owner, "Play", actions.toggle);
|
|
19
|
+
this.previous = createButton(owner, "Previous step", () => actions.step(-1));
|
|
20
|
+
this.next = createButton(owner, "Next step", () => actions.step(1));
|
|
21
|
+
this.progress = owner.createElement("input");
|
|
22
|
+
this.progress.type = "range";
|
|
23
|
+
this.progress.min = "0";
|
|
24
|
+
this.progress.step = "any";
|
|
25
|
+
this.progress.setAttribute("aria-label", "Progress");
|
|
26
|
+
this.progress.addEventListener("input", () => actions.seek(Number(this.progress.value)));
|
|
27
|
+
this.scene = createSelect(owner, "Scene");
|
|
28
|
+
this.scene.addEventListener("change", () => {
|
|
29
|
+
const choice = this.sceneChoices?.find((scene) => scene.id === this.scene.value);
|
|
30
|
+
if (choice)
|
|
31
|
+
actions.seek(choice.startMs);
|
|
32
|
+
});
|
|
33
|
+
this.speed = createSelect(owner, "Speed");
|
|
34
|
+
this.speed.addEventListener("change", () => actions.setSpeed(Number(this.speed.value)));
|
|
35
|
+
this.element.append(this.previous, this.play, this.next, this.progress, this.scene, this.speed);
|
|
36
|
+
}
|
|
37
|
+
/** Update existing controls; scene choices change by reference and custom speeds are added as options. */
|
|
38
|
+
render(state) {
|
|
39
|
+
const isStatic = state.totalDuration === 0;
|
|
40
|
+
this.play.textContent = state.playing ? "Pause" : "Play";
|
|
41
|
+
this.play.setAttribute("aria-label", this.play.textContent);
|
|
42
|
+
this.play.disabled = isStatic || (!state.playing && state.timeMs >= state.totalDuration);
|
|
43
|
+
this.previous.disabled = isStatic || state.timeMs <= 0;
|
|
44
|
+
this.next.disabled = isStatic || state.timeMs >= state.totalDuration;
|
|
45
|
+
this.progress.disabled = isStatic;
|
|
46
|
+
this.progress.max = String(state.totalDuration);
|
|
47
|
+
this.progress.value = String(state.timeMs);
|
|
48
|
+
this.scene.disabled = isStatic;
|
|
49
|
+
this.speed.disabled = isStatic;
|
|
50
|
+
if (this.sceneChoices !== state.scenes) {
|
|
51
|
+
this.sceneChoices = state.scenes;
|
|
52
|
+
this.scene.replaceChildren(...state.scenes.map((scene) => createOption(this.scene.ownerDocument, scene.id, scene.label)));
|
|
53
|
+
}
|
|
54
|
+
this.scene.value = state.sceneId ?? "";
|
|
55
|
+
const speeds = [...new Set([...DEFAULT_SPEEDS, state.speed])].sort((left, right) => left - right);
|
|
56
|
+
if (!Array.from(this.speed.options).some((option) => Number(option.value) === state.speed)) {
|
|
57
|
+
this.speed.replaceChildren(...speeds.map((speed) => createOption(this.speed.ownerDocument, String(speed), `${speed}×`)));
|
|
58
|
+
}
|
|
59
|
+
this.speed.value = String(state.speed);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function createButton(owner, label, click) {
|
|
63
|
+
const button = owner.createElement("button");
|
|
64
|
+
button.type = "button";
|
|
65
|
+
button.textContent = label;
|
|
66
|
+
button.setAttribute("aria-label", label);
|
|
67
|
+
button.addEventListener("click", click);
|
|
68
|
+
return button;
|
|
69
|
+
}
|
|
70
|
+
function createSelect(owner, label) {
|
|
71
|
+
const select = owner.createElement("select");
|
|
72
|
+
select.setAttribute("aria-label", label);
|
|
73
|
+
return select;
|
|
74
|
+
}
|
|
75
|
+
function createOption(owner, value, label) {
|
|
76
|
+
const option = owner.createElement("option");
|
|
77
|
+
option.value = value;
|
|
78
|
+
option.textContent = label;
|
|
79
|
+
return option;
|
|
80
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Player layout and page-visibility resources, scoped to the lifetime of one instance. */
|
|
2
|
+
/**
|
|
3
|
+
* Set up flex/aspect-preserving SVG containment and pause on page hiding; restoring visibility does not resume.
|
|
4
|
+
* The returned cleanup removes owned styles/listeners and restores the host's display/flexDirection.
|
|
5
|
+
*/
|
|
6
|
+
export declare function attachPlayerLifecycle(host: HTMLElement, canvas: HTMLElement, pause: () => void): () => void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Player layout and page-visibility resources, scoped to the lifetime of one instance. */
|
|
2
|
+
/**
|
|
3
|
+
* Set up flex/aspect-preserving SVG containment and pause on page hiding; restoring visibility does not resume.
|
|
4
|
+
* The returned cleanup removes owned styles/listeners and restores the host's display/flexDirection.
|
|
5
|
+
*/
|
|
6
|
+
export function attachPlayerLifecycle(host, canvas, pause) {
|
|
7
|
+
const owner = host.ownerDocument;
|
|
8
|
+
const previousDisplay = host.style.display;
|
|
9
|
+
const previousDirection = host.style.flexDirection;
|
|
10
|
+
host.style.display = "flex";
|
|
11
|
+
host.style.flexDirection = "column";
|
|
12
|
+
// Native SVG scaling and flex layout respond to container changes without allocating a ResizeObserver.
|
|
13
|
+
canvas.style.flex = "1 1 auto";
|
|
14
|
+
canvas.style.minHeight = "0";
|
|
15
|
+
canvas.style.minWidth = "0";
|
|
16
|
+
canvas.style.overflow = "hidden";
|
|
17
|
+
const style = owner.createElement("style");
|
|
18
|
+
style.textContent = ".flowsvg-canvas > svg { display: block; width: 100%; height: 100%; }";
|
|
19
|
+
host.append(style);
|
|
20
|
+
const onVisibilityChange = () => {
|
|
21
|
+
if (owner.visibilityState === "hidden")
|
|
22
|
+
pause();
|
|
23
|
+
};
|
|
24
|
+
owner.addEventListener("visibilitychange", onVisibilityChange);
|
|
25
|
+
return () => {
|
|
26
|
+
owner.removeEventListener("visibilitychange", onVisibilityChange);
|
|
27
|
+
style.remove();
|
|
28
|
+
host.style.display = previousDisplay;
|
|
29
|
+
host.style.flexDirection = previousDirection;
|
|
30
|
+
};
|
|
31
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React adapter for component lifecycle, props, events and imperative controls.
|
|
3
|
+
* Playback/rendering reuse browser; imports do not create DOM, and the player is created only on mount.
|
|
4
|
+
*/
|
|
5
|
+
import { type CSSProperties } from "react";
|
|
6
|
+
import { type PlayerErrorDetail, type PlayerState } from "./browser.js";
|
|
7
|
+
import type { DiagramDocument, ThemeName } from "./core.js";
|
|
8
|
+
/** React document, visual options and callbacks; document reference changes trigger a reload. */
|
|
9
|
+
export interface FlowPlayerProps {
|
|
10
|
+
document: DiagramDocument;
|
|
11
|
+
/** Positive playback multiplier; omit to use the player default. */
|
|
12
|
+
speed?: number;
|
|
13
|
+
theme?: ThemeName;
|
|
14
|
+
controls?: boolean;
|
|
15
|
+
/** Defaults to false: hover marks borders only. True also highlights backgrounds, including groups. */
|
|
16
|
+
hoverFill?: boolean;
|
|
17
|
+
reducedMotion?: boolean;
|
|
18
|
+
/** Initial position in milliseconds, applied once; use seek afterward, and document reloads reset to zero. */
|
|
19
|
+
initialTimeMs?: number;
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: CSSProperties;
|
|
22
|
+
onStepChange?: (state: PlayerState) => void;
|
|
23
|
+
onTimeUpdate?: (state: PlayerState) => void;
|
|
24
|
+
onPlayStateChange?: (state: PlayerState) => void;
|
|
25
|
+
onError?: (detail: PlayerErrorDetail) => void;
|
|
26
|
+
}
|
|
27
|
+
/** Imperative controls for a mounted component; calls before mounting or after unmounting throw a not-mounted error. */
|
|
28
|
+
export interface FlowPlayerHandle {
|
|
29
|
+
/** Resume from the current position when playback can advance. */
|
|
30
|
+
play(): void;
|
|
31
|
+
/** Pause while retaining the current frame. */
|
|
32
|
+
pause(): void;
|
|
33
|
+
/** Seek in milliseconds, clamping to the timeline; non-finite values become zero. */
|
|
34
|
+
seek(timeMs: number): void;
|
|
35
|
+
/** Pause and jump to a beat boundary; backward from inside a beat goes to that beat's start. */
|
|
36
|
+
step(direction: 1 | -1): void;
|
|
37
|
+
/** Set a finite positive multiplier; ignore invalid values. */
|
|
38
|
+
setSpeed(speed: number): void;
|
|
39
|
+
/** Reset/pause at zero on success; emit an error and retain playback state if validation fails. */
|
|
40
|
+
load(document: DiagramDocument): void;
|
|
41
|
+
/** Read the current browser-player snapshot. */
|
|
42
|
+
getState(): PlayerState;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* SSR renders only a host container; an effect mounts the browser player and unmount cleanup releases it.
|
|
46
|
+
* Document reference changes call load; visual/control options rebuild the instance, with initial positioning applied once.
|
|
47
|
+
*/
|
|
48
|
+
export declare const FlowPlayer: import("react").ForwardRefExoticComponent<FlowPlayerProps & import("react").RefAttributes<FlowPlayerHandle>>;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React adapter for component lifecycle, props, events and imperative controls.
|
|
3
|
+
* Playback/rendering reuse browser; imports do not create DOM, and the player is created only on mount.
|
|
4
|
+
*/
|
|
5
|
+
import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
6
|
+
import { createPlayer } from "./browser.js";
|
|
7
|
+
/**
|
|
8
|
+
* SSR renders only a host container; an effect mounts the browser player and unmount cleanup releases it.
|
|
9
|
+
* Document reference changes call load; visual/control options rebuild the instance, with initial positioning applied once.
|
|
10
|
+
*/
|
|
11
|
+
export const FlowPlayer = forwardRef(function FlowPlayer(props, ref) {
|
|
12
|
+
const hostRef = useRef(null);
|
|
13
|
+
const playerRef = useRef(null);
|
|
14
|
+
const eventCleanupRef = useRef(null);
|
|
15
|
+
const callbacksRef = useRef({});
|
|
16
|
+
const documentRef = useRef(null);
|
|
17
|
+
const optionsRef = useRef(null);
|
|
18
|
+
const initialTimeRef = useRef(props.initialTimeMs);
|
|
19
|
+
const initialAppliedRef = useRef(false);
|
|
20
|
+
// Listeners read current callbacks through a stable ref so callback identity changes do not rebuild the player.
|
|
21
|
+
callbacksRef.current = {
|
|
22
|
+
onStepChange: props.onStepChange,
|
|
23
|
+
onTimeUpdate: props.onTimeUpdate,
|
|
24
|
+
onPlayStateChange: props.onPlayStateChange,
|
|
25
|
+
onError: props.onError
|
|
26
|
+
};
|
|
27
|
+
useImperativeHandle(ref, () => ({
|
|
28
|
+
play: () => requirePlayer(playerRef).play(),
|
|
29
|
+
pause: () => requirePlayer(playerRef).pause(),
|
|
30
|
+
seek: (timeMs) => requirePlayer(playerRef).seek(timeMs),
|
|
31
|
+
step: (direction) => requirePlayer(playerRef).step(direction),
|
|
32
|
+
setSpeed: (speed) => requirePlayer(playerRef).setSpeed(speed),
|
|
33
|
+
load: (document) => requirePlayer(playerRef).load(document),
|
|
34
|
+
getState: () => requirePlayer(playerRef).getState()
|
|
35
|
+
}), []);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const host = hostRef.current;
|
|
38
|
+
if (!host)
|
|
39
|
+
return;
|
|
40
|
+
const nextOptions = {
|
|
41
|
+
theme: props.theme,
|
|
42
|
+
controls: props.controls,
|
|
43
|
+
hoverFill: props.hoverFill,
|
|
44
|
+
reducedMotion: props.reducedMotion
|
|
45
|
+
};
|
|
46
|
+
const existing = playerRef.current;
|
|
47
|
+
const optionsChanged = !sameOptions(optionsRef.current, nextOptions);
|
|
48
|
+
if (existing && !optionsChanged && documentRef.current !== props.document) {
|
|
49
|
+
existing.load(props.document);
|
|
50
|
+
documentRef.current = props.document;
|
|
51
|
+
}
|
|
52
|
+
else if (!existing || optionsChanged) {
|
|
53
|
+
// Detach old callbacks before replacement so old lifecycle events cannot reach the new component state.
|
|
54
|
+
eventCleanupRef.current?.();
|
|
55
|
+
existing?.destroy();
|
|
56
|
+
const player = createPlayer(host, props.document, nextOptions);
|
|
57
|
+
// A new instance needs current props even when the separate speed effect has no changed dependency.
|
|
58
|
+
if (props.speed !== undefined)
|
|
59
|
+
player.setSpeed(props.speed);
|
|
60
|
+
eventCleanupRef.current = attachPlayerEvents(player, callbacksRef);
|
|
61
|
+
playerRef.current = player;
|
|
62
|
+
documentRef.current = props.document;
|
|
63
|
+
optionsRef.current = nextOptions;
|
|
64
|
+
if (!initialAppliedRef.current && initialTimeRef.current !== undefined) {
|
|
65
|
+
player.seek(initialTimeRef.current);
|
|
66
|
+
}
|
|
67
|
+
initialAppliedRef.current = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
optionsRef.current = nextOptions;
|
|
71
|
+
}, [props.document, props.theme, props.controls, props.reducedMotion, props.hoverFill]);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const player = playerRef.current;
|
|
74
|
+
if (player && props.speed !== undefined)
|
|
75
|
+
player.setSpeed(props.speed);
|
|
76
|
+
}, [props.speed]);
|
|
77
|
+
useEffect(() => () => {
|
|
78
|
+
eventCleanupRef.current?.();
|
|
79
|
+
eventCleanupRef.current = null;
|
|
80
|
+
playerRef.current?.destroy();
|
|
81
|
+
playerRef.current = null;
|
|
82
|
+
documentRef.current = null;
|
|
83
|
+
optionsRef.current = null;
|
|
84
|
+
initialAppliedRef.current = false;
|
|
85
|
+
}, []);
|
|
86
|
+
const className = props.className ? `flowsvg ${props.className}` : "flowsvg";
|
|
87
|
+
return createElement("div", { ref: hostRef, className, style: props.style });
|
|
88
|
+
});
|
|
89
|
+
function requirePlayer(playerRef) {
|
|
90
|
+
if (!playerRef.current)
|
|
91
|
+
throw new Error("FlowPlayer is not mounted");
|
|
92
|
+
return playerRef.current;
|
|
93
|
+
}
|
|
94
|
+
function sameOptions(previous, next) {
|
|
95
|
+
if (!previous)
|
|
96
|
+
return false;
|
|
97
|
+
return previous.theme === next.theme
|
|
98
|
+
&& previous.controls === next.controls
|
|
99
|
+
&& previous.hoverFill === next.hoverFill
|
|
100
|
+
&& previous.reducedMotion === next.reducedMotion;
|
|
101
|
+
}
|
|
102
|
+
/** Translate browser event details to component callbacks; share cleanup between replacement and unmount. */
|
|
103
|
+
function attachPlayerEvents(player, callbacksRef) {
|
|
104
|
+
const onStepChange = (event) => {
|
|
105
|
+
callbacksRef.current.onStepChange?.(event.detail);
|
|
106
|
+
};
|
|
107
|
+
const onTimeUpdate = (event) => {
|
|
108
|
+
callbacksRef.current.onTimeUpdate?.(event.detail);
|
|
109
|
+
};
|
|
110
|
+
const onPlayStateChange = (event) => {
|
|
111
|
+
callbacksRef.current.onPlayStateChange?.(event.detail);
|
|
112
|
+
};
|
|
113
|
+
const onError = (event) => {
|
|
114
|
+
callbacksRef.current.onError?.(event.detail);
|
|
115
|
+
};
|
|
116
|
+
player.element.addEventListener("stepchange", onStepChange);
|
|
117
|
+
player.element.addEventListener("timeupdate", onTimeUpdate);
|
|
118
|
+
player.element.addEventListener("playstatechange", onPlayStateChange);
|
|
119
|
+
player.element.addEventListener("error", onError);
|
|
120
|
+
return () => {
|
|
121
|
+
player.element.removeEventListener("stepchange", onStepChange);
|
|
122
|
+
player.element.removeEventListener("timeupdate", onTimeUpdate);
|
|
123
|
+
player.element.removeEventListener("playstatechange", onPlayStateChange);
|
|
124
|
+
player.element.removeEventListener("error", onError);
|
|
125
|
+
};
|
|
126
|
+
}
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* Control and canvas focus styles; CSS variables customize controls only. The core theme resolves diagram colors. */
|
|
2
|
+
.flowsvg-canvas:focus-visible {
|
|
3
|
+
outline: 3px solid #2563eb;
|
|
4
|
+
outline-offset: 3px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.flowsvg-controls {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-wrap: wrap;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: 0.5rem;
|
|
12
|
+
padding: 0.75rem;
|
|
13
|
+
color: var(--flowsvg-control-color, #172033);
|
|
14
|
+
background: var(--flowsvg-control-background, #ffffff);
|
|
15
|
+
font: 1rem/1.4 system-ui, sans-serif;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.flowsvg-controls :is(button, input, select) {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
min-height: 2.75rem;
|
|
21
|
+
max-width: 100%;
|
|
22
|
+
color: var(--flowsvg-control-color, #172033);
|
|
23
|
+
background: var(--flowsvg-control-background, #ffffff);
|
|
24
|
+
border: 1px solid var(--flowsvg-control-border, #64748b);
|
|
25
|
+
border-radius: 0.25rem;
|
|
26
|
+
font: inherit;
|
|
27
|
+
accent-color: var(--flowsvg-control-accent, #2563eb);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.flowsvg-controls :is(button, select) {
|
|
31
|
+
padding: 0.5rem 0.75rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.flowsvg-controls :is(button, input, select):focus-visible {
|
|
35
|
+
outline: 3px solid var(--flowsvg-control-focus, #2563eb);
|
|
36
|
+
outline-offset: 3px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.flowsvg-controls :disabled {
|
|
40
|
+
opacity: 0.55;
|
|
41
|
+
cursor: default;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.flowsvg-controls input[type="range"] {
|
|
45
|
+
flex: 1 1 10rem;
|
|
46
|
+
min-width: 4rem;
|
|
47
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG DOM synchronization: apply core-generated static SVG to existing elements and paint order.
|
|
3
|
+
* This layer stores no animation state and does not recalculate time, geometry or colors.
|
|
4
|
+
*/
|
|
5
|
+
/** Apply core serialization while retaining reusable SVG elements; accept only markup produced by the core. */
|
|
6
|
+
export declare function updateSvgDom(host: HTMLElement, markup: string): void;
|
package/dist/svg-dom.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG DOM synchronization: apply core-generated static SVG to existing elements and paint order.
|
|
3
|
+
* This layer stores no animation state and does not recalculate time, geometry or colors.
|
|
4
|
+
*/
|
|
5
|
+
/** Apply core serialization while retaining reusable SVG elements; accept only markup produced by the core. */
|
|
6
|
+
export function updateSvgDom(host, markup) {
|
|
7
|
+
const template = host.ownerDocument.createElement("template");
|
|
8
|
+
template.innerHTML = markup;
|
|
9
|
+
const next = template.content.firstElementChild;
|
|
10
|
+
if (!next)
|
|
11
|
+
throw new Error("Core SVG output is empty");
|
|
12
|
+
const current = host.firstElementChild;
|
|
13
|
+
if (!current || current.tagName !== next.tagName) {
|
|
14
|
+
host.replaceChildren(next);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
synchronizeElement(current, next);
|
|
18
|
+
}
|
|
19
|
+
/** Remove obsolete attributes before applying new ones so visibility/style changes leave no stale paint. */
|
|
20
|
+
function synchronizeElement(current, next) {
|
|
21
|
+
for (const attribute of Array.from(current.attributes)) {
|
|
22
|
+
if (!next.hasAttribute(attribute.name))
|
|
23
|
+
current.removeAttribute(attribute.name);
|
|
24
|
+
}
|
|
25
|
+
for (const attribute of Array.from(next.attributes)) {
|
|
26
|
+
if (current.getAttribute(attribute.name) !== attribute.value)
|
|
27
|
+
current.setAttribute(attribute.name, attribute.value);
|
|
28
|
+
}
|
|
29
|
+
if (next.children.length === 0) {
|
|
30
|
+
if (current.textContent !== next.textContent)
|
|
31
|
+
current.textContent = next.textContent;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
synchronizeChildren(current, next);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Match object keys first, reuse unkeyed elements by tag, and synchronize paint order.
|
|
38
|
+
* Retain matching elements so per-frame updates preserve interactions attached by the host.
|
|
39
|
+
*/
|
|
40
|
+
function synchronizeChildren(current, next) {
|
|
41
|
+
const existing = Array.from(current.children);
|
|
42
|
+
const keyed = new Map();
|
|
43
|
+
for (const child of existing) {
|
|
44
|
+
const key = elementKey(child);
|
|
45
|
+
if (key !== undefined)
|
|
46
|
+
keyed.set(key, child);
|
|
47
|
+
}
|
|
48
|
+
const used = new Set();
|
|
49
|
+
let cursor = current.firstElementChild;
|
|
50
|
+
for (const child of Array.from(next.children)) {
|
|
51
|
+
const key = elementKey(child);
|
|
52
|
+
const match = key === undefined
|
|
53
|
+
? existing.find((candidate) => !used.has(candidate) && elementKey(candidate) === undefined && candidate.tagName === child.tagName)
|
|
54
|
+
: keyed.get(key);
|
|
55
|
+
const target = match?.tagName === child.tagName ? match : child;
|
|
56
|
+
used.add(target);
|
|
57
|
+
if (target !== cursor)
|
|
58
|
+
current.insertBefore(target, cursor);
|
|
59
|
+
if (target === match)
|
|
60
|
+
synchronizeElement(target, child);
|
|
61
|
+
cursor = target.nextElementSibling;
|
|
62
|
+
}
|
|
63
|
+
for (const child of existing) {
|
|
64
|
+
if (!used.has(child))
|
|
65
|
+
child.remove();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function elementKey(element) {
|
|
69
|
+
const objectId = element.getAttribute("data-id");
|
|
70
|
+
if (objectId !== null)
|
|
71
|
+
return `object:${objectId}`;
|
|
72
|
+
const id = element.getAttribute("id");
|
|
73
|
+
return id === null ? undefined : `id:${id}`;
|
|
74
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Shared deterministic paint values for core rendering and browser-only interaction feedback. */
|
|
2
|
+
export interface ThemePalette {
|
|
3
|
+
background: string;
|
|
4
|
+
fill: string;
|
|
5
|
+
stroke: string;
|
|
6
|
+
text: string;
|
|
7
|
+
mutedText: string;
|
|
8
|
+
tagText: string;
|
|
9
|
+
tagFill: string;
|
|
10
|
+
accentText: string;
|
|
11
|
+
edge: string;
|
|
12
|
+
highlight: string;
|
|
13
|
+
}
|
|
14
|
+
/** Select a palette without consulting browser preferences. */
|
|
15
|
+
export declare function resolveThemePalette(theme: "light" | "dark" | undefined): ThemePalette;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const LIGHT_PALETTE = { background: "#ffffff", fill: "#e8eef7", stroke: "#253858", text: "#172033", mutedText: "#5f6f86", tagText: "#164b9b", tagFill: "#eaf1ff", accentText: "#0864d7", edge: "#4b607d", highlight: "#f59e0b" };
|
|
2
|
+
const DARK_PALETTE = { background: "#101828", fill: "#26344c", stroke: "#d9e2f2", text: "#f4f7fb", mutedText: "#b3c0d3", tagText: "#e8f1ff", tagFill: "#344e73", accentText: "#88bdff", edge: "#a9bad4", highlight: "#fbbf24" };
|
|
3
|
+
/** Select a palette without consulting browser preferences. */
|
|
4
|
+
export function resolveThemePalette(theme) {
|
|
5
|
+
return theme === "dark" ? DARK_PALETTE : LIGHT_PALETTE;
|
|
6
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic global timeline: compile actions/snapshots and evaluate base styles, visibility, highlights and focus.
|
|
3
|
+
* Spatial layout and SVG output belong to the core; theme defaults are supplied only during evaluation.
|
|
4
|
+
*/
|
|
5
|
+
import type { DiagramDocument, EvaluateOptions, FlowDirection, NodeVisualState, EdgeVisualState } from "./core.js";
|
|
6
|
+
type VisualValues = NodeVisualState & EdgeVisualState;
|
|
7
|
+
/** Evaluation-time theme defaults; unspecified snapshot colors remain unresolved so compilation does not fix a theme. */
|
|
8
|
+
export interface TimelinePalette {
|
|
9
|
+
fill: string;
|
|
10
|
+
stroke: string;
|
|
11
|
+
edge: string;
|
|
12
|
+
}
|
|
13
|
+
/** One transfer in a synchronized beat-local group; no transfer persists in inherited snapshots. */
|
|
14
|
+
export interface CompiledFlow {
|
|
15
|
+
target: string;
|
|
16
|
+
direction: FlowDirection;
|
|
17
|
+
text?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Object transition result; highlight is the overlay weight, while highlighted is the target logical state. */
|
|
20
|
+
export interface TimelineObject {
|
|
21
|
+
visible: boolean;
|
|
22
|
+
opacity: number;
|
|
23
|
+
highlight: number;
|
|
24
|
+
highlighted: boolean;
|
|
25
|
+
values: VisualValues;
|
|
26
|
+
}
|
|
27
|
+
/** Timeline state/progress only; the core adds absolute geometry, text, edge routing and camera bounds. */
|
|
28
|
+
export interface TimelineFrame {
|
|
29
|
+
timeMs: number;
|
|
30
|
+
sceneId: string | null;
|
|
31
|
+
beatId: string | null;
|
|
32
|
+
beatProgress: number;
|
|
33
|
+
transitionProgress: number;
|
|
34
|
+
focusBefore: string | null;
|
|
35
|
+
focusAfter: string | null;
|
|
36
|
+
caption?: string;
|
|
37
|
+
objects: ReadonlyMap<string, TimelineObject>;
|
|
38
|
+
flows: readonly CompiledFlow[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Flatten scenes into continuous beats and compile snapshots once; queries locate beats by binary search.
|
|
42
|
+
* Each beat inherits the previous final state; evaluation is independent of query order and clocks.
|
|
43
|
+
* See docs/adr/0002-continuous-global-timeline.md for time semantics.
|
|
44
|
+
*/
|
|
45
|
+
export declare class CompiledTimeline {
|
|
46
|
+
readonly totalDuration: number;
|
|
47
|
+
private readonly beats;
|
|
48
|
+
private readonly initial;
|
|
49
|
+
private readonly title?;
|
|
50
|
+
constructor(document: DiagramDocument);
|
|
51
|
+
/** Clamp finite times globally; reduced motion skips interpolation but preserves beat selection and total duration. */
|
|
52
|
+
evaluateAt(timeMs: number, options: EvaluateOptions, palette: TimelinePalette): TimelineFrame;
|
|
53
|
+
/** Select the next beat at endMs, retaining the last beat at total duration; static documents return undefined. */
|
|
54
|
+
private findBeat;
|
|
55
|
+
}
|
|
56
|
+
/** Interpolate validated hexadecimal RGB/RGBA channels; preserve endpoint strings and normalize intermediate colors. */
|
|
57
|
+
export declare function mixColor(source: string, target: string, progress: number): string;
|
|
58
|
+
export {};
|