@hyperframes/studio 0.8.102 → 0.8.103
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/assets/{hyperframes-player-BuRiFtrF.js → hyperframes-player-DG795Hwu.js} +1 -1
- package/dist/assets/{index-C2utDZt-.js → index-BPJRf2X9.js} +118 -118
- package/dist/assets/{index-BT3sauee.js → index-BiEtDkae.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-BM35fjFf.js → index-DuCZ3pQw.js} +1 -1
- package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
- package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
- package/dist/chunk-DI4LCJHL.js.map +1 -0
- package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
- package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.html +2 -2
- package/dist/index.js +122 -84
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +2 -2
- package/src/components/editor/domEditOverlayGestures.ts +1 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/manualOffsetDrag.test.ts +16 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -5
- package/src/components/editor/plainTranslate.test.ts +38 -22
- package/src/components/editor/plainTranslate.ts +66 -36
- package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
- package/src/components/editor/previewReadOnlyContext.tsx +3 -1
- package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
- package/src/components/editor/useColorGradingController.test.ts +36 -1
- package/src/components/editor/useColorGradingController.ts +2 -2
- package/src/components/editor/useDomEditNudge.test.tsx +1 -0
- package/src/components/editor/useDomEditNudge.ts +6 -3
- package/src/hooks/usePreviewInteraction.test.ts +32 -0
- package/src/hooks/usePreviewInteraction.ts +3 -2
- package/src/player/components/TimelineClipFades.test.tsx +103 -3
- package/src/player/components/TimelineClipFades.tsx +53 -50
- package/src/styles/theme.css +1 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +3 -1
- package/dist/chunk-S65AQH6M.js.map +0 -1
- /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
- /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
- /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
|
@@ -267,3 +267,35 @@ describe("usePreviewInteraction", () => {
|
|
|
267
267
|
cleanup();
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("a refused move's toast", () => {
|
|
272
|
+
it("gives the refusal's own reason first, else the selection's", () => {
|
|
273
|
+
const showToast = vi.fn();
|
|
274
|
+
let blocked!: (selection: DomEditSelection, reason?: string) => void;
|
|
275
|
+
function Harness() {
|
|
276
|
+
blocked = usePreviewInteraction({
|
|
277
|
+
captionEditMode: false,
|
|
278
|
+
compositionLoading: false,
|
|
279
|
+
previewIframeRef: { current: null },
|
|
280
|
+
showToast,
|
|
281
|
+
applyDomSelection: vi.fn(),
|
|
282
|
+
resolveDomSelectionFromPreviewPoint: vi.fn(async () => null),
|
|
283
|
+
resolveAllDomSelectionsFromPreviewPoint: vi.fn(async () => []),
|
|
284
|
+
updateDomEditHoverSelection: vi.fn(),
|
|
285
|
+
setActiveGroupElement: vi.fn(),
|
|
286
|
+
}).handleBlockedDomMove;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const root = createRoot(document.createElement("div"));
|
|
290
|
+
act(() => root.render(React.createElement(Harness)));
|
|
291
|
+
const selection = makeSelection("Box", document.createElement("div"));
|
|
292
|
+
selection.capabilities.reasonIfDisabled = "Locked layer.";
|
|
293
|
+
blocked(selection, "Studio can't read it.");
|
|
294
|
+
blocked(selection);
|
|
295
|
+
act(() => root.unmount());
|
|
296
|
+
expect(showToast.mock.calls).toEqual([
|
|
297
|
+
["Studio can't read it.", "info"],
|
|
298
|
+
["Locked layer.", "info"],
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -260,9 +260,10 @@ export function usePreviewInteraction({
|
|
|
260
260
|
}, [updateDomEditHoverSelection]);
|
|
261
261
|
|
|
262
262
|
const handleBlockedDomMove = useCallback(
|
|
263
|
-
(selection: DomEditSelection) => {
|
|
263
|
+
(selection: DomEditSelection, reason?: string) => {
|
|
264
264
|
showToast(
|
|
265
|
-
|
|
265
|
+
reason ??
|
|
266
|
+
selection.capabilities.reasonIfDisabled ??
|
|
266
267
|
"This element can't be adjusted directly from the preview.",
|
|
267
268
|
"info",
|
|
268
269
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { TimelineClipFades } from "./TimelineClipFades";
|
|
@@ -9,7 +9,10 @@ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
|
9
9
|
|
|
10
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
11
|
|
|
12
|
+
const mounted: Root[] = [];
|
|
13
|
+
|
|
12
14
|
afterEach(() => {
|
|
15
|
+
act(() => mounted.splice(0).forEach((root) => root.unmount()));
|
|
13
16
|
document.body.innerHTML = "";
|
|
14
17
|
});
|
|
15
18
|
|
|
@@ -31,6 +34,7 @@ function render(
|
|
|
31
34
|
const host = document.createElement("div");
|
|
32
35
|
document.body.append(host);
|
|
33
36
|
const root = createRoot(host);
|
|
37
|
+
mounted.push(root);
|
|
34
38
|
const onSetElementAttributeLive = vi.fn();
|
|
35
39
|
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
|
36
40
|
const onRevertElementAttributeLive = vi.fn();
|
|
@@ -97,6 +101,14 @@ describe("TimelineClipFades", () => {
|
|
|
97
101
|
act(() => root.unmount());
|
|
98
102
|
});
|
|
99
103
|
|
|
104
|
+
it("clips the ramps to the clip's rounded corners", () => {
|
|
105
|
+
const { host, root } = render(clip, { showHandles: false, provide: false });
|
|
106
|
+
const ramps = host.querySelector<SVGElement>('[data-testid="clip-fade-ramps"]');
|
|
107
|
+
expect(ramps?.style.overflow).toBe("hidden");
|
|
108
|
+
expect(ramps?.style.borderRadius).toBe("inherit");
|
|
109
|
+
act(() => root.unmount());
|
|
110
|
+
});
|
|
111
|
+
|
|
100
112
|
it("renders nothing for a clip without fades when the handles are hidden", () => {
|
|
101
113
|
const { host, root } = render(
|
|
102
114
|
{ ...clip, fadeIn: undefined, fadeOut: undefined },
|
|
@@ -113,7 +125,7 @@ describe("TimelineClipFades", () => {
|
|
|
113
125
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
114
126
|
if (!handle) throw new Error("expected a fade-in handle");
|
|
115
127
|
armCapture(handle);
|
|
116
|
-
//
|
|
128
|
+
// 150 px of rightward travel adds 1.5 s to the 1 s fade-in, wherever the press lands.
|
|
117
129
|
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
118
130
|
act(() => handle.dispatchEvent(pointer("pointermove", 200)));
|
|
119
131
|
act(() => handle.dispatchEvent(pointer("pointermove", 250)));
|
|
@@ -134,7 +146,7 @@ describe("TimelineClipFades", () => {
|
|
|
134
146
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
135
147
|
if (!handle) throw new Error("expected a fade-out handle");
|
|
136
148
|
armCapture(handle);
|
|
137
|
-
//
|
|
149
|
+
// 300 px of leftward travel adds 3 s to the 2 s fade-out, wherever the press lands.
|
|
138
150
|
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
|
|
139
151
|
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
|
|
140
152
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
@@ -179,6 +191,30 @@ describe("TimelineClipFades", () => {
|
|
|
179
191
|
act(() => root.unmount());
|
|
180
192
|
});
|
|
181
193
|
|
|
194
|
+
it("cancels a drag on Escape pressed while focus is elsewhere, and saves nothing", () => {
|
|
195
|
+
const { host, onSetElementAttributeQuiet, onRevertElementAttributeLive } = render(clip);
|
|
196
|
+
const elsewhere = document.createElement("button");
|
|
197
|
+
document.body.append(elsewhere);
|
|
198
|
+
elsewhere.focus();
|
|
199
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
200
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
201
|
+
armCapture(handle);
|
|
202
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
203
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
204
|
+
expect(document.activeElement).toBe(elsewhere);
|
|
205
|
+
act(() => {
|
|
206
|
+
document.activeElement?.dispatchEvent(
|
|
207
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 300)));
|
|
211
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
212
|
+
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
|
213
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
214
|
+
"0,0 100,0 0,100",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
182
218
|
it.each([
|
|
183
219
|
["above the window", 300, -40],
|
|
184
220
|
["on the window's right edge", window.innerWidth, 10],
|
|
@@ -200,6 +236,70 @@ describe("TimelineClipFades", () => {
|
|
|
200
236
|
act(() => root.unmount());
|
|
201
237
|
});
|
|
202
238
|
|
|
239
|
+
it.each([
|
|
240
|
+
["in", "Fade in 0.25 s"],
|
|
241
|
+
["out", "Fade out 0.4 s"],
|
|
242
|
+
])(
|
|
243
|
+
"gives the fade-%s handle a 24 px target and a tooltip naming its length",
|
|
244
|
+
async (edge, text) => {
|
|
245
|
+
const { host, root } = render({ ...clip, fadeIn: 0.25, fadeOut: 0.4 });
|
|
246
|
+
const handle = host.querySelector<HTMLElement>(`[data-testid="clip-fade-handle-${edge}"]`);
|
|
247
|
+
if (!handle) throw new Error(`expected a fade-${edge} handle`);
|
|
248
|
+
expect(parseFloat(handle.style.width)).toBeGreaterThanOrEqual(24);
|
|
249
|
+
expect(parseFloat(handle.style.height)).toBeGreaterThanOrEqual(24);
|
|
250
|
+
expect(handle.getAttribute("title")).toBe("");
|
|
251
|
+
act(() => handle.focus());
|
|
252
|
+
await act(async () => {
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
254
|
+
});
|
|
255
|
+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(text);
|
|
256
|
+
act(() => root.unmount());
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
it.each([0.3, 3])("pins the handles to the top corners for a %s s fade-in", (fadeIn) => {
|
|
261
|
+
const { host } = render({ ...clip, fadeIn, fadeOut: 0.4 });
|
|
262
|
+
const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
263
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
264
|
+
expect(inHandle?.style.left).toBe("0px");
|
|
265
|
+
expect(fadeOut?.style.left).toBe(`${1000 - 24}px`);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("lengthens a fade by the pointer's inward travel from wherever the press lands", () => {
|
|
269
|
+
const { host, onSetElementAttributeLive } = render(clip);
|
|
270
|
+
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
271
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
272
|
+
if (!fadeIn || !fadeOut) throw new Error("expected both handles");
|
|
273
|
+
armCapture(fadeIn);
|
|
274
|
+
armCapture(fadeOut);
|
|
275
|
+
act(() => fadeIn.dispatchEvent(pointer("pointerdown", 12)));
|
|
276
|
+
act(() => fadeIn.dispatchEvent(pointer("pointermove", 162)));
|
|
277
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
|
|
278
|
+
act(() => fadeIn.dispatchEvent(pointer("pointerup", 162)));
|
|
279
|
+
act(() => fadeOut.dispatchEvent(pointer("pointerdown", 988, 2)));
|
|
280
|
+
act(() => fadeOut.dispatchEvent(pointer("pointermove", 888, 2)));
|
|
281
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "3");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("paints the handles above the clip's waveform and thumbnail layers", () => {
|
|
285
|
+
const { host } = render(clip);
|
|
286
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
287
|
+
expect(Number(handle?.style.zIndex)).toBeGreaterThan(10);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("splits a clip narrower than two targets between the handles", () => {
|
|
291
|
+
const { host } = render({ ...clip, duration: 0.3, fadeIn: 0.1, fadeOut: 0.1 });
|
|
292
|
+
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
293
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
294
|
+
expect([fadeIn?.style.left, fadeIn?.style.width]).toEqual(["0px", "15px"]);
|
|
295
|
+
expect([fadeOut?.style.left, fadeOut?.style.width]).toEqual(["15px", "15px"]);
|
|
296
|
+
const dot = fadeIn?.firstElementChild as HTMLElement;
|
|
297
|
+
expect(getComputedStyle(dot).width).toBe("10px");
|
|
298
|
+
// Layout is not real here; tests/e2e/fade-handles.mjs measures the box in Chrome.
|
|
299
|
+
expect(getComputedStyle(dot).flexShrink).toBe("0");
|
|
300
|
+
expect(getComputedStyle(dot).pointerEvents).toBe("none");
|
|
301
|
+
});
|
|
302
|
+
|
|
203
303
|
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
204
304
|
const outer = vi.fn();
|
|
205
305
|
const { host, root } = render(clip, { onClipPointerDown: outer });
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useEffect,
|
|
3
|
-
useRef,
|
|
4
|
-
useState,
|
|
5
|
-
type CSSProperties,
|
|
6
|
-
type KeyboardEvent,
|
|
7
|
-
type PointerEvent,
|
|
8
|
-
} from "react";
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
|
9
2
|
import {
|
|
10
3
|
HF_AUDIO_FADE_IN_ATTR,
|
|
11
4
|
HF_AUDIO_FADE_OUT_ATTR,
|
|
@@ -13,13 +6,16 @@ import {
|
|
|
13
6
|
formatFadeSeconds,
|
|
14
7
|
} from "@hyperframes/core/audio-fade";
|
|
15
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
9
|
+
import { Tooltip } from "../../components/ui";
|
|
16
10
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
11
|
import { releasedOutsideWindow } from "./timelinePointerRelease";
|
|
18
12
|
|
|
19
13
|
type FadeEdge = "in" | "out";
|
|
20
14
|
|
|
21
15
|
const HANDLE_SIZE = 10;
|
|
22
|
-
const HANDLE_HIT =
|
|
16
|
+
const HANDLE_HIT = 24;
|
|
17
|
+
const HANDLE_Z_ABOVE_CLIP_CONTENT = 30;
|
|
18
|
+
const SUPPRESS_CLIP_NATIVE_TITLE = "";
|
|
23
19
|
/** Pixels of pointer travel before a press on the handle counts as a drag. */
|
|
24
20
|
const DRAG_THRESHOLD_PX = 2;
|
|
25
21
|
|
|
@@ -147,31 +143,38 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
147
143
|
);
|
|
148
144
|
};
|
|
149
145
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (dragging === null) return;
|
|
148
|
+
const cancelOnWindowEscape = (e: KeyboardEvent) => {
|
|
149
|
+
const g = gesture.current;
|
|
150
|
+
if (e.key !== "Escape" || !g) return;
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
gesture.current = null;
|
|
154
|
+
setDragging(null);
|
|
155
|
+
revertGesture(g);
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener("keydown", cancelOnWindowEscape, { capture: true });
|
|
158
|
+
return () => window.removeEventListener("keydown", cancelOnWindowEscape, { capture: true });
|
|
159
|
+
});
|
|
158
160
|
|
|
159
161
|
const showIn = fades.fadeIn > 0;
|
|
160
162
|
const showOut = fades.fadeOut > 0;
|
|
161
163
|
const handlesVisible = showHandles || dragging !== null;
|
|
162
164
|
if (!showIn && !showOut && !handlesVisible) return null;
|
|
163
165
|
|
|
164
|
-
const
|
|
166
|
+
const hitWidth = Math.min(HANDLE_HIT, widthPx / 2);
|
|
167
|
+
const handleStyle = (edge: FadeEdge): CSSProperties => ({
|
|
165
168
|
position: "absolute",
|
|
166
169
|
top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
|
|
167
|
-
left:
|
|
168
|
-
width:
|
|
170
|
+
left: edge === "in" ? 0 : widthPx - hitWidth,
|
|
171
|
+
width: hitWidth,
|
|
169
172
|
height: HANDLE_HIT,
|
|
170
173
|
display: "flex",
|
|
171
174
|
alignItems: "center",
|
|
172
175
|
justifyContent: "center",
|
|
173
176
|
cursor: "ew-resize",
|
|
174
|
-
zIndex:
|
|
177
|
+
zIndex: HANDLE_Z_ABOVE_CLIP_CONTENT,
|
|
175
178
|
opacity: handlesVisible ? 1 : 0,
|
|
176
179
|
pointerEvents: handlesVisible && canEdit ? "auto" : "none",
|
|
177
180
|
touchAction: "none",
|
|
@@ -191,7 +194,8 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
191
194
|
height: "100%",
|
|
192
195
|
pointerEvents: "none",
|
|
193
196
|
zIndex: 5,
|
|
194
|
-
overflow: "
|
|
197
|
+
overflow: "hidden",
|
|
198
|
+
borderRadius: "inherit",
|
|
195
199
|
}}
|
|
196
200
|
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
|
|
197
201
|
preserveAspectRatio="none"
|
|
@@ -245,25 +249,23 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
245
249
|
direction="in"
|
|
246
250
|
value={fades.fadeIn}
|
|
247
251
|
max={Math.max(0, el.duration - fades.fadeOut)}
|
|
248
|
-
style={handleStyle(
|
|
252
|
+
style={handleStyle("in")}
|
|
249
253
|
dragging={dragging === "in"}
|
|
250
254
|
onPointerDown={onHandlePointerDown("in")}
|
|
251
255
|
onPointerMove={onHandlePointerMove}
|
|
252
256
|
onPointerUp={(e) => finish(e, false)}
|
|
253
257
|
onPointerCancel={(e) => finish(e, true)}
|
|
254
|
-
onKeyDown={onHandleKeyDown}
|
|
255
258
|
/>
|
|
256
259
|
<FadeHandle
|
|
257
260
|
direction="out"
|
|
258
261
|
value={fades.fadeOut}
|
|
259
262
|
max={Math.max(0, el.duration - fades.fadeIn)}
|
|
260
|
-
style={handleStyle(
|
|
263
|
+
style={handleStyle("out")}
|
|
261
264
|
dragging={dragging === "out"}
|
|
262
265
|
onPointerDown={onHandlePointerDown("out")}
|
|
263
266
|
onPointerMove={onHandlePointerMove}
|
|
264
267
|
onPointerUp={(e) => finish(e, false)}
|
|
265
268
|
onPointerCancel={(e) => finish(e, true)}
|
|
266
|
-
onKeyDown={onHandleKeyDown}
|
|
267
269
|
/>
|
|
268
270
|
</>
|
|
269
271
|
)}
|
|
@@ -281,7 +283,6 @@ function FadeHandle({
|
|
|
281
283
|
onPointerMove,
|
|
282
284
|
onPointerUp,
|
|
283
285
|
onPointerCancel,
|
|
284
|
-
onKeyDown,
|
|
285
286
|
}: {
|
|
286
287
|
direction: "in" | "out";
|
|
287
288
|
value: number;
|
|
@@ -292,31 +293,31 @@ function FadeHandle({
|
|
|
292
293
|
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
293
294
|
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
294
295
|
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
|
|
295
|
-
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
296
296
|
}) {
|
|
297
297
|
const label = direction === "in" ? "Fade in" : "Fade out";
|
|
298
298
|
return (
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
299
|
+
<Tooltip label={`${label} ${formatFadeSeconds(value)} s`}>
|
|
300
|
+
<div
|
|
301
|
+
role="slider"
|
|
302
|
+
tabIndex={-1}
|
|
303
|
+
aria-label={label}
|
|
304
|
+
aria-valuemin={0}
|
|
305
|
+
aria-valuemax={max}
|
|
306
|
+
aria-valuenow={value}
|
|
307
|
+
aria-valuetext={`${formatFadeSeconds(value)}s`}
|
|
308
|
+
data-testid={`clip-fade-handle-${direction}`}
|
|
309
|
+
title={SUPPRESS_CLIP_NATIVE_TITLE}
|
|
310
|
+
style={style}
|
|
311
|
+
onPointerDown={onPointerDown}
|
|
312
|
+
onPointerMove={onPointerMove}
|
|
313
|
+
onPointerUp={onPointerUp}
|
|
314
|
+
onPointerCancel={onPointerCancel}
|
|
315
|
+
onClick={(e) => e.stopPropagation()}
|
|
316
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
317
|
+
>
|
|
318
|
+
<FadeDot active={dragging} />
|
|
319
|
+
</div>
|
|
320
|
+
</Tooltip>
|
|
320
321
|
);
|
|
321
322
|
}
|
|
322
323
|
|
|
@@ -326,10 +327,12 @@ function FadeDot({ active }: { active: boolean }) {
|
|
|
326
327
|
aria-hidden="true"
|
|
327
328
|
style={{
|
|
328
329
|
display: "block",
|
|
330
|
+
flexShrink: 0,
|
|
331
|
+
pointerEvents: "none",
|
|
329
332
|
width: HANDLE_SIZE,
|
|
330
333
|
height: HANDLE_SIZE,
|
|
331
334
|
borderRadius: "50%",
|
|
332
|
-
background:
|
|
335
|
+
background: "var(--timeline-fade-dot)",
|
|
333
336
|
boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
|
|
334
337
|
transform: active ? "scale(1.15)" : undefined,
|
|
335
338
|
transition: "transform 80ms ease-out",
|
package/src/styles/theme.css
CHANGED
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
--timeline-handle: rgba(255, 255, 255, 0.55);
|
|
115
115
|
/* Audio clip fades: the shaded wedge over the faded gain, and the corner dots. */
|
|
116
116
|
--timeline-fade-shade: rgba(0, 0, 0, 0.38);
|
|
117
|
-
--timeline-fade-dot
|
|
117
|
+
--timeline-fade-dot: #fff;
|
|
118
118
|
--timeline-fade-dot-ring: rgba(0, 0, 0, 0.55);
|
|
119
119
|
--timeline-resize-seam: rgba(255, 255, 255, 0.12);
|
|
120
120
|
--timeline-resize-active: rgba(255, 255, 255, 0.24);
|
|
@@ -18,6 +18,15 @@ describe("project routing utilities", () => {
|
|
|
18
18
|
},
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
+
it("opens a folder whose name has a colon, encoded once both ways", () => {
|
|
22
|
+
const id = "Customer story: Northwind";
|
|
23
|
+
expect(encodeProjectId(id)).toBe("Customer%20story%3A%20Northwind");
|
|
24
|
+
expect(buildProjectApiPath(id, "/preview")).toBe(
|
|
25
|
+
"/api/projects/Customer%20story%3A%20Northwind/preview",
|
|
26
|
+
);
|
|
27
|
+
expect(parseProjectIdFromHash(buildProjectHash(id))).toBe(id);
|
|
28
|
+
});
|
|
29
|
+
|
|
21
30
|
it("decodes project ids from hash routes before building capture URLs", () => {
|
|
22
31
|
vi.useFakeTimers();
|
|
23
32
|
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
|
|
@@ -5,13 +5,15 @@ export interface ProjectHashRoute {
|
|
|
5
5
|
params: URLSearchParams;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
const WINDOWS_DRIVE_PREFIX = /^[a-z]:/i;
|
|
9
|
+
|
|
8
10
|
/** Project names are single path segments, including when received from a hash or server. */
|
|
9
11
|
export function isValidProjectId(value: string): boolean {
|
|
10
12
|
return (
|
|
11
13
|
value.length > 0 &&
|
|
12
14
|
value !== "." &&
|
|
13
15
|
value !== ".." &&
|
|
14
|
-
!
|
|
16
|
+
!WINDOWS_DRIVE_PREFIX.test(value) &&
|
|
15
17
|
!value.includes("/") &&
|
|
16
18
|
!value.includes("\\") &&
|
|
17
19
|
!Array.from(value).some((char) => char.charCodeAt(0) < 32)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/utils/sourceScopedSelectorIndex.ts","../src/utils/projectRouting.ts"],"sourcesContent":["/**\n * Occurrence index for a selector within its source document.\n *\n * The preview flattens multiple composition files into one DOM, so a raw\n * `querySelectorAll` index is not a stable source-file identity. Callers supply\n * their existing source resolver; this helper alone owns occurrence scoping.\n */\n\n/** Every element matching one selector, mapped to the source file it belongs to\n * and its occurrence index within that file. One document query fills it. */\ntype SelectorOccurrences = Map<Element, { scope: string; index: number }>;\n\ninterface SelectorIndexPass {\n doc: Document;\n bySelector: Map<string, SelectorOccurrences>;\n}\n\nlet activePass: SelectorIndexPass | null = null;\n\n/**\n * Share one occurrence index per selector across every\n * `getSourceScopedSelectorIndex` call made synchronously inside `run`.\n *\n * Unshared, each call runs its own whole-document `querySelectorAll(selector)`\n * and resolves the source file of every match. A rebuild that walks n elements\n * sharing a selector therefore does n whole-document queries and n² resolver\n * calls — a composition of repeated cards or tiles is exactly that shape. One\n * index per selector makes the same pass linear.\n *\n * Correctness rests on the pass being ONE synchronous walk of a document that\n * does not change under it: nothing invalidates the index, so `run` must not\n * mutate `doc`, and every call inside it must resolve source files the same way\n * (the resolver may be a fresh closure per call, as long as it answers the\n * same). Outside a pass the index is per-call, exactly as before.\n */\nexport function withSelectorIndexPass<T>(doc: Document, run: () => T): T {\n const previous = activePass;\n activePass = { doc, bySelector: new Map() };\n try {\n return run();\n } finally {\n activePass = previous;\n }\n}\n\nfunction buildOccurrences(\n doc: Document,\n selector: string,\n resolveSourceFile: (candidate: Element) => string | undefined,\n): SelectorOccurrences {\n const occurrences: SelectorOccurrences = new Map();\n const counts = new Map<string, number>();\n for (const candidate of doc.querySelectorAll(selector)) {\n const scope = resolveSourceFile(candidate) ?? \"index.html\";\n const index = counts.get(scope) ?? 0;\n counts.set(scope, index + 1);\n occurrences.set(candidate, { scope, index });\n }\n return occurrences;\n}\n\nexport function getSourceScopedSelectorIndex(\n doc: Document,\n el: Element,\n selector: string | undefined,\n sourceFile: string | undefined,\n resolveSourceFile: (candidate: Element) => string | undefined,\n): number | undefined {\n if (!selector || selector.startsWith(\"#\") || selector.startsWith(\"[data-composition-id=\")) {\n return undefined;\n }\n\n try {\n const pass = activePass?.doc === doc ? activePass : null;\n let occurrences = pass?.bySelector.get(selector);\n if (!occurrences) {\n occurrences = buildOccurrences(doc, selector, resolveSourceFile);\n pass?.bySelector.set(selector, occurrences);\n }\n // An element outside the requested scope is not in that scope's occurrence\n // run at all, which is the `indexOf` miss the filtered form returned before.\n const hit = occurrences.get(el);\n return hit && hit.scope === (sourceFile ?? \"index.html\") ? hit.index : undefined;\n } catch {\n return undefined;\n }\n}\n","const PROJECT_HASH_PREFIX = \"#project/\";\n\nexport interface ProjectHashRoute {\n projectId: string;\n params: URLSearchParams;\n}\n\n/** Project names are single path segments, including when received from a hash or server. */\nexport function isValidProjectId(value: string): boolean {\n return (\n value.length > 0 &&\n value !== \".\" &&\n value !== \"..\" &&\n !value.includes(\":\") &&\n !value.includes(\"/\") &&\n !value.includes(\"\\\\\") &&\n !Array.from(value).some((char) => char.charCodeAt(0) < 32)\n );\n}\n\nfunction decodeHashProjectId(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\nfunction normalizeHashParams(\n params?: URLSearchParams | Record<string, string | null | undefined>,\n): URLSearchParams {\n if (!params) return new URLSearchParams();\n if (params instanceof URLSearchParams) return params;\n\n const next = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n if (!key || value == null || value === \"\") continue;\n next.set(key, value);\n }\n return next;\n}\n\nexport function encodeProjectId(projectId: string): string {\n if (!isValidProjectId(projectId)) throw new Error(\"Invalid project ID\");\n return encodeURIComponent(projectId);\n}\n\nexport function buildProjectHash(\n projectId: string,\n params?: URLSearchParams | Record<string, string | null | undefined>,\n): string {\n const search = normalizeHashParams(params).toString();\n return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : \"\"}`;\n}\n\nexport function parseProjectHashRoute(hash: string): ProjectHashRoute | null {\n if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;\n\n const route = hash.slice(PROJECT_HASH_PREFIX.length);\n const queryIndex = route.indexOf(\"?\");\n const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;\n if (!encodedProjectId || encodedProjectId.includes(\"/\")) return null;\n\n const projectId = decodeHashProjectId(encodedProjectId);\n if (!isValidProjectId(projectId)) return null;\n\n const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : \"\";\n return {\n projectId,\n params: new URLSearchParams(rawParams),\n };\n}\n\nexport function parseProjectIdFromHash(hash: string): string | null {\n return parseProjectHashRoute(hash)?.projectId ?? null;\n}\n\nexport function buildProjectApiPath(projectId: string, suffix = \"\"): string {\n const normalizedSuffix = suffix && !suffix.startsWith(\"/\") ? `/${suffix}` : suffix;\n return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;\n}\n"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|