@volter/editor-ui 0.5.210 → 0.5.212

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.
Files changed (31) hide show
  1. package/NOTICE +12 -0
  2. package/contributions/component-board.service.ts +10 -2
  3. package/contributions/design-time-mount.service.ts +39 -2
  4. package/contributions/react-inspector.service.ts +11 -2
  5. package/contributions/story-documents.service.ts +14 -2
  6. package/contributions/ui.page.ts +28 -0
  7. package/package.json +78 -13
  8. package/src/host/authoring/struct-write-pipe.ts +102 -0
  9. package/src/host/components/ResolutionPicker.tsx +83 -0
  10. package/src/host/crash-null-boundary.ts +24 -0
  11. package/src/host/portable-stories.ts +44 -0
  12. package/src/host/projection/dom.ts +40 -0
  13. package/src/host/react-mount-runtime.ts +76 -0
  14. package/src/host/realm-services.ts +53 -0
  15. package/src/host/roots/react-root.ts +82 -0
  16. package/src/host/stories/story-arg-descriptors.ts +50 -0
  17. package/src/host/stories/story-media-presence.ts +89 -0
  18. package/src/host/stories/story-presentation.ts +203 -0
  19. package/src/host/surface-content.ts +44 -0
  20. package/src/react/composition-movie.ts +247 -0
  21. package/src/react/design-time-react-mount.ts +525 -0
  22. package/src/react/react-inspector-section.tsx +2247 -0
  23. package/src/react/react-world-authoring-adapter.ts +2602 -0
  24. package/src/react/story-documents/story-args-section.ts +9 -0
  25. package/src/react/story-documents/story-documents.tsx +1342 -0
  26. package/src/react/story-paint-bounds.ts +94 -0
  27. package/src/react/ui-board-document.tsx +55 -0
  28. package/src/react/ui-board-title.ts +9 -0
  29. package/src/react/ui-component-board.ts +52 -0
  30. package/src/runtime/presentation.ts +60 -0
  31. package/contributions/play-layers.service.ts +0 -31
@@ -0,0 +1,247 @@
1
+ /**
2
+ * A REACT COMPOSITION, RENDERED FRAME BY FRAME into `@volter/editor-movie`'s encoder: the story
3
+ * document's Render and `cyclotron render-movie --composition <story>` (the verb
4
+ * `story-render-movie`; docs/adr/movie-sound-and-react-frames.md, decision 3). A story is a
5
+ * composition when it reads `useMovieFrame()`; its `parameters.volter.movie` gives its length
6
+ * (`{ fps, frames }`) and, optionally, its size.
7
+ *
8
+ * - AN ISOLATED EVALUATION (DOCUMENT-VIEW-OWNERSHIP.md's Captures). The render has a mount of its
9
+ * own, off-screen in the editor's document (so the project's scoped CSS styles it as it styles the
10
+ * board), holding only the composition. The board's card and every other document are untouched,
11
+ * and only the mount's own animations are read (`getAnimations({ subtree: true })`).
12
+ * - A FIXED REVISION. The composed story the registry holds at the start is the one rendered; when
13
+ * the registry's story changes during the render (its module or a fixture was edited), the render
14
+ * is refused by name and nothing is written.
15
+ * - EACH FRAME, IN THIS ORDER: React commits the frame (the SDK's isolated story mount commits
16
+ * synchronously), so an element mounted, replaced or restyled at this frame exists; style is
17
+ * recalculated, so the CSS animations and transitions that commit started exist; every animation
18
+ * in the mount is paused and seeked to its own time, `1000 * (frame - f0) / fps` where `f0` is the
19
+ * frame it was first found at; then the frame is drawn by the editor's capture
20
+ * (`documents.drawElement`).
21
+ * - THE DRAWN COPY FREEZES MOTION, so before each draw the seeked value of every animated property
22
+ * is written inline, `!important`, on its element, and removed after. An animated pseudo-element
23
+ * cannot be written inline and draws un-animated.
24
+ * - TEARDOWN on end or failure: the root unmounted, the mount removed, the encoder closed.
25
+ */
26
+ import { createElement } from 'react';
27
+ import { beginMovie, type Movie } from '@volter/editor-movie';
28
+ import { MovieFrameContext, type HeldMovieFrame } from '@volter/editor-movie/clock';
29
+ import { getCurrentProject } from '@volter/sdk/active-project';
30
+ import { commandJobs } from '@volter/sdk/commands';
31
+ import { editorHost } from '@volter/sdk/host';
32
+ import { mountIsolatedStory } from '@volter/sdk/stories/StoryPreviewMount';
33
+ import type { ProjectPreviewStory } from '@volter/sdk/stories/story-types';
34
+
35
+ /** What a render is asked for. `story` is the Storybook id, or an export name only one story has. */
36
+ interface CompositionRenderRequest {
37
+ readonly story: string;
38
+ /** The project file the movie is written to; `movies/<story id>.webm` by default. */
39
+ readonly out?: string | undefined;
40
+ /** The first and last composition frames rendered; all of them by default. */
41
+ readonly from?: number | undefined;
42
+ readonly to?: number | undefined;
43
+ /** The movie's width; its height keeps the composition's aspect. */
44
+ readonly width?: number | undefined;
45
+ }
46
+
47
+ /** A composition's length and size, from its story's `parameters.volter.movie`. */
48
+ interface CompositionMovie {
49
+ readonly fps: number;
50
+ readonly frames: number;
51
+ readonly width: number;
52
+ readonly height: number;
53
+ }
54
+
55
+ /** A render: where it stands, and once done the project file it wrote. */
56
+ interface CompositionRender {
57
+ readonly story: string;
58
+ readonly out: string;
59
+ readonly frame: number;
60
+ readonly frames: number;
61
+ readonly state: 'rendering' | 'done' | 'failed';
62
+ readonly error?: string;
63
+ readonly bytes?: number;
64
+ readonly codec?: string;
65
+ }
66
+
67
+ /** The renders, by story id: what the story document's Render shows and the status verb answers. */
68
+ export const compositionRenders = commandJobs<CompositionRender>();
69
+
70
+ /** The story a request names, refused by name when none or several match. */
71
+ async function compositionStory(name: string): Promise<ProjectPreviewStory> {
72
+ const stories = editorHost().stories;
73
+ await stories.whenReady();
74
+ const all = stories.previewStories();
75
+ const byId = all.find((story) => story.id === name);
76
+ if (byId) return byId;
77
+ const byExport = all.filter((story) => story.name === name);
78
+ if (byExport.length === 1) return byExport[0]!;
79
+ if (byExport.length > 1)
80
+ throw new Error(`Several stories are "${name}": ${byExport.map((story) => story.id).join(', ')}. Name one by its id.`);
81
+ throw new Error(`No story is "${name}". The project's stories: ${all.map((story) => story.id).join(', ') || 'none'}.`);
82
+ }
83
+
84
+ /** A story's `parameters.volter.movie`, or `undefined` when it declares none. */
85
+ function movieParameters(story: { readonly parameters: Record<string, unknown> }): Record<string, unknown> | undefined {
86
+ const volter = story.parameters['volter'];
87
+ const movie = typeof volter === 'object' && volter !== null ? (volter as Record<string, unknown>)['movie'] : undefined;
88
+ return typeof movie === 'object' && movie !== null ? (movie as Record<string, unknown>) : undefined;
89
+ }
90
+
91
+ /** Whether a story declares a movie, so its document offers Render. */
92
+ export const declaresMovie = (story: { readonly parameters: Record<string, unknown> }): boolean => movieParameters(story) !== undefined;
93
+
94
+ /** The story's movie, with the project's resolution as its size when it gives none. */
95
+ function compositionMovie(story: ProjectPreviewStory): CompositionMovie {
96
+ const movie = movieParameters(story) ?? {};
97
+ const positive = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0;
98
+ const { fps, frames } = movie;
99
+ if (!positive(fps) || !positive(frames) || !Number.isInteger(frames))
100
+ throw new Error(`The story ${story.id} (${story.modulePath}) declares no movie: a composition's story gives parameters.volter.movie = { fps, frames }.`);
101
+ const resolution = getCurrentProject()?.config.resolution;
102
+ const width = positive(movie['width']) ? movie['width'] : resolution?.width ?? 1280;
103
+ const height = positive(movie['height']) ? movie['height'] : resolution?.height ?? 720;
104
+ return { fps, frames, width, height };
105
+ }
106
+
107
+ const kebab = (property: string): string =>
108
+ property.startsWith('--') ? property : property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
109
+
110
+ /** The CSS properties an animation sets on its element. */
111
+ function animatedProperties(animation: Animation): string[] {
112
+ if (animation instanceof CSSTransition) return [animation.transitionProperty];
113
+ const effect = animation.effect;
114
+ if (!(effect instanceof KeyframeEffect)) return [];
115
+ const properties = new Set<string>();
116
+ for (const keyframe of effect.getKeyframes())
117
+ for (const key of Object.keys(keyframe))
118
+ if (!['offset', 'computedOffset', 'easing', 'composite'].includes(key)) properties.add(kebab(key));
119
+ return [...properties];
120
+ }
121
+
122
+ /** Each animated element's seeked values written inline, `!important`, and the undo. */
123
+ function holdAnimatedValues(animations: readonly Animation[]): () => void {
124
+ const undo: (() => void)[] = [];
125
+ for (const animation of animations) {
126
+ const effect = animation.effect;
127
+ if (!(effect instanceof KeyframeEffect) || effect.pseudoElement || !(effect.target instanceof HTMLElement || effect.target instanceof SVGElement)) continue;
128
+ const element = effect.target;
129
+ const computed = getComputedStyle(element);
130
+ for (const property of animatedProperties(animation)) {
131
+ const value = computed.getPropertyValue(property);
132
+ const before = { value: element.style.getPropertyValue(property), priority: element.style.getPropertyPriority(property) };
133
+ element.style.setProperty(property, value, 'important');
134
+ undo.push(() => element.style.setProperty(property, before.value, before.priority));
135
+ }
136
+ }
137
+ return () => { for (const step of undo.reverse()) step(); };
138
+ }
139
+
140
+ /** Render `story` frames `from`..`to` into a WebM, which `@volter/editor-movie` has played before handing it back. */
141
+ async function renderFrames(
142
+ story: ProjectPreviewStory,
143
+ request: CompositionRenderRequest,
144
+ progress: (frame: number, frames: number) => void,
145
+ ): Promise<{ readonly bytes: Uint8Array; readonly frames: number; readonly codec: string }> {
146
+ const composition = compositionMovie(story);
147
+ const from = request.from ?? 0;
148
+ const to = request.to ?? composition.frames - 1;
149
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to >= composition.frames || to < from)
150
+ throw new Error(`Frames ${from}-${to} are not within the composition's 0-${composition.frames - 1}.`);
151
+ const scale = request.width ? request.width / composition.width : 1;
152
+ const size = { width: Math.round(composition.width * scale), height: Math.round(composition.height * scale) };
153
+ const host = editorHost();
154
+
155
+ // THE REVISION, bound at the start. A refresh a source write starts keeps every module the write did not touch, so
156
+ // only this story's own module or fixtures changing gives it a new composed component.
157
+ const bound = story.Component;
158
+ let revised: string | null = null;
159
+ const stopWatching = host.stories.subscribe(() => {
160
+ if (!host.stories.ready()) return;
161
+ const now = host.stories.previewStories().find((one) => one.id === story.id);
162
+ if (now?.Component !== bound) revised = `The composition ${story.id} changed while it rendered (${story.modulePath}); nothing was written. Render it again.`;
163
+ });
164
+
165
+ // The mount, off-screen: the drawn copy is the container alone, so where it sits does not show.
166
+ const shelf = document.createElement('div');
167
+ shelf.setAttribute('aria-hidden', 'true');
168
+ Object.assign(shelf.style, { position: 'fixed', left: '-100000px', top: '0', pointerEvents: 'none' });
169
+ const container = document.createElement('div');
170
+ Object.assign(container.style, { position: 'relative', width: `${composition.width}px`, height: `${composition.height}px`, overflow: 'hidden' });
171
+ shelf.appendChild(container);
172
+ let movie: Movie | null = null;
173
+ let unmount = () => {};
174
+ try {
175
+ document.body.appendChild(shelf);
176
+ await document.fonts.ready;
177
+ let held: HeldMovieFrame | null = null;
178
+ let read = false;
179
+ let loaded: Promise<unknown> | null = null;
180
+ // The story under the movie's clock; its loaders run once, for the first frame.
181
+ const Frame = Object.assign(
182
+ (props: Record<string, unknown>) => createElement(MovieFrameContext, { value: held }, createElement(story.Component, props)),
183
+ story.Component,
184
+ { load: () => (loaded ??= story.Component.load()) },
185
+ );
186
+ movie = await beginMovie({ ...size, fps: composition.fps });
187
+ const output = document.createElement('canvas');
188
+ const firstSeen = new Map<Animation, number>();
189
+ const projectRoot = host.projectLocalState.projectRootPath() ?? undefined;
190
+
191
+ for (let at = from; at <= to; at++) {
192
+ if (revised) throw new Error(revised);
193
+ held = {
194
+ value: { frame: at, fps: composition.fps, time: (1000 * at) / composition.fps },
195
+ read: () => { read = true; },
196
+ };
197
+ // 1. React commits this frame
198
+ const mounted = await mountIsolatedStory(container, story.modulePath, story.name, Frame);
199
+ if (!mounted) throw new Error(`The composition ${story.id} did not mount at frame ${at}.`);
200
+ unmount = mounted.unmount;
201
+ if (!read) throw new Error(`The story ${story.id} never reads useMovieFrame(), so it is not a composition.`);
202
+ // 2. style recalculated, so the animations that commit started exist
203
+ getComputedStyle(container).getPropertyValue('opacity');
204
+ // 3. the mount's own animations, each at its own time
205
+ const animations = container.getAnimations({ subtree: true });
206
+ for (const animation of animations) {
207
+ if (!firstSeen.has(animation)) firstSeen.set(animation, at);
208
+ animation.pause();
209
+ animation.currentTime = (1000 * (at - firstSeen.get(animation)!)) / composition.fps;
210
+ }
211
+ // 4. drawn
212
+ const release = holdAnimatedValues(animations);
213
+ try {
214
+ await host.documents.drawElement(container, output, { size, projectRoot });
215
+ } finally {
216
+ release();
217
+ }
218
+ await movie.frame(output);
219
+ progress(at - from + 1, to - from + 1);
220
+ }
221
+ if (revised) throw new Error(revised);
222
+ const ending = movie;
223
+ movie = null;
224
+ const bytes = await ending.end();
225
+ return { bytes, frames: ending.frames, codec: ending.codec };
226
+ } finally {
227
+ stopWatching();
228
+ movie?.cancel();
229
+ unmount();
230
+ shelf.remove();
231
+ }
232
+ }
233
+
234
+ /** Start rendering the composition a request names into its project file, answering at once. One story renders once
235
+ * at a time. */
236
+ export async function startCompositionRender(request: CompositionRenderRequest): Promise<CompositionRender> {
237
+ const story = await compositionStory(request.story);
238
+ if (compositionRenders.get(story.id)?.state === 'rendering') throw new Error(`The composition ${story.id} is already rendering.`);
239
+ const out = request.out ?? `movies/${story.id}.webm`;
240
+ if (!/\.webm$/i.test(out)) throw new Error(`A movie is written as .webm, not ${out}.`);
241
+ return compositionRenders.start(story.id, { story: story.id, out, frame: 0, frames: 0, state: 'rendering' }, async (report) => {
242
+ let render: CompositionRender = { story: story.id, out, frame: 0, frames: 0, state: 'rendering' };
243
+ const done = await renderFrames(story, request, (frame, frames) => { render = { ...render, frame, frames }; report(render); });
244
+ await editorHost().files.write(out, done.bytes);
245
+ report({ ...render, state: 'done', frame: done.frames, frames: done.frames, bytes: done.bytes.length, codec: done.codec });
246
+ });
247
+ }