@hyperframes/studio 0.8.139 → 0.8.140
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-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
- package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.html +1 -1
- package/dist/index.js +6498 -5813
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.140",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.140",
|
|
64
|
+
"@hyperframes/parsers": "0.8.140",
|
|
65
|
+
"@hyperframes/sdk": "0.8.140",
|
|
66
|
+
"@hyperframes/player": "0.8.140",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.140",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.140"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"vite": "^6.4.2",
|
|
83
83
|
"vitest": "^4.1.11",
|
|
84
84
|
"zustand": "^5.0.0",
|
|
85
|
-
"@hyperframes/producer": "0.8.
|
|
85
|
+
"@hyperframes/producer": "0.8.140"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
88
|
"react": "19",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
|
@@ -11,6 +11,7 @@ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
|
11
11
|
import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
|
|
12
12
|
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
|
|
13
13
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
14
|
+
import { registerTimelineZoomViewport } from "../player/components/timelineZoomInput";
|
|
14
15
|
|
|
15
16
|
vi.mock("../contexts/StudioContext", () => ({
|
|
16
17
|
useStudioShellContextOptional: () => ({
|
|
@@ -239,6 +240,81 @@ describe("TimelineToolbar snap key", () => {
|
|
|
239
240
|
});
|
|
240
241
|
|
|
241
242
|
describe("TimelineToolbar Fit", () => {
|
|
243
|
+
// A zoom lands on the next frame; these tests step frames instead of waiting for them.
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
vi.useFakeTimers({
|
|
246
|
+
toFake: [
|
|
247
|
+
"requestAnimationFrame",
|
|
248
|
+
"cancelAnimationFrame",
|
|
249
|
+
"setTimeout",
|
|
250
|
+
"clearTimeout",
|
|
251
|
+
"performance",
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
afterEach(() => {
|
|
256
|
+
vi.useRealTimers();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/** A mounted timeline for the zoom to preview on: 1080px wide, 32px of headers. */
|
|
260
|
+
function mountTimelineViewport() {
|
|
261
|
+
const scroll = document.createElement("div");
|
|
262
|
+
Object.defineProperties(scroll, {
|
|
263
|
+
clientWidth: { value: 1080 },
|
|
264
|
+
// At Fit the content is as wide as the view.
|
|
265
|
+
scrollWidth: { value: 1080 },
|
|
266
|
+
scrollLeft: { value: 0, writable: true },
|
|
267
|
+
});
|
|
268
|
+
usePlayerStore.setState({
|
|
269
|
+
zoomMode: "fit",
|
|
270
|
+
manualZoomPercent: 100,
|
|
271
|
+
timelineFitPps: 10,
|
|
272
|
+
timelinePps: 10,
|
|
273
|
+
});
|
|
274
|
+
return registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
it("moves the slider and its readout with a zoom while it is previewed", () => {
|
|
278
|
+
const unregister = mountTimelineViewport();
|
|
279
|
+
const { host, root } = renderToolbar();
|
|
280
|
+
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]')!;
|
|
281
|
+
const before = slider.value;
|
|
282
|
+
act(() => {
|
|
283
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
|
|
284
|
+
slider,
|
|
285
|
+
String(Number(before) + 5),
|
|
286
|
+
);
|
|
287
|
+
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
|
288
|
+
});
|
|
289
|
+
act(() => vi.advanceTimersToNextFrame());
|
|
290
|
+
// Not laid out yet: the store still holds Fit, but the toolbar shows the zoom on screen.
|
|
291
|
+
expect(usePlayerStore.getState().zoomMode).toBe("fit");
|
|
292
|
+
expect(slider.value).not.toBe(before);
|
|
293
|
+
expect(host.querySelector('[aria-label="Timeline zoom level"]')?.textContent).not.toBe("Fit");
|
|
294
|
+
act(() => root.unmount());
|
|
295
|
+
act(() => unregister());
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("keeps Fit when it is picked while a zoom waits to be laid out", () => {
|
|
299
|
+
const unregister = mountTimelineViewport();
|
|
300
|
+
const { host, root } = renderToolbar();
|
|
301
|
+
act(() =>
|
|
302
|
+
host
|
|
303
|
+
.querySelector('button[aria-label="Zoom in"]')
|
|
304
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
act(() =>
|
|
308
|
+
host
|
|
309
|
+
.querySelector('button[aria-label="Fit timeline to width"]')
|
|
310
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
311
|
+
);
|
|
312
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
313
|
+
expect(usePlayerStore.getState().zoomMode).toBe("fit");
|
|
314
|
+
act(() => root.unmount());
|
|
315
|
+
act(() => unregister());
|
|
316
|
+
});
|
|
317
|
+
|
|
242
318
|
it("shows Fit as a named icon and says whether fit is on", () => {
|
|
243
319
|
const { host, root } = renderToolbar();
|
|
244
320
|
const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
|
|
@@ -251,6 +327,7 @@ describe("TimelineToolbar Fit", () => {
|
|
|
251
327
|
.querySelector('button[aria-label="Zoom in"]')
|
|
252
328
|
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
253
329
|
);
|
|
330
|
+
act(() => vi.advanceTimersToNextFrame());
|
|
254
331
|
expect(fit()?.getAttribute("aria-pressed")).toBe("false");
|
|
255
332
|
act(() => root.unmount());
|
|
256
333
|
});
|
|
@@ -263,6 +340,7 @@ describe("TimelineToolbar Fit", () => {
|
|
|
263
340
|
.querySelector('button[aria-label="Zoom in"]')
|
|
264
341
|
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
265
342
|
);
|
|
343
|
+
act(() => vi.advanceTimersToNextFrame());
|
|
266
344
|
expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
|
|
267
345
|
act(() => root.unmount());
|
|
268
346
|
});
|
|
@@ -14,12 +14,14 @@ import {
|
|
|
14
14
|
import { computeElementPercentage, playsNear } from "../hooks/gsapShared";
|
|
15
15
|
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
|
16
16
|
import {
|
|
17
|
-
getNextTimelineZoomPercent,
|
|
18
|
-
getTimelineZoomPercent,
|
|
19
17
|
timelineZoomPercentToSlider,
|
|
20
18
|
timelineSliderToZoomPercent,
|
|
21
19
|
} from "../player/components/timelineZoom";
|
|
22
20
|
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
|
21
|
+
import {
|
|
22
|
+
useShownTimelineZoomPercent,
|
|
23
|
+
zoomTimelineStep,
|
|
24
|
+
} from "../player/components/timelineZoomInput";
|
|
23
25
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
24
26
|
import { Tooltip } from "./ui";
|
|
25
27
|
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
|
|
@@ -170,17 +172,13 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
170
172
|
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
|
|
171
173
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
172
174
|
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
|
173
|
-
//
|
|
174
|
-
// the selection changes, not only on the next playhead tick.
|
|
175
|
+
// Subscribed, so the split button follows the selection, not only playhead ticks.
|
|
175
176
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
176
177
|
const elements = usePlayerStore((s) => s.elements);
|
|
177
178
|
const timelineFitPps = usePlayerStore((s) => s.timelineFitPps);
|
|
178
|
-
const { zoomMode,
|
|
179
|
-
const displayedTimelineZoomPercent =
|
|
180
|
-
|
|
181
|
-
manualZoomPercent,
|
|
182
|
-
timelineFitPps,
|
|
183
|
-
);
|
|
179
|
+
const { zoomMode, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
180
|
+
const displayedTimelineZoomPercent = useShownTimelineZoomPercent();
|
|
181
|
+
const showsFit = zoomMode === "fit" && displayedTimelineZoomPercent === 100;
|
|
184
182
|
const {
|
|
185
183
|
state: keyframeState,
|
|
186
184
|
isMotionPath: keyframeIsMotionPath,
|
|
@@ -215,8 +213,6 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
215
213
|
}, []);
|
|
216
214
|
|
|
217
215
|
return (
|
|
218
|
-
// The "TIMELINE" label is dropped for CapCut-like density — the pane's
|
|
219
|
-
// position (tracks right below) makes it self-evident.
|
|
220
216
|
<div className="border-b border-neutral-800/60">
|
|
221
217
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
222
218
|
<div className="flex items-center gap-0.5">
|
|
@@ -510,12 +506,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
510
506
|
<button
|
|
511
507
|
type="button"
|
|
512
508
|
aria-label="Zoom out"
|
|
513
|
-
onClick={() =>
|
|
514
|
-
setZoomMode("manual");
|
|
515
|
-
setManualZoomPercent(
|
|
516
|
-
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent, timelineFitPps),
|
|
517
|
-
);
|
|
518
|
-
}}
|
|
509
|
+
onClick={() => zoomTimelineStep("out")}
|
|
519
510
|
className={flatIdle}
|
|
520
511
|
>
|
|
521
512
|
<MagnifyingGlassMinus size={16} aria-hidden="true" />
|
|
@@ -528,12 +519,11 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
528
519
|
value={timelineZoomPercentToSlider(displayedTimelineZoomPercent, timelineFitPps)}
|
|
529
520
|
title={`${displayedTimelineZoomPercent}%`}
|
|
530
521
|
aria-label="Timeline zoom"
|
|
531
|
-
onChange={(e) =>
|
|
532
|
-
setZoomMode("manual");
|
|
522
|
+
onChange={(e) =>
|
|
533
523
|
setManualZoomPercent(
|
|
534
524
|
timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps),
|
|
535
|
-
)
|
|
536
|
-
}
|
|
525
|
+
)
|
|
526
|
+
}
|
|
537
527
|
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
|
|
538
528
|
// track stays 2px and the thumb 10px, only the pointer box grows.
|
|
539
529
|
className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-text-0 [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
|
|
@@ -542,12 +532,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
542
532
|
<button
|
|
543
533
|
type="button"
|
|
544
534
|
aria-label="Zoom in"
|
|
545
|
-
onClick={() =>
|
|
546
|
-
setZoomMode("manual");
|
|
547
|
-
setManualZoomPercent(
|
|
548
|
-
getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent, timelineFitPps),
|
|
549
|
-
);
|
|
550
|
-
}}
|
|
535
|
+
onClick={() => zoomTimelineStep("in")}
|
|
551
536
|
className={flatIdle}
|
|
552
537
|
>
|
|
553
538
|
<MagnifyingGlassPlus size={16} aria-hidden="true" />
|
|
@@ -558,7 +543,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
558
543
|
className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
|
|
559
544
|
aria-label="Timeline zoom level"
|
|
560
545
|
>
|
|
561
|
-
{
|
|
546
|
+
{showsFit ? "Fit" : `${displayedTimelineZoomPercent}%`}
|
|
562
547
|
</span>
|
|
563
548
|
</div>
|
|
564
549
|
</div>
|
|
@@ -61,7 +61,7 @@ export interface DomEditOverlayProps {
|
|
|
61
61
|
hoverSelection: DomEditSelection | null;
|
|
62
62
|
allowCanvasMovement?: boolean;
|
|
63
63
|
allowBodyDrag?: boolean;
|
|
64
|
-
/** "host": no
|
|
64
|
+
/** "host": no marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
|
|
65
65
|
canvasInput?: "overlay" | "host";
|
|
66
66
|
onTextEditingChange?: (editing: boolean) => void;
|
|
67
67
|
/** A click on a single selection's box, in either mode; the event may be the pointerup. */
|
|
@@ -500,7 +500,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
500
500
|
onLostPointerCapture={gestures.onLostPointerCapture}
|
|
501
501
|
onContextMenu={hostInput ? undefined : handleContextMenu}
|
|
502
502
|
>
|
|
503
|
-
{
|
|
503
|
+
{hoverSelection && hoverRect && compRect.width > 0 && (
|
|
504
504
|
<div
|
|
505
505
|
aria-hidden="true"
|
|
506
506
|
data-dom-edit-hover-box="true"
|
|
@@ -565,6 +565,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
565
565
|
styles={styles}
|
|
566
566
|
assets={assets}
|
|
567
567
|
onSetStyle={onSetStyle}
|
|
568
|
+
onSetHtmlAttribute={props.onSetHtmlAttribute}
|
|
568
569
|
onImportAssets={onImportAssets}
|
|
569
570
|
gsapBorderRadius={gsapBorderRadius}
|
|
570
571
|
/>
|
|
@@ -351,6 +351,7 @@ export function PropertyPanelFlat({
|
|
|
351
351
|
styles={styles}
|
|
352
352
|
assets={assets}
|
|
353
353
|
onSetStyle={onSetStyle}
|
|
354
|
+
onSetHtmlAttribute={forwardedProps.onSetHtmlAttribute}
|
|
354
355
|
onPreviewStyle={onPreviewStyle}
|
|
355
356
|
onImportAssets={onImportAssets}
|
|
356
357
|
gsapBorderRadius={gsapBorderRadius}
|
|
@@ -44,9 +44,21 @@ export function readFileChangeAffectedCompositions(payload: unknown): readonly s
|
|
|
44
44
|
return value.map(normalizeStudioFileChangePath);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function
|
|
47
|
+
export function mergeFileChangePayloads(waiting: unknown, incoming: unknown): unknown {
|
|
48
48
|
const before = readFileChangeAffectedCompositions(waiting);
|
|
49
49
|
const after = readFileChangeAffectedCompositions(incoming);
|
|
50
50
|
const merged = before && after ? [...new Set([...before, ...after])] : null;
|
|
51
|
-
|
|
51
|
+
const next = asPayloadRecord(incoming);
|
|
52
|
+
const affectsPreview =
|
|
53
|
+
readFileChangeAffectsPreview(waiting) || readFileChangeAffectsPreview(incoming);
|
|
54
|
+
if (readStudioFileChangePath(waiting) === readStudioFileChangePath(incoming)) {
|
|
55
|
+
return { ...next, affectsPreview, affectedCompositions: merged };
|
|
56
|
+
}
|
|
57
|
+
// Changes to two files are owed: only a project-wide reload ("." path) covers both.
|
|
58
|
+
return {
|
|
59
|
+
path: ".",
|
|
60
|
+
projectId: next?.projectId,
|
|
61
|
+
affectsPreview,
|
|
62
|
+
affectedCompositions: merged,
|
|
63
|
+
};
|
|
52
64
|
}
|
|
@@ -311,7 +311,7 @@ describe("DomEditOverlay with canvasInput host", () => {
|
|
|
311
311
|
expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
-
it("draws
|
|
314
|
+
it("draws the hover box for the selection the host hovers, and none without one", () => {
|
|
315
315
|
layout.hover = RECT;
|
|
316
316
|
const hovered = (props = {}) => {
|
|
317
317
|
const { overlay } = fixture(props);
|
|
@@ -321,7 +321,8 @@ describe("DomEditOverlay with canvasInput host", () => {
|
|
|
321
321
|
};
|
|
322
322
|
const selection = makeSelection("Hover", textElement("hover"));
|
|
323
323
|
expect(hovered({ hoverSelection: selection })).not.toBeNull();
|
|
324
|
-
expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
|
|
324
|
+
expect(hovered({ ...HOST, hoverSelection: selection })).not.toBeNull();
|
|
325
|
+
expect(hovered({ ...HOST, hoverSelection: null })).toBeNull();
|
|
325
326
|
});
|
|
326
327
|
|
|
327
328
|
it("starts no marquee and makes no selection from a press on empty canvas", () => {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "./gradientValue";
|
|
11
11
|
import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
|
|
12
12
|
import { IMAGE_EXT } from "../../utils/mediaTypes";
|
|
13
|
+
import type { DomEditSelection } from "./domEditing";
|
|
13
14
|
import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
14
15
|
import {
|
|
15
16
|
DetailField,
|
|
@@ -27,9 +28,13 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
|
27
28
|
function normalizeProjectPath(value: string): string {
|
|
28
29
|
const trimmed = value.trim();
|
|
29
30
|
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
let decodedPath = maybeUrl;
|
|
32
|
+
try {
|
|
33
|
+
decodedPath = decodeURIComponent(maybeUrl);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!(error instanceof URIError)) throw error;
|
|
36
|
+
}
|
|
37
|
+
return decodedPath.replace(/\\/g, "/").replace(/^\.?\//, "");
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
@@ -75,7 +80,8 @@ function resolveSelectedAsset(
|
|
|
75
80
|
|
|
76
81
|
export function ImageFillField({
|
|
77
82
|
projectId,
|
|
78
|
-
|
|
83
|
+
element,
|
|
84
|
+
onSetHtmlAttribute,
|
|
79
85
|
value,
|
|
80
86
|
assets,
|
|
81
87
|
disabled,
|
|
@@ -83,7 +89,8 @@ export function ImageFillField({
|
|
|
83
89
|
onImportAssets,
|
|
84
90
|
}: {
|
|
85
91
|
projectId: string;
|
|
86
|
-
|
|
92
|
+
element: DomEditSelection;
|
|
93
|
+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
87
94
|
value: string;
|
|
88
95
|
assets: string[];
|
|
89
96
|
disabled?: boolean;
|
|
@@ -91,15 +98,29 @@ export function ImageFillField({
|
|
|
91
98
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
92
99
|
}) {
|
|
93
100
|
const track = useTrackDesignInput();
|
|
101
|
+
const sourceFile = element.sourceFile;
|
|
102
|
+
const isImage = element.tagName === "img";
|
|
103
|
+
const imageUrl = isImage ? (element.element.getAttribute("src") ?? "") : value;
|
|
104
|
+
const commitImage = (next: string, projectAsset = false) => {
|
|
105
|
+
if (isImage) {
|
|
106
|
+
if (next)
|
|
107
|
+
void onSetHtmlAttribute(
|
|
108
|
+
"src",
|
|
109
|
+
projectAsset ? toRelativeProjectAssetPath(sourceFile, next) : next,
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
onCommit(next ? `url("${next}")` : "none");
|
|
114
|
+
};
|
|
94
115
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
95
116
|
const [uploading, setUploading] = useState(false);
|
|
96
117
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
97
118
|
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
|
98
119
|
const selectedAsset = useMemo(
|
|
99
|
-
() => resolveSelectedAsset(
|
|
100
|
-
[imageAssets, sourceFile,
|
|
120
|
+
() => resolveSelectedAsset(imageUrl, sourceFile, imageAssets),
|
|
121
|
+
[imageAssets, sourceFile, imageUrl],
|
|
101
122
|
);
|
|
102
|
-
const externalUrlValue = selectedAsset ? "" :
|
|
123
|
+
const externalUrlValue = selectedAsset ? "" : imageUrl;
|
|
103
124
|
|
|
104
125
|
const handleUpload = async (files: FileList | null) => {
|
|
105
126
|
if (!files?.length || !onImportAssets) return;
|
|
@@ -110,7 +131,7 @@ export function ImageFillField({
|
|
|
110
131
|
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
|
111
132
|
if (nextImage) {
|
|
112
133
|
track("button", "Upload image");
|
|
113
|
-
|
|
134
|
+
commitImage(toProjectRootAssetPath(nextImage), true);
|
|
114
135
|
}
|
|
115
136
|
} catch {
|
|
116
137
|
setUploadError("Upload failed — check the file and try again.");
|
|
@@ -174,14 +195,16 @@ export function ImageFillField({
|
|
|
174
195
|
const next = e.target.value;
|
|
175
196
|
track("select", "Project asset");
|
|
176
197
|
if (!next) {
|
|
177
|
-
|
|
198
|
+
commitImage("");
|
|
178
199
|
return;
|
|
179
200
|
}
|
|
180
|
-
|
|
201
|
+
commitImage(toProjectRootAssetPath(next), true);
|
|
181
202
|
}}
|
|
182
203
|
className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-hidden disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
183
204
|
>
|
|
184
|
-
<option value="">
|
|
205
|
+
<option value="" disabled={isImage}>
|
|
206
|
+
None
|
|
207
|
+
</option>
|
|
185
208
|
{imageAssets.map((asset) => (
|
|
186
209
|
<option key={asset} value={asset}>
|
|
187
210
|
{asset}
|
|
@@ -201,7 +224,7 @@ export function ImageFillField({
|
|
|
201
224
|
label="External URL"
|
|
202
225
|
value={externalUrlValue}
|
|
203
226
|
disabled={disabled}
|
|
204
|
-
onCommit={(next) =>
|
|
227
|
+
onCommit={(next) => commitImage(next.trim())}
|
|
205
228
|
/>
|
|
206
229
|
</div>
|
|
207
230
|
);
|
|
@@ -63,6 +63,7 @@ function renderSection(
|
|
|
63
63
|
styles={mergedStyles}
|
|
64
64
|
assets={[]}
|
|
65
65
|
onSetStyle={onSetStyle}
|
|
66
|
+
onSetHtmlAttribute={vi.fn()}
|
|
66
67
|
gsapBorderRadius={gsapBorderRadius}
|
|
67
68
|
/>,
|
|
68
69
|
);
|
|
@@ -375,6 +376,7 @@ describe("FlatStyleSection — blur sliders", () => {
|
|
|
375
376
|
styles={{ filter: "blur(4px)" }}
|
|
376
377
|
assets={[]}
|
|
377
378
|
onSetStyle={onSetStyle}
|
|
379
|
+
onSetHtmlAttribute={vi.fn()}
|
|
378
380
|
gsapBorderRadius={null}
|
|
379
381
|
/>,
|
|
380
382
|
);
|
|
@@ -483,6 +485,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
|
|
|
483
485
|
styles={{}}
|
|
484
486
|
assets={[]}
|
|
485
487
|
onSetStyle={onSetStyle}
|
|
488
|
+
onSetHtmlAttribute={vi.fn()}
|
|
486
489
|
gsapBorderRadius={null}
|
|
487
490
|
/>,
|
|
488
491
|
);
|
|
@@ -593,6 +596,7 @@ describe("FlatStyleSection — Opacity", () => {
|
|
|
593
596
|
styles={{}}
|
|
594
597
|
assets={[]}
|
|
595
598
|
onSetStyle={onSetStyle}
|
|
599
|
+
onSetHtmlAttribute={vi.fn()}
|
|
596
600
|
gsapBorderRadius={null}
|
|
597
601
|
/>,
|
|
598
602
|
);
|
|
@@ -43,6 +43,7 @@ function FlatFillFields({
|
|
|
43
43
|
styles,
|
|
44
44
|
assets,
|
|
45
45
|
onSetStyle,
|
|
46
|
+
onSetHtmlAttribute,
|
|
46
47
|
onPreviewStyle,
|
|
47
48
|
onImportAssets,
|
|
48
49
|
}: {
|
|
@@ -51,18 +52,19 @@ function FlatFillFields({
|
|
|
51
52
|
styles: Record<string, string>;
|
|
52
53
|
assets: string[];
|
|
53
54
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
55
|
+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
54
56
|
onPreviewStyle?: (prop: string, value: string) => void;
|
|
55
57
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
56
58
|
}) {
|
|
57
59
|
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
|
58
60
|
const backgroundImage = styles["background-image"] ?? "none";
|
|
59
61
|
const hasTextControls = isTextEditableSelection(element);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
let fillMode = "Solid";
|
|
63
|
+
if (element.tagName === "img") {
|
|
64
|
+
fillMode = "Image";
|
|
65
|
+
} else if (backgroundImage && backgroundImage !== "none") {
|
|
66
|
+
fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
|
|
67
|
+
}
|
|
66
68
|
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
|
67
69
|
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
|
68
70
|
|
|
@@ -124,7 +126,8 @@ function FlatFillFields({
|
|
|
124
126
|
) : (
|
|
125
127
|
<ImageFillField
|
|
126
128
|
projectId={projectId}
|
|
127
|
-
|
|
129
|
+
element={element}
|
|
130
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
128
131
|
value={imageUrl}
|
|
129
132
|
assets={assets}
|
|
130
133
|
disabled={styleEditingDisabled}
|
|
@@ -456,6 +459,7 @@ export function FlatStyleSection({
|
|
|
456
459
|
styles,
|
|
457
460
|
assets,
|
|
458
461
|
onSetStyle,
|
|
462
|
+
onSetHtmlAttribute,
|
|
459
463
|
onPreviewStyle,
|
|
460
464
|
onImportAssets,
|
|
461
465
|
gsapBorderRadius,
|
|
@@ -465,6 +469,7 @@ export function FlatStyleSection({
|
|
|
465
469
|
styles: Record<string, string>;
|
|
466
470
|
assets: string[];
|
|
467
471
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
472
|
+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
468
473
|
onPreviewStyle?: (prop: string, value: string) => void;
|
|
469
474
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
470
475
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
@@ -478,6 +483,7 @@ export function FlatStyleSection({
|
|
|
478
483
|
styles={styles}
|
|
479
484
|
assets={assets}
|
|
480
485
|
onSetStyle={onSetStyle}
|
|
486
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
481
487
|
onPreviewStyle={onPreviewStyle}
|
|
482
488
|
onImportAssets={onImportAssets}
|
|
483
489
|
/>
|