@editframe/react 0.26.3-beta.0 → 0.26.4-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@editframe/react",
3
- "version": "0.26.3-beta.0",
3
+ "version": "0.26.4-beta.0",
4
4
  "description": "",
5
5
  "exports": {
6
6
  ".": {
@@ -22,7 +22,7 @@
22
22
  "author": "",
23
23
  "license": "UNLICENSED",
24
24
  "dependencies": {
25
- "@editframe/elements": "0.26.3-beta.0",
25
+ "@editframe/elements": "0.26.4-beta.0",
26
26
  "@lit/task": "^1.0.1",
27
27
  "lit": "^3.3.1",
28
28
  "react": "^18.3.0"
package/tsdown.config.ts CHANGED
@@ -16,7 +16,7 @@ const inlineCssPlugin = (): Plugin => ({
16
16
  path.dirname(importer),
17
17
  source.replace("?inline", ""),
18
18
  );
19
- return { id: resolved + "?inline", external: false };
19
+ return { id: `${resolved}?inline`, external: false };
20
20
  }
21
21
  return null;
22
22
  },
@@ -1,13 +0,0 @@
1
- import { EFTimeDisplay } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const TimeDisplay = createComponent({
6
- tagName: "ef-time-display",
7
- elementClass: EFTimeDisplay,
8
- react: React,
9
- });
10
-
11
- export type TimeDisplayProps = {
12
- className?: string;
13
- };
@@ -1,9 +0,0 @@
1
- import { EFAudio as EFAudioElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Audio = createComponent({
6
- tagName: "ef-audio",
7
- elementClass: EFAudioElement,
8
- react: React,
9
- });
@@ -1,39 +0,0 @@
1
- import {
2
- EFCaptionsActiveWord as EFCaptionsActiveWordElement,
3
- EFCaptionsAfterActiveWord as EFCaptionsAfterActiveWordElement,
4
- EFCaptionsBeforeActiveWord as EFCaptionsBeforeActiveWordElement,
5
- EFCaptions as EFCaptionsElement,
6
- EFCaptionsSegment as EFCaptionsSegmentElement,
7
- } from "@editframe/elements";
8
- import React from "react";
9
- import { createComponent } from "../hooks/create-element";
10
-
11
- export const Captions = createComponent({
12
- tagName: "ef-captions",
13
- elementClass: EFCaptionsElement,
14
- react: React,
15
- });
16
-
17
- export const CaptionsActiveWord = createComponent({
18
- tagName: "ef-captions-active-word",
19
- elementClass: EFCaptionsActiveWordElement,
20
- react: React,
21
- });
22
-
23
- export const CaptionsSegment = createComponent({
24
- tagName: "ef-captions-segment",
25
- elementClass: EFCaptionsSegmentElement,
26
- react: React,
27
- });
28
-
29
- export const CaptionsBeforeActiveWord = createComponent({
30
- tagName: "ef-captions-before-active-word",
31
- elementClass: EFCaptionsBeforeActiveWordElement,
32
- react: React,
33
- });
34
-
35
- export const CaptionsAfterActiveWord = createComponent({
36
- tagName: "ef-captions-after-active-word",
37
- elementClass: EFCaptionsAfterActiveWordElement,
38
- react: React,
39
- });
@@ -1,9 +0,0 @@
1
- import { EFImage as EFImageElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Image = createComponent({
6
- tagName: "ef-image",
7
- elementClass: EFImageElement,
8
- react: React,
9
- });
@@ -1,11 +0,0 @@
1
- import { EFSurface as EFSurfaceElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Surface = createComponent({
6
- tagName: "ef-surface",
7
- elementClass: EFSurfaceElement,
8
- react: React,
9
- displayName: "Surface",
10
- events: {},
11
- });
@@ -1,11 +0,0 @@
1
- import { EFThumbnailStrip as EFThumbnailStripElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const ThumbnailStrip = createComponent({
6
- tagName: "ef-thumbnail-strip",
7
- elementClass: EFThumbnailStripElement,
8
- react: React,
9
- displayName: "ThumbnailStrip",
10
- events: {},
11
- });
@@ -1,9 +0,0 @@
1
- import { EFTimegroup as EFTimegroupElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Timegroup = createComponent({
6
- tagName: "ef-timegroup",
7
- elementClass: EFTimegroupElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFVideo as EFVideoElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Video = createComponent({
6
- tagName: "ef-video",
7
- elementClass: EFVideoElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFWaveform as EFWaveformElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Waveform = createComponent({
6
- tagName: "ef-waveform",
7
- elementClass: EFWaveformElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFConfiguration } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Configuration = createComponent({
6
- tagName: "ef-configuration",
7
- elementClass: EFConfiguration,
8
- react: React,
9
- });
@@ -1,112 +0,0 @@
1
- import React from "react";
2
- import { createRoot, hydrateRoot, type Root } from "react-dom/client";
3
- import { renderToString } from "react-dom/server";
4
- import { test as baseTest, describe, vi } from "vitest";
5
- import { Timegroup } from "../elements/Timegroup";
6
- import { setIsomorphicEffect } from "../hooks/create-element";
7
- import { Configuration } from "./Configuration";
8
- import { Controls } from "./Controls";
9
- import { Preview } from "./Preview";
10
- import { TogglePlay } from "./TogglePlay";
11
-
12
- const test = baseTest.extend<{
13
- root: Root;
14
- markup: JSX.Element;
15
- stringMarkup: string;
16
- hydratedContainer: HTMLElement;
17
- renderedContainer: HTMLElement;
18
- }>({
19
- // biome-ignore lint/correctness/noEmptyPattern: Required by Vitest fixture syntax
20
- markup: async ({}, use) => {
21
- const markup = (
22
- <>
23
- <Configuration>
24
- {/* biome-ignore lint/correctness/useUniqueElementIds: OK for test fixture with single instance */}
25
- <Preview id="test-preview">
26
- <Timegroup mode="fixed" duration="10s" />
27
- </Preview>
28
- <Controls target="test-preview">
29
- <TogglePlay />
30
- </Controls>
31
- </Configuration>
32
- </>
33
- );
34
- await use(markup);
35
- },
36
- stringMarkup: async ({ markup }, use) => {
37
- setIsomorphicEffect(React.useEffect);
38
- const stringMarkup = renderToString(markup);
39
- setIsomorphicEffect(React.useLayoutEffect);
40
- await use(stringMarkup);
41
- },
42
- hydratedContainer: async ({ stringMarkup, markup }, use) => {
43
- const container = document.createElement("div");
44
- document.body.appendChild(container);
45
- container.innerHTML = stringMarkup;
46
- hydrateRoot(container, markup);
47
- await use(container);
48
- container.remove();
49
- },
50
- // biome-ignore lint/correctness/noEmptyPattern: Required by Vitest fixture syntax
51
- renderedContainer: async ({}, use) => {
52
- const container = document.createElement("div");
53
- document.body.appendChild(container);
54
- await use(container);
55
- container.remove();
56
- },
57
- root: async ({ renderedContainer, markup }, use) => {
58
- const root = createRoot(renderedContainer);
59
- root.render(markup);
60
- await use(root);
61
- root.unmount();
62
- },
63
- });
64
-
65
- describe("Controls", () => {
66
- describe("renderedContainer", () => {
67
- test.skip("works", async ({ renderedContainer, expect }) => {
68
- await vi.waitUntil(
69
- () => {
70
- return renderedContainer.innerHTML.includes("ef-controls");
71
- },
72
- { timeout: 5000 },
73
- );
74
- const controls =
75
- // biome-ignore lint/style/noNonNullAssertion: Safe in tests where elements are guaranteed to exist
76
- renderedContainer.getElementsByTagName("ef-controls")[0]!;
77
- const preview =
78
- // biome-ignore lint/style/noNonNullAssertion: Safe in tests where elements are guaranteed to exist
79
- renderedContainer.getElementsByTagName("ef-preview")[0]!;
80
- const togglePlay =
81
- // biome-ignore lint/style/noNonNullAssertion: Safe in tests where elements are guaranteed to exist
82
- renderedContainer.getElementsByTagName("ef-toggle-play")[0]!;
83
-
84
- expect(controls.targetElement).toBe(preview);
85
- expect(togglePlay.efContext).toBe(preview);
86
- }, 5000);
87
- });
88
-
89
- describe("hydratedContainer", () => {
90
- test("proxies contexts following hydration", async ({
91
- hydratedContainer,
92
- expect,
93
- }) => {
94
- await vi.waitUntil(
95
- () => hydratedContainer.innerHTML.includes("ef-controls"),
96
- { timeout: 5000 },
97
- );
98
- const controls =
99
- // biome-ignore lint/style/noNonNullAssertion: Safe in tests where elements are guaranteed to exist
100
- hydratedContainer.getElementsByTagName("ef-controls")[0]!;
101
- const preview =
102
- // biome-ignore lint/style/noNonNullAssertion: Safe in tests where elements are guaranteed to exist
103
- hydratedContainer.getElementsByTagName("ef-preview")[0]!;
104
- const togglePlay =
105
- // biome-ignore lint/style/noNonNullAssertion: Safe in tests where elements are guaranteed to exist
106
- hydratedContainer.getElementsByTagName("ef-toggle-play")[0]!;
107
-
108
- expect(controls.targetElement).toBe(preview);
109
- expect(togglePlay.efContext).toBe(preview);
110
- }, 5000);
111
- });
112
- });
@@ -1,9 +0,0 @@
1
- import { EFControls as EFControlsElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Controls = createComponent({
6
- tagName: "ef-controls",
7
- elementClass: EFControlsElement,
8
- react: React,
9
- });
package/src/gui/EFDial.ts DELETED
@@ -1,12 +0,0 @@
1
- import { EFDial as EFDialElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Dial = createComponent({
6
- tagName: "ef-dial",
7
- elementClass: EFDialElement,
8
- react: React,
9
- events: {
10
- onChange: "change",
11
- },
12
- });
@@ -1,12 +0,0 @@
1
- import { EFResizableBox as EFResizableBoxElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const ResizableBox = createComponent({
6
- tagName: "ef-resizable-box",
7
- elementClass: EFResizableBoxElement,
8
- react: React,
9
- events: {
10
- onBoundsChange: "bounds-change",
11
- },
12
- });
@@ -1,9 +0,0 @@
1
- import { EFFilmstrip as EFFilmstripElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Filmstrip = createComponent({
6
- tagName: "ef-filmstrip",
7
- elementClass: EFFilmstripElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFFitScale as EFFitScaleElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const FitScale = createComponent({
6
- tagName: "ef-fit-scale",
7
- elementClass: EFFitScaleElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFFocusOverlay as EFFocusOverlayElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const FocusOverlay = createComponent({
6
- tagName: "ef-focus-overlay",
7
- elementClass: EFFocusOverlayElement,
8
- react: React,
9
- });
package/src/gui/Pause.ts DELETED
@@ -1,9 +0,0 @@
1
- import { EFPause as EFPauseElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Pause = createComponent({
6
- tagName: "ef-pause",
7
- elementClass: EFPauseElement,
8
- react: React,
9
- });
package/src/gui/Play.ts DELETED
@@ -1,9 +0,0 @@
1
- import { EFPlay as EFPlayElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Play = createComponent({
6
- tagName: "ef-play",
7
- elementClass: EFPlayElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFPreview as EFPreviewElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Preview = createComponent({
6
- tagName: "ef-preview",
7
- elementClass: EFPreviewElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFScrubber as EFScrubberElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Scrubber = createComponent({
6
- tagName: "ef-scrubber",
7
- elementClass: EFScrubberElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFToggleLoop as EFToggleLoopElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const ToggleLoop = createComponent({
6
- tagName: "ef-toggle-loop",
7
- elementClass: EFToggleLoopElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFTogglePlay as EFTogglePlayElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const TogglePlay = createComponent({
6
- tagName: "ef-toggle-play",
7
- elementClass: EFTogglePlayElement,
8
- react: React,
9
- });
@@ -1,9 +0,0 @@
1
- import { EFWorkbench as EFWorkbenchElement } from "@editframe/elements";
2
- import React from "react";
3
- import { createComponent } from "../hooks/create-element";
4
-
5
- export const Workbench = createComponent({
6
- tagName: "ef-workbench",
7
- elementClass: EFWorkbenchElement,
8
- react: React,
9
- });
@@ -1,167 +0,0 @@
1
- import React from "react";
2
-
3
- let isomorphicEffect =
4
- typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
5
-
6
- export function setIsomorphicEffect(
7
- effect: typeof React.useLayoutEffect | typeof React.useEffect,
8
- ) {
9
- isomorphicEffect = effect;
10
- }
11
-
12
- const reservedReactProperties = new Set([
13
- "children",
14
- "localName",
15
- "ref",
16
- "style",
17
- "className",
18
- ]);
19
- const listenedEvents = new WeakMap<Element, Map<string, EventListenerObject>>();
20
-
21
- type Constructor<T> = { new (): T };
22
- type EventNames = Record<string, string>;
23
-
24
- type EventListeners<E extends EventNames> = {
25
- [K in keyof E]?: (e: Event) => void;
26
- };
27
-
28
- type ElementProps<I> = Partial<Omit<I, keyof HTMLElement>>;
29
- type ComponentProps<I, E extends EventNames = {}> = Omit<
30
- React.HTMLAttributes<I>,
31
- keyof E | keyof ElementProps<I>
32
- > &
33
- EventListeners<E> &
34
- ElementProps<I>;
35
-
36
- export type ReactWebComponent<
37
- I extends HTMLElement,
38
- E extends EventNames = {},
39
- > = React.ForwardRefExoticComponent<
40
- ComponentProps<I, E> & React.RefAttributes<I>
41
- >;
42
-
43
- export interface Options<I extends HTMLElement, E extends EventNames = {}> {
44
- react: typeof React;
45
- tagName: string;
46
- elementClass: Constructor<I>;
47
- events?: E;
48
- displayName?: string;
49
- }
50
-
51
- function addOrUpdateEventListener(
52
- node: Element,
53
- event: string,
54
- listener?: (e?: Event) => void,
55
- ) {
56
- let events = listenedEvents.get(node);
57
- if (!events) {
58
- events = new Map();
59
- listenedEvents.set(node, events);
60
- }
61
- let handler = events.get(event);
62
-
63
- if (listener) {
64
- if (!handler) {
65
- handler = { handleEvent: listener };
66
- events.set(event, handler);
67
- node.addEventListener(event, handler);
68
- } else {
69
- handler.handleEvent = listener;
70
- }
71
- } else if (handler) {
72
- events.delete(event);
73
- node.removeEventListener(event, handler);
74
- }
75
- }
76
-
77
- function setProperty<E extends Element>(
78
- node: E,
79
- name: string,
80
- value: unknown,
81
- old: unknown,
82
- events?: EventNames,
83
- ) {
84
- const event = events?.[name];
85
- if (event) {
86
- if (value !== old)
87
- addOrUpdateEventListener(node, event, value as (e?: Event) => void);
88
- return;
89
- }
90
- node[name as keyof E] = value as E[keyof E];
91
- if (
92
- (value === undefined || value === null) &&
93
- name in HTMLElement.prototype
94
- ) {
95
- node.removeAttribute(name);
96
- }
97
- }
98
-
99
- export function createComponent<
100
- I extends HTMLElement,
101
- E extends EventNames = {},
102
- >({
103
- react: React,
104
- tagName,
105
- elementClass,
106
- events,
107
- displayName,
108
- }: Options<I, E>): ReactWebComponent<I, E> {
109
- const eventProps = new Set(Object.keys(events ?? {}));
110
-
111
- const ReactComponent = React.forwardRef<I, ComponentProps<I, E>>(
112
- (props, ref) => {
113
- const elementRef = React.useRef<I | null>(null);
114
- const prevPropsRef = React.useRef(new Map<string, unknown>());
115
-
116
- const reactProps: Record<string, unknown> = {
117
- suppressHydrationWarning: true,
118
- };
119
- const elementProps: Record<string, unknown> = {};
120
-
121
- for (const [k, v] of Object.entries(props)) {
122
- if (reservedReactProperties.has(k)) {
123
- reactProps[k === "className" ? "class" : k] = v;
124
- continue;
125
- }
126
- if (eventProps.has(k) || k in elementClass.prototype)
127
- elementProps[k] = v;
128
- reactProps[k] = v;
129
- }
130
-
131
- isomorphicEffect(() => {
132
- if (!elementRef.current) return;
133
- const newProps = new Map<string, unknown>();
134
- for (const key in elementProps) {
135
- setProperty(
136
- elementRef.current,
137
- key,
138
- props[key as keyof typeof props],
139
- prevPropsRef.current.get(key),
140
- events,
141
- );
142
- prevPropsRef.current.delete(key);
143
- newProps.set(key, props[key as keyof typeof props]);
144
- }
145
- for (const [key, value] of prevPropsRef.current) {
146
- setProperty(elementRef.current, key, undefined, value, events);
147
- }
148
- prevPropsRef.current = newProps;
149
-
150
- // Remove defer-hydration if present
151
- elementRef.current.removeAttribute("defer-hydration");
152
- }, [props]);
153
-
154
- return React.createElement(tagName, {
155
- ...reactProps,
156
- ref: (node: I) => {
157
- elementRef.current = node;
158
- if (typeof ref === "function") ref(node);
159
- else if (ref) ref.current = node;
160
- },
161
- });
162
- },
163
- );
164
-
165
- ReactComponent.displayName = displayName ?? elementClass.name;
166
- return ReactComponent as ReactWebComponent<I, E>;
167
- }
@@ -1,371 +0,0 @@
1
- import type { EFTimegroup } from "@editframe/elements";
2
- import { type FC, useEffect } from "react";
3
- import { createRoot } from "react-dom/client";
4
- import { assert, beforeEach, describe, test } from "vitest";
5
- import { Timegroup } from "../elements/Timegroup.js";
6
- import { Video } from "../elements/Video.js";
7
- import { Configuration } from "../gui/Configuration.js";
8
- import { Preview } from "../gui/Preview.js";
9
- import { useTimingInfo } from "./useTimingInfo.js";
10
-
11
- beforeEach(() => {
12
- while (document.body.children.length) {
13
- document.body.children[0]?.remove();
14
- }
15
- });
16
-
17
- interface TimingDisplayProps {
18
- onUpdate?: (info: {
19
- ownCurrentTimeMs: number;
20
- durationMs: number;
21
- percentComplete: number;
22
- }) => void;
23
- }
24
-
25
- const TimingDisplay: FC<TimingDisplayProps> = ({ onUpdate }) => {
26
- const { ownCurrentTimeMs, durationMs, percentComplete, ref } =
27
- useTimingInfo();
28
-
29
- useEffect(() => {
30
- if (onUpdate) {
31
- onUpdate({ ownCurrentTimeMs, durationMs, percentComplete });
32
- }
33
- }, [ownCurrentTimeMs, durationMs, percentComplete, onUpdate]);
34
-
35
- return (
36
- // biome-ignore lint/correctness/useUniqueElementIds: OK for test fixture with single instance
37
- <Preview id="test-preview">
38
- <Timegroup mode="fixed" duration="3s" ref={ref}>
39
- <Video
40
- src="https://editframe-dev-assets.s3.us-east-1.amazonaws.com/test-assets/test_audio.mp4"
41
- trim="0s-1s"
42
- />
43
- </Timegroup>
44
- </Preview>
45
- );
46
- };
47
-
48
- describe("useTimingInfo", () => {
49
- test("provides initial timing information", async () => {
50
- const container = document.createElement("div");
51
- document.body.appendChild(container);
52
-
53
- let receivedInfo: {
54
- ownCurrentTimeMs: number;
55
- durationMs: number;
56
- percentComplete: number;
57
- } | null = null;
58
-
59
- const root = createRoot(container);
60
- root.render(
61
- <Configuration>
62
- <TimingDisplay
63
- onUpdate={(info) => {
64
- receivedInfo = info;
65
- }}
66
- />
67
- </Configuration>,
68
- );
69
-
70
- // Wait for the component to mount and update
71
- await new Promise((resolve) => setTimeout(resolve, 100));
72
-
73
- const preview = container.querySelector("ef-preview");
74
- const timegroup = preview?.querySelector("ef-timegroup") as EFTimegroup;
75
-
76
- assert.ok(timegroup, "Timegroup should be rendered");
77
- await timegroup.updateComplete;
78
- await timegroup.waitForMediaDurations();
79
-
80
- // Wait for initial frame task
81
- await timegroup.frameTask.run();
82
-
83
- assert.ok(receivedInfo, "Should receive timing info");
84
- assert.equal(receivedInfo?.ownCurrentTimeMs, 0);
85
- assert.equal(receivedInfo?.durationMs, 3000);
86
- assert.equal(receivedInfo?.percentComplete, 0);
87
-
88
- root.unmount();
89
- container.remove();
90
- }, 5000);
91
-
92
- test("updates only on frame task completion, not on every Lit update", async () => {
93
- const container = document.createElement("div");
94
- document.body.appendChild(container);
95
-
96
- const updates: number[] = [];
97
-
98
- const root = createRoot(container);
99
- root.render(
100
- <Configuration>
101
- <TimingDisplay
102
- onUpdate={(info) => {
103
- updates.push(info.ownCurrentTimeMs);
104
- }}
105
- />
106
- </Configuration>,
107
- );
108
-
109
- // Wait for initial mount
110
- await new Promise((resolve) => setTimeout(resolve, 100));
111
-
112
- const preview = container.querySelector("ef-preview");
113
- const timegroup = preview?.querySelector("ef-timegroup") as EFTimegroup;
114
-
115
- assert.ok(timegroup, "Timegroup should be rendered");
116
- await timegroup.updateComplete;
117
- await timegroup.waitForMediaDurations();
118
-
119
- // Clear initial updates
120
- updates.length = 0;
121
-
122
- // Trigger multiple Lit updates without frame task
123
- // These should NOT trigger React updates
124
- timegroup.requestUpdate("mode");
125
- await timegroup.updateComplete;
126
- timegroup.requestUpdate("mode");
127
- await timegroup.updateComplete;
128
- timegroup.requestUpdate("mode");
129
- await timegroup.updateComplete;
130
-
131
- // Should have no updates since no frame tasks ran
132
- assert.equal(
133
- updates.length,
134
- 0,
135
- "Should not update on Lit property changes",
136
- );
137
-
138
- // Now trigger frame task via seek (proper API that triggers both task and update)
139
- await timegroup.seek(1000);
140
- // Give React a chance to process the state update
141
- await new Promise((resolve) => setTimeout(resolve, 50));
142
-
143
- // Should have exactly one update from frame task
144
- assert.ok(updates.length >= 1, "Should update once per frame task");
145
-
146
- root.unmount();
147
- container.remove();
148
- }, 5000);
149
-
150
- test("updates synchronously with frame tasks during seek", async () => {
151
- const container = document.createElement("div");
152
- document.body.appendChild(container);
153
-
154
- const updates: number[] = [];
155
-
156
- const root = createRoot(container);
157
- root.render(
158
- <Configuration>
159
- <TimingDisplay
160
- onUpdate={(info) => {
161
- updates.push(info.ownCurrentTimeMs);
162
- }}
163
- />
164
- </Configuration>,
165
- );
166
-
167
- await new Promise((resolve) => setTimeout(resolve, 100));
168
-
169
- const preview = container.querySelector("ef-preview");
170
- const timegroup = preview?.querySelector("ef-timegroup") as EFTimegroup;
171
-
172
- assert.ok(timegroup, "Timegroup should be rendered");
173
- await timegroup.updateComplete;
174
- await timegroup.waitForMediaDurations();
175
-
176
- // Clear initial updates
177
- updates.length = 0;
178
-
179
- // Seek to different times
180
- await timegroup.seek(1000);
181
- await new Promise((resolve) => setTimeout(resolve, 50));
182
- const updatesAfterFirstSeek = updates.length;
183
- assert.ok(updatesAfterFirstSeek > 0, "Should update after first seek");
184
-
185
- await timegroup.seek(2000);
186
- await new Promise((resolve) => setTimeout(resolve, 50));
187
- const updatesAfterSecondSeek = updates.length;
188
- assert.ok(
189
- updatesAfterSecondSeek > updatesAfterFirstSeek,
190
- "Should update after second seek",
191
- );
192
-
193
- // Verify the last update has the correct time
194
- const lastUpdate = updates[updates.length - 1];
195
- assert.equal(lastUpdate, 2000, "Should reflect the seeked time");
196
-
197
- root.unmount();
198
- container.remove();
199
- }, 5000);
200
-
201
- test("updates at controlled rate with sequential frame updates", async () => {
202
- const container = document.createElement("div");
203
- document.body.appendChild(container);
204
-
205
- const updateTimestamps: number[] = [];
206
- const updateTimes: number[] = [];
207
-
208
- const root = createRoot(container);
209
- root.render(
210
- <Configuration>
211
- <TimingDisplay
212
- onUpdate={(info) => {
213
- updateTimestamps.push(performance.now());
214
- updateTimes.push(info.ownCurrentTimeMs);
215
- }}
216
- />
217
- </Configuration>,
218
- );
219
-
220
- await new Promise((resolve) => setTimeout(resolve, 100));
221
-
222
- const preview = container.querySelector("ef-preview");
223
- const timegroup = preview?.querySelector("ef-timegroup") as EFTimegroup;
224
-
225
- assert.ok(timegroup, "Timegroup should be rendered");
226
- await timegroup.updateComplete;
227
- await timegroup.waitForMediaDurations();
228
-
229
- // Clear initial updates
230
- updateTimestamps.length = 0;
231
- updateTimes.length = 0;
232
-
233
- // Simulate frame-by-frame updates at a controlled rate (30fps)
234
- // Note: We use seek() rather than play() because AudioContext-based playback
235
- // doesn't work in headless browsers due to autoplay policies that require user interaction
236
- const FPS = 30;
237
- const MS_PER_FRAME = 1000 / FPS;
238
- const DURATION_MS = 500;
239
- const numFrames = Math.floor(DURATION_MS / MS_PER_FRAME);
240
-
241
- for (let i = 0; i < numFrames; i++) {
242
- const targetTime = i * MS_PER_FRAME;
243
- await timegroup.seek(targetTime);
244
- await new Promise((resolve) => setTimeout(resolve, MS_PER_FRAME));
245
- }
246
-
247
- // Should have received multiple updates during simulated playback
248
- assert.ok(
249
- updateTimestamps.length >= 10,
250
- `Should have at least 10 updates during simulated playback (got ${updateTimestamps.length})`,
251
- );
252
-
253
- // Calculate update intervals
254
- const intervals: number[] = [];
255
- for (let i = 1; i < updateTimestamps.length; i++) {
256
- intervals.push(updateTimestamps[i] - updateTimestamps[i - 1]);
257
- }
258
-
259
- // Average interval should be around 33ms (30fps) matching our simulated rate
260
- const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
261
-
262
- // Should be approximately 33ms per frame
263
- // Allow generous variance for timing imprecision in CI
264
- assert.ok(
265
- avgInterval > 20 && avgInterval < 100,
266
- `Update interval should be between 20-100ms (got ${avgInterval}ms), indicating controlled rate`,
267
- );
268
-
269
- root.unmount();
270
- container.remove();
271
- }, 5000);
272
-
273
- test("continues observing after errors in frame task", async () => {
274
- const container = document.createElement("div");
275
- document.body.appendChild(container);
276
-
277
- const updates: number[] = [];
278
-
279
- const root = createRoot(container);
280
- root.render(
281
- <Configuration>
282
- <TimingDisplay
283
- onUpdate={(info) => {
284
- updates.push(info.ownCurrentTimeMs);
285
- }}
286
- />
287
- </Configuration>,
288
- );
289
-
290
- await new Promise((resolve) => setTimeout(resolve, 100));
291
-
292
- const preview = container.querySelector("ef-preview");
293
- const timegroup = preview?.querySelector("ef-timegroup") as EFTimegroup;
294
-
295
- assert.ok(timegroup, "Timegroup should be rendered");
296
- await timegroup.updateComplete;
297
- await timegroup.waitForMediaDurations();
298
-
299
- updates.length = 0;
300
-
301
- // First seek triggers frame task
302
- await timegroup.seek(500);
303
- await new Promise((resolve) => setTimeout(resolve, 50));
304
- assert.ok(updates.length >= 1, "Should update after first seek");
305
-
306
- // Even if there's an error or the task rejects, the observer should continue
307
- // (The implementation catches errors and continues observing)
308
- const updatesAfterFirst = updates.length;
309
-
310
- // Second seek triggers another frame task
311
- await timegroup.seek(1000);
312
- await new Promise((resolve) => setTimeout(resolve, 50));
313
- assert.ok(updates.length > updatesAfterFirst, "Should continue updating");
314
-
315
- root.unmount();
316
- container.remove();
317
- }, 5000);
318
-
319
- test("stops observing when component unmounts", async () => {
320
- const container = document.createElement("div");
321
- document.body.appendChild(container);
322
-
323
- const updates: number[] = [];
324
-
325
- const root = createRoot(container);
326
- root.render(
327
- <Configuration>
328
- <TimingDisplay
329
- onUpdate={(info) => {
330
- updates.push(info.ownCurrentTimeMs);
331
- }}
332
- />
333
- </Configuration>,
334
- );
335
-
336
- await new Promise((resolve) => setTimeout(resolve, 100));
337
-
338
- const preview = container.querySelector("ef-preview");
339
- const timegroup = preview?.querySelector("ef-timegroup") as EFTimegroup;
340
-
341
- assert.ok(timegroup, "Timegroup should be rendered");
342
- await timegroup.updateComplete;
343
- await timegroup.waitForMediaDurations();
344
-
345
- updates.length = 0;
346
-
347
- // Seek before unmount (triggers frame task)
348
- await timegroup.seek(500);
349
- await new Promise((resolve) => setTimeout(resolve, 50));
350
- assert.ok(updates.length >= 1, "Should update before unmount");
351
-
352
- // Unmount the component
353
- root.unmount();
354
- await new Promise((resolve) => setTimeout(resolve, 50));
355
-
356
- // Seek after unmount should not cause updates
357
- const updatesBeforePost = updates.length;
358
- await timegroup.seek(1000);
359
-
360
- // Give time for any potential updates
361
- await new Promise((resolve) => setTimeout(resolve, 50));
362
-
363
- assert.equal(
364
- updates.length,
365
- updatesBeforePost,
366
- "Should not update after unmount",
367
- );
368
-
369
- container.remove();
370
- }, 5000);
371
- });
@@ -1,107 +0,0 @@
1
- import type { EFTimegroup } from "@editframe/elements";
2
- import type { Task } from "@lit/task";
3
- import type { ReactiveController, ReactiveControllerHost } from "lit";
4
- import { useEffect, useRef, useState } from "react";
5
-
6
- interface TimeInfo {
7
- ownCurrentTimeMs: number;
8
- durationMs: number;
9
- percentComplete: number;
10
- }
11
-
12
- class CurrentTimeController implements ReactiveController {
13
- #lastTaskPromise: Promise<unknown> | null = null;
14
- #isConnected = false;
15
-
16
- constructor(
17
- private host: {
18
- ownCurrentTimeMs: number;
19
- durationMs: number;
20
- frameTask: Task<readonly unknown[], unknown>;
21
- } & ReactiveControllerHost,
22
- private setCurrentTime: React.Dispatch<React.SetStateAction<TimeInfo>>,
23
- ) {
24
- this.host.addController(this);
25
- }
26
-
27
- hostConnected(): void {
28
- this.#isConnected = true;
29
- }
30
-
31
- hostDisconnected(): void {
32
- this.#isConnected = false;
33
- this.#lastTaskPromise = null;
34
- this.host.removeController(this);
35
- }
36
-
37
- hostUpdated(): void {
38
- const currentTaskPromise = this.host.frameTask.taskComplete;
39
-
40
- // Detect if a new frame task has started (promise reference changed)
41
- if (currentTaskPromise !== this.#lastTaskPromise) {
42
- this.#lastTaskPromise = currentTaskPromise;
43
-
44
- // Wait for this specific task to complete, then update React
45
- // This is async so it doesn't block the update cycle
46
- currentTaskPromise
47
- .then(() => {
48
- // Only update if still connected
49
- if (this.#isConnected) {
50
- this.#updateReactState();
51
- }
52
- })
53
- .catch(() => {
54
- // Ignore task errors - we'll continue observing
55
- });
56
- }
57
- }
58
-
59
- #updateReactState(): void {
60
- // Always update to ensure React has the latest state
61
- this.setCurrentTime({
62
- ownCurrentTimeMs: this.host.ownCurrentTimeMs,
63
- durationMs: this.host.durationMs,
64
- percentComplete: this.host.ownCurrentTimeMs / this.host.durationMs,
65
- });
66
- }
67
-
68
- // Public method to manually trigger sync (for initialization)
69
- syncNow(): void {
70
- this.#updateReactState();
71
- }
72
- }
73
-
74
- export const useTimingInfo = (
75
- timegroupRef: React.RefObject<EFTimegroup> = useRef<EFTimegroup>(null),
76
- ) => {
77
- const [timeInfo, setTimeInfo] = useState<TimeInfo>({
78
- ownCurrentTimeMs: 0,
79
- durationMs: 0,
80
- percentComplete: 0,
81
- });
82
-
83
- useEffect(() => {
84
- if (!timegroupRef.current) {
85
- throw new Error("Timegroup ref not set");
86
- }
87
-
88
- const controller = new CurrentTimeController(
89
- timegroupRef.current,
90
- setTimeInfo,
91
- );
92
-
93
- // Trigger initial update if the timegroup is already connected
94
- if (timegroupRef.current.isConnected) {
95
- controller.hostConnected();
96
- // Sync initial state immediately
97
- controller.syncNow();
98
- }
99
-
100
- // Cleanup function
101
- return () => {
102
- controller.hostDisconnected();
103
- };
104
- }, [timegroupRef.current]);
105
-
106
- return { ...timeInfo, ref: timegroupRef };
107
- };