@hyperframes/studio 0.7.56 → 0.7.57
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--Z69cEkE.js +459 -0
- package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
- package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4345 -3812
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditOverlay.tsx +31 -62
- package/src/components/editor/LayersPanel.tsx +10 -1
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +36 -16
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/timelineEditingHelpers.test.ts +91 -0
- package/src/hooks/timelineEditingHelpers.ts +31 -244
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +5 -3
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +92 -1
- package/src/hooks/useDomEditCommits.ts +63 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
- package/src/hooks/useElementLifecycleOps.ts +20 -7
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +378 -212
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineGroupEditing.ts +153 -132
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +85 -8
- package/src/player/components/Timeline.tsx +9 -9
- package/src/player/components/TimelineCanvas.tsx +11 -2
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/timelineClipDragCommit.test.ts +91 -8
- package/src/player/components/timelineClipDragCommit.ts +106 -9
- package/src/player/components/timelineLayout.ts +99 -26
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
- package/src/player/components/useTimelineStackingSync.ts +13 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.ts +9 -0
- package/src/player/lib/timelineElementHelpers.ts +19 -0
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +37 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
|
+
isElementVisibleForZOrder,
|
|
4
5
|
isZOrderActionEnabled,
|
|
5
6
|
parseZIndex,
|
|
7
|
+
resolveCrossedNeighbor,
|
|
6
8
|
resolveZOrderChange,
|
|
7
9
|
type ZOrderAction,
|
|
8
10
|
type ZOrderPatch,
|
|
@@ -48,8 +50,12 @@ function makeFamily(
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
|
|
51
|
-
function resolveZOrderPatches(
|
|
52
|
-
|
|
53
|
+
function resolveZOrderPatches(
|
|
54
|
+
target: HTMLElement,
|
|
55
|
+
action: ZOrderAction,
|
|
56
|
+
options?: { isVisible?: (el: HTMLElement) => boolean },
|
|
57
|
+
): ZOrderPatch[] {
|
|
58
|
+
const patches = resolveZOrderChange(target, action, options);
|
|
53
59
|
expect(patches).not.toBeNull();
|
|
54
60
|
if (!patches) throw new Error("expected z-order patches");
|
|
55
61
|
return patches;
|
|
@@ -375,6 +381,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
|
|
|
375
381
|
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
|
|
376
382
|
});
|
|
377
383
|
|
|
384
|
+
it("ignores <template>/<noscript> siblings in the family", () => {
|
|
385
|
+
// A renumber fallback once wrote z-index/position into <template> source
|
|
386
|
+
// markup because templates entered the sibling family. They never paint —
|
|
387
|
+
// exclude them like audio/script/style.
|
|
388
|
+
const parent = document.createElement("div");
|
|
389
|
+
const a = makeEl("a", "0");
|
|
390
|
+
const template = document.createElement("template");
|
|
391
|
+
template.style.zIndex = "2";
|
|
392
|
+
const noscript = document.createElement("noscript");
|
|
393
|
+
const target = makeEl("target", "0");
|
|
394
|
+
parent.append(a, template, noscript, target);
|
|
395
|
+
|
|
396
|
+
const patches = resolveZOrderPatches(target, "send-to-back");
|
|
397
|
+
for (const p of patches) {
|
|
398
|
+
expect(p.element).not.toBe(template);
|
|
399
|
+
expect(p.element).not.toBe(noscript);
|
|
400
|
+
}
|
|
401
|
+
const order = renderOrderIds(parent, { a, target }, patches);
|
|
402
|
+
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
|
|
403
|
+
});
|
|
404
|
+
|
|
378
405
|
it("a lone painting element beside only non-painting siblings has no family → null", () => {
|
|
379
406
|
const parent = document.createElement("div");
|
|
380
407
|
const target = makeEl("target", "1");
|
|
@@ -392,6 +419,163 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
|
|
|
392
419
|
});
|
|
393
420
|
});
|
|
394
421
|
|
|
422
|
+
// ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
|
|
423
|
+
//
|
|
424
|
+
// The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
|
|
425
|
+
// like rect reading is stubbable — these tests drive the scoping logic with a
|
|
426
|
+
// stub, without a real style engine.
|
|
427
|
+
|
|
428
|
+
describe("resolveZOrderChange – visibility scoping (injectable stub)", () => {
|
|
429
|
+
/** Probe that hides exactly the given elements. */
|
|
430
|
+
function hiding(...hidden: HTMLElement[]) {
|
|
431
|
+
return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
|
|
435
|
+
// Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
|
|
436
|
+
// is invisible at the current frame — forward must land the target above
|
|
437
|
+
// `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
|
|
438
|
+
const { target, byId } = makeFamily("1", [
|
|
439
|
+
["hidden", "2"],
|
|
440
|
+
["vis", "3"],
|
|
441
|
+
]);
|
|
442
|
+
const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
|
|
443
|
+
expect(patches).toHaveLength(1);
|
|
444
|
+
expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
|
|
445
|
+
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
|
|
449
|
+
// Render order: vis(1), hidden(2), target(3). Backward must drop the target
|
|
450
|
+
// below `vis`, not merely below the invisible `hidden`.
|
|
451
|
+
const { target, byId } = makeFamily("3", [
|
|
452
|
+
["vis", "1"],
|
|
453
|
+
["hidden", "2"],
|
|
454
|
+
]);
|
|
455
|
+
const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
|
|
456
|
+
expect(patches).toHaveLength(1);
|
|
457
|
+
expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
|
|
458
|
+
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
|
|
462
|
+
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
|
|
463
|
+
const opts = hiding(byId.hidden!);
|
|
464
|
+
expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
|
|
465
|
+
expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
|
|
469
|
+
// Unchanged semantics: front/back operate across all siblings, so an
|
|
470
|
+
// invisible sibling still counts and the actions stay meaningful.
|
|
471
|
+
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
|
|
472
|
+
const opts = hiding(byId.hidden!);
|
|
473
|
+
const patches = resolveZOrderPatches(target, "bring-to-front", opts);
|
|
474
|
+
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
|
|
475
|
+
expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("the target itself is retained even when the probe reports it invisible", () => {
|
|
479
|
+
const { target, byId } = makeFamily("1", [["vis", "2"]]);
|
|
480
|
+
const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
|
|
481
|
+
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
|
|
485
|
+
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
|
|
486
|
+
const opts = hiding(byId.hidden!);
|
|
487
|
+
// Forward/backward: scoped set collapses to the target alone → disabled.
|
|
488
|
+
expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
|
|
489
|
+
expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
|
|
490
|
+
// Front/back: full family → enabled exactly where the resolver acts.
|
|
491
|
+
expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
|
|
492
|
+
expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ── default visibility probe (element-level computed style) ───────────────────
|
|
497
|
+
|
|
498
|
+
describe("isElementVisibleForZOrder – default probe", () => {
|
|
499
|
+
function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
|
|
500
|
+
const el = document.createElement("div");
|
|
501
|
+
Object.assign(el.style, style);
|
|
502
|
+
document.body.appendChild(el);
|
|
503
|
+
return el;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
|
|
507
|
+
expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
|
|
508
|
+
expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
|
|
509
|
+
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
|
|
510
|
+
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("treats normal, translucent, and unstyled elements as visible", () => {
|
|
514
|
+
expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
|
|
515
|
+
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
|
|
516
|
+
expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
|
|
520
|
+
const el = attachedEl({ opacity: "0" });
|
|
521
|
+
el.setAttribute("data-hf-color-grading-source-hidden", "");
|
|
522
|
+
expect(isElementVisibleForZOrder(el)).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
|
|
526
|
+
// End-to-end through resolveZOrderChange with NO injected probe: the
|
|
527
|
+
// runtime hides inactive clips with inline `visibility:hidden` (see core
|
|
528
|
+
// runtime syncTimedElementVisibility) — computed style picks that up.
|
|
529
|
+
const parent = document.createElement("div");
|
|
530
|
+
const target = makeEl("target", "1");
|
|
531
|
+
const hidden = makeEl("hidden", "2");
|
|
532
|
+
hidden.style.visibility = "hidden";
|
|
533
|
+
const vis = makeEl("vis", "3");
|
|
534
|
+
parent.append(target, hidden, vis);
|
|
535
|
+
document.body.appendChild(parent);
|
|
536
|
+
const patches = resolveZOrderPatches(target, "bring-forward");
|
|
537
|
+
expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
|
|
538
|
+
expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
|
|
543
|
+
|
|
544
|
+
describe("resolveCrossedNeighbor", () => {
|
|
545
|
+
it("returns the visible sibling directly above for bring-forward", () => {
|
|
546
|
+
const { target, byId } = makeFamily("1", [
|
|
547
|
+
["hidden", "2"],
|
|
548
|
+
["vis", "3"],
|
|
549
|
+
]);
|
|
550
|
+
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
|
|
551
|
+
expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("returns the visible sibling directly below for send-backward", () => {
|
|
555
|
+
const { target, byId } = makeFamily("3", [
|
|
556
|
+
["vis", "1"],
|
|
557
|
+
["hidden", "2"],
|
|
558
|
+
]);
|
|
559
|
+
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
|
|
560
|
+
expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("returns null for front/back actions and for no-op steps", () => {
|
|
564
|
+
const { target } = makeFamily("1", [["a", "2"]]);
|
|
565
|
+
expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
|
|
566
|
+
expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
|
|
567
|
+
expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
|
|
568
|
+
const { target: top } = makeFamily("5", [["a", "2"]]);
|
|
569
|
+
expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("returns null when there are no siblings", () => {
|
|
573
|
+
const solo = makeEl("solo", "1");
|
|
574
|
+
document.createElement("div").appendChild(solo);
|
|
575
|
+
expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
395
579
|
// ── isZOrderActionEnabled ─────────────────────────────────────────────────────
|
|
396
580
|
|
|
397
581
|
describe("isZOrderActionEnabled", () => {
|
|
@@ -7,8 +7,23 @@
|
|
|
7
7
|
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
|
|
8
8
|
*
|
|
9
9
|
* "Overlapping siblings" = siblings whose bounding rects intersect the
|
|
10
|
-
* target's bounding rect
|
|
11
|
-
* front/back operate across all
|
|
10
|
+
* target's bounding rect AND are actually visible at the current frame.
|
|
11
|
+
* Forward/backward operate within that set; front/back operate across all
|
|
12
|
+
* siblings (full painting family, visible or not — unchanged semantics).
|
|
13
|
+
*
|
|
14
|
+
* ── Visibility ───────────────────────────────────────────────────────────────
|
|
15
|
+
* In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
|
|
16
|
+
* paused frame: the runtime hides time-inactive clips with inline
|
|
17
|
+
* `visibility:hidden` / `display:none` (see core runtime
|
|
18
|
+
* syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
|
|
19
|
+
* Stepping "forward" over such a sibling looks like a silent no-op. The
|
|
20
|
+
* forward/backward comparison set therefore keeps only siblings whose
|
|
21
|
+
* element-level computed style is visible (display ≠ none, visibility ≠
|
|
22
|
+
* hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
|
|
23
|
+
* computed style covers them. Ancestor-chain checks are unnecessary here:
|
|
24
|
+
* siblings share the target's ancestors. The probe is injectable
|
|
25
|
+
* (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
|
|
26
|
+
* without a real style engine, mirroring how tests stub rect reading.
|
|
12
27
|
*
|
|
13
28
|
* ── Tie-awareness ────────────────────────────────────────────────────────────
|
|
14
29
|
* CSS paint order for elements that share a z-index is DOM document order:
|
|
@@ -26,6 +41,8 @@
|
|
|
26
41
|
* (project convention clamps z ≥ 0).
|
|
27
42
|
*/
|
|
28
43
|
|
|
44
|
+
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
45
|
+
|
|
29
46
|
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
|
30
47
|
|
|
31
48
|
/** A resolved change: set `element`'s z-index to `zIndex`. */
|
|
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
|
|
|
34
51
|
zIndex: number;
|
|
35
52
|
}
|
|
36
53
|
|
|
54
|
+
/** Injectable knobs for the pure resolver (kept mockable like rect reading). */
|
|
55
|
+
export interface ZOrderResolveOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Element-level visibility probe used to scope the forward/backward
|
|
58
|
+
* comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
|
|
59
|
+
* display/visibility/opacity). Injectable so tests can run without a real
|
|
60
|
+
* style engine.
|
|
61
|
+
*/
|
|
62
|
+
isVisible?: (el: HTMLElement) => boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default visibility probe: is this element itself visible at the current
|
|
67
|
+
* frame? Element-level only (siblings share the target's ancestor chain).
|
|
68
|
+
* Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
|
|
69
|
+
* `display:none`) and animation-parked `opacity:0`, all of which surface
|
|
70
|
+
* through computed style. A color-grading source (hidden at opacity:0 while
|
|
71
|
+
* its canvas paints in its place) still counts as visible, matching
|
|
72
|
+
* isElementVisibleThroughAncestors in domEditingDom.
|
|
73
|
+
*/
|
|
74
|
+
export function isElementVisibleForZOrder(el: HTMLElement): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const win = el.ownerDocument?.defaultView;
|
|
77
|
+
if (!win) return true;
|
|
78
|
+
const computed = win.getComputedStyle(el);
|
|
79
|
+
if (computed.display === "none") return false;
|
|
80
|
+
if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
|
|
81
|
+
const opacity = Number.parseFloat(computed.opacity);
|
|
82
|
+
if (
|
|
83
|
+
Number.isFinite(opacity) &&
|
|
84
|
+
opacity <= 0.01 &&
|
|
85
|
+
!el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
|
|
86
|
+
) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
/* cross-origin / detached — assume visible (fail open, matches rect fallback) */
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
37
96
|
interface RenderEntry {
|
|
38
97
|
element: HTMLElement;
|
|
39
98
|
zIndex: number;
|
|
@@ -81,8 +140,18 @@ function isElementNode(node: Node): node is HTMLElement {
|
|
|
81
140
|
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
|
|
82
141
|
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
|
|
83
142
|
* non-painting and could otherwise pad the family / eat a z slot.
|
|
143
|
+
* `<template>/<noscript>` never paint either — letting them in meant renumber
|
|
144
|
+
* fallbacks wrote z-index/position into template source markup.
|
|
84
145
|
*/
|
|
85
|
-
const NON_PAINTING_TAGS = new Set([
|
|
146
|
+
const NON_PAINTING_TAGS = new Set([
|
|
147
|
+
"AUDIO",
|
|
148
|
+
"SCRIPT",
|
|
149
|
+
"STYLE",
|
|
150
|
+
"LINK",
|
|
151
|
+
"META",
|
|
152
|
+
"TEMPLATE",
|
|
153
|
+
"NOSCRIPT",
|
|
154
|
+
]);
|
|
86
155
|
|
|
87
156
|
/** A painting element: an element node whose tag actually renders pixels. */
|
|
88
157
|
function isPaintingElement(node: Node): node is HTMLElement {
|
|
@@ -112,7 +181,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
|
|
|
112
181
|
return { entries, targetIndex };
|
|
113
182
|
}
|
|
114
183
|
|
|
115
|
-
/** True if two DOM bounding rects
|
|
184
|
+
/** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
|
|
116
185
|
function rectsIntersect(
|
|
117
186
|
a: { left: number; top: number; right: number; bottom: number },
|
|
118
187
|
b: { left: number; top: number; right: number; bottom: number },
|
|
@@ -121,26 +190,35 @@ function rectsIntersect(
|
|
|
121
190
|
}
|
|
122
191
|
|
|
123
192
|
/**
|
|
124
|
-
* Restrict a family to the target plus siblings
|
|
125
|
-
* the target's rect. The target is always retained
|
|
126
|
-
*
|
|
127
|
-
*
|
|
193
|
+
* Restrict a family to the target plus siblings that are VISIBLE and whose
|
|
194
|
+
* bounding rect overlaps the target's rect. The target is always retained
|
|
195
|
+
* (even when itself hidden at the current frame — it is the user's explicit
|
|
196
|
+
* selection). If the target's rect is unavailable or empty (headless /
|
|
197
|
+
* happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
|
|
198
|
+
* entries are kept — matching the prior rect-fallback behavior.
|
|
128
199
|
*/
|
|
129
|
-
function getOverlappingFamily(
|
|
200
|
+
function getOverlappingFamily(
|
|
201
|
+
target: HTMLElement,
|
|
202
|
+
entries: RenderEntry[],
|
|
203
|
+
isVisible: (el: HTMLElement) => boolean,
|
|
204
|
+
): RenderEntry[] {
|
|
205
|
+
const visibleEntries = entries.filter(
|
|
206
|
+
(entry) => entry.element === target || isVisible(entry.element),
|
|
207
|
+
);
|
|
130
208
|
let targetRect: DOMRect;
|
|
131
209
|
try {
|
|
132
210
|
targetRect = target.getBoundingClientRect();
|
|
133
211
|
} catch {
|
|
134
|
-
return
|
|
212
|
+
return visibleEntries;
|
|
135
213
|
}
|
|
136
|
-
if (targetRect.width === 0 && targetRect.height === 0) return
|
|
214
|
+
if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
|
|
137
215
|
const tr = {
|
|
138
216
|
left: targetRect.left,
|
|
139
217
|
top: targetRect.top,
|
|
140
218
|
right: targetRect.right,
|
|
141
219
|
bottom: targetRect.bottom,
|
|
142
220
|
};
|
|
143
|
-
return
|
|
221
|
+
return visibleEntries.filter((entry) => {
|
|
144
222
|
if (entry.element === target) return true;
|
|
145
223
|
try {
|
|
146
224
|
const r = entry.element.getBoundingClientRect();
|
|
@@ -296,28 +374,46 @@ function buildGlobalOrder(
|
|
|
296
374
|
}
|
|
297
375
|
|
|
298
376
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* end of its set), otherwise the minimal list of {element, zIndex} changes.
|
|
377
|
+
* The shared scoping pipeline: full painting family for front/back, visible
|
|
378
|
+
* overlapping siblings for forward/backward, sorted into render order with the
|
|
379
|
+
* target's position. Null when the family/scope is too small to act on.
|
|
303
380
|
*/
|
|
304
|
-
|
|
381
|
+
function resolveScopedRenderOrder(
|
|
305
382
|
target: HTMLElement,
|
|
306
383
|
action: ZOrderAction,
|
|
307
|
-
|
|
384
|
+
options?: ZOrderResolveOptions,
|
|
385
|
+
): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
|
|
308
386
|
const { entries } = getFamily(target);
|
|
309
387
|
// Family always includes the target; fewer than 2 means no siblings at all.
|
|
310
388
|
if (entries.length < 2) return null;
|
|
311
389
|
|
|
390
|
+
const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
|
|
312
391
|
const scoped =
|
|
313
392
|
action === "bring-to-front" || action === "send-to-back"
|
|
314
393
|
? entries
|
|
315
|
-
: getOverlappingFamily(target, entries);
|
|
394
|
+
: getOverlappingFamily(target, entries, isVisible);
|
|
316
395
|
if (scoped.length < 2) return null;
|
|
317
396
|
|
|
318
397
|
const order = toRenderOrder(scoped);
|
|
319
398
|
const pos = order.findIndex((e) => e.element === target);
|
|
320
399
|
if (pos === -1) return null;
|
|
400
|
+
return { entries, order, pos };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Resolve the z-order patches for an action.
|
|
405
|
+
*
|
|
406
|
+
* Returns null when the action is a no-op (target already at the relevant
|
|
407
|
+
* end of its set), otherwise the minimal list of {element, zIndex} changes.
|
|
408
|
+
*/
|
|
409
|
+
export function resolveZOrderChange(
|
|
410
|
+
target: HTMLElement,
|
|
411
|
+
action: ZOrderAction,
|
|
412
|
+
options?: ZOrderResolveOptions,
|
|
413
|
+
): ZOrderPatch[] | null {
|
|
414
|
+
const resolved = resolveScopedRenderOrder(target, action, options);
|
|
415
|
+
if (!resolved) return null;
|
|
416
|
+
const { entries, order, pos } = resolved;
|
|
321
417
|
|
|
322
418
|
const desired = [...order];
|
|
323
419
|
const [moved] = desired.splice(pos, 1);
|
|
@@ -343,10 +439,36 @@ export function resolveZOrderChange(
|
|
|
343
439
|
return realizeOrder(order, desired, target, entries);
|
|
344
440
|
}
|
|
345
441
|
|
|
442
|
+
/**
|
|
443
|
+
* The sibling a forward/backward step crosses: the visible overlapping
|
|
444
|
+
* neighbor directly above (bring-forward) or below (send-backward) the target
|
|
445
|
+
* in render order. Null for front/back, for a no-op step, or when the scope is
|
|
446
|
+
* too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
|
|
447
|
+
* same options BEFORE any live styles are applied.
|
|
448
|
+
*/
|
|
449
|
+
export function resolveCrossedNeighbor(
|
|
450
|
+
target: HTMLElement,
|
|
451
|
+
action: ZOrderAction,
|
|
452
|
+
options?: ZOrderResolveOptions,
|
|
453
|
+
): HTMLElement | null {
|
|
454
|
+
if (action !== "bring-forward" && action !== "send-backward") return null;
|
|
455
|
+
const resolved = resolveScopedRenderOrder(target, action, options);
|
|
456
|
+
if (!resolved) return null;
|
|
457
|
+
const { order, pos } = resolved;
|
|
458
|
+
const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
|
|
459
|
+
return neighbor?.element ?? null;
|
|
460
|
+
}
|
|
461
|
+
|
|
346
462
|
/**
|
|
347
463
|
* Whether a z-order action is available for the target.
|
|
348
|
-
* "disabled" = the element is already at that limit.
|
|
464
|
+
* "disabled" = the element is already at that limit. Shares the resolver (and
|
|
465
|
+
* its visibility scoping), so enable/disable always matches what the action
|
|
466
|
+
* would actually do.
|
|
349
467
|
*/
|
|
350
|
-
export function isZOrderActionEnabled(
|
|
351
|
-
|
|
468
|
+
export function isZOrderActionEnabled(
|
|
469
|
+
target: HTMLElement,
|
|
470
|
+
action: ZOrderAction,
|
|
471
|
+
options?: ZOrderResolveOptions,
|
|
472
|
+
): boolean {
|
|
473
|
+
return resolveZOrderChange(target, action, options) !== null;
|
|
352
474
|
}
|
|
@@ -24,6 +24,10 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
24
24
|
import { ColorGradingControls } from "./propertyPanelColorGradingControls";
|
|
25
25
|
import { stripQueryAndHash } from "./propertyPanelHelpers";
|
|
26
26
|
import { Section } from "./propertyPanelPrimitives";
|
|
27
|
+
import {
|
|
28
|
+
acceptStudioRuntimeMessage,
|
|
29
|
+
postRuntimeControlMessage,
|
|
30
|
+
} from "../../player/lib/runtimeProtocol";
|
|
27
31
|
|
|
28
32
|
const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
|
|
29
33
|
const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
|
|
@@ -393,35 +397,23 @@ export function ColorGradingSection({
|
|
|
393
397
|
|
|
394
398
|
const postColorGrading = useCallback(
|
|
395
399
|
(nextGrading: NormalizedHfColorGrading) => {
|
|
396
|
-
previewIframeRef?.current?.contentWindow
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
action: "set-color-grading",
|
|
401
|
-
target,
|
|
402
|
-
grading: toBridgeColorGrading(nextGrading),
|
|
403
|
-
},
|
|
404
|
-
"*",
|
|
405
|
-
);
|
|
400
|
+
postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
|
|
401
|
+
target,
|
|
402
|
+
grading: toBridgeColorGrading(nextGrading),
|
|
403
|
+
});
|
|
406
404
|
},
|
|
407
405
|
[previewIframeRef, target],
|
|
408
406
|
);
|
|
409
407
|
|
|
410
408
|
const postCompare = useCallback(
|
|
411
409
|
(enabled: boolean) => {
|
|
412
|
-
|
|
410
|
+
postRuntimeControlMessage(
|
|
411
|
+
previewIframeRef?.current?.contentWindow,
|
|
412
|
+
"set-color-grading-compare",
|
|
413
413
|
{
|
|
414
|
-
source: "hf-parent",
|
|
415
|
-
type: "control",
|
|
416
|
-
action: "set-color-grading-compare",
|
|
417
414
|
target,
|
|
418
|
-
compare: {
|
|
419
|
-
enabled,
|
|
420
|
-
position: 1,
|
|
421
|
-
lineWidth: 0,
|
|
422
|
-
},
|
|
415
|
+
compare: { enabled, position: 1, lineWidth: 0 },
|
|
423
416
|
},
|
|
424
|
-
"*",
|
|
425
417
|
);
|
|
426
418
|
},
|
|
427
419
|
[previewIframeRef, target],
|
|
@@ -440,7 +432,9 @@ export function ColorGradingSection({
|
|
|
440
432
|
const onMessage = (event: MessageEvent) => {
|
|
441
433
|
if (event.source !== iframe.contentWindow) return;
|
|
442
434
|
const data = event.data as { source?: unknown; type?: unknown } | null;
|
|
443
|
-
if (data?.source
|
|
435
|
+
if (data?.source !== "hf-preview" || data.type !== "ready") return;
|
|
436
|
+
if (!acceptStudioRuntimeMessage(data)) return;
|
|
437
|
+
refreshAndReplay();
|
|
444
438
|
};
|
|
445
439
|
iframe.addEventListener("load", refreshAndReplay);
|
|
446
440
|
window.addEventListener("message", onMessage);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas right-click context-menu state for DomEditOverlay: where the menu is
|
|
3
|
+
* open (viewport x/y) and which selection it targets, plus the right-click
|
|
4
|
+
* handler that resolves/selects the element under the pointer before opening.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useState, type RefObject } from "react";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
export interface CanvasContextMenuState {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
sel: DomEditSelection;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UseCanvasContextMenuStateParams {
|
|
16
|
+
selection: DomEditSelection | null;
|
|
17
|
+
selectionRef: RefObject<DomEditSelection | null>;
|
|
18
|
+
hoverSelectionRef: RefObject<DomEditSelection | null>;
|
|
19
|
+
onCanvasPointerMoveRef: RefObject<
|
|
20
|
+
(
|
|
21
|
+
event: React.PointerEvent<HTMLDivElement>,
|
|
22
|
+
options?: { preferClipAncestor?: boolean },
|
|
23
|
+
) => Promise<DomEditSelection | null>
|
|
24
|
+
>;
|
|
25
|
+
onSelectionChangeRef: RefObject<
|
|
26
|
+
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
|
|
27
|
+
>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useCanvasContextMenuState({
|
|
31
|
+
selection,
|
|
32
|
+
selectionRef,
|
|
33
|
+
hoverSelectionRef,
|
|
34
|
+
onCanvasPointerMoveRef,
|
|
35
|
+
onSelectionChangeRef,
|
|
36
|
+
}: UseCanvasContextMenuStateParams): {
|
|
37
|
+
contextMenu: CanvasContextMenuState | null;
|
|
38
|
+
closeContextMenu: () => void;
|
|
39
|
+
handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
|
|
40
|
+
} {
|
|
41
|
+
// Context menu state: position of the right-click that opened it.
|
|
42
|
+
// contextMenu.sel is the element the menu targets — captured at right-click
|
|
43
|
+
// time so the menu can open even before the React selection state settles.
|
|
44
|
+
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
|
|
45
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
46
|
+
|
|
47
|
+
// Close the context menu whenever the selection moves off the element the menu
|
|
48
|
+
// targets (a click that reselects elsewhere, a deselect, or a preview reload
|
|
49
|
+
// that rebuilds the selection). Without this the menu can linger — orphaned —
|
|
50
|
+
// over a stale target after the underlying element is gone. A right-click that
|
|
51
|
+
// OPENS the menu also selects its target, so the common open path keeps the
|
|
52
|
+
// menu (same element) rather than immediately dismissing it.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!contextMenu) return;
|
|
55
|
+
if (!selection || selection.element !== contextMenu.sel.element) {
|
|
56
|
+
setContextMenu(null);
|
|
57
|
+
}
|
|
58
|
+
}, [selection, contextMenu]);
|
|
59
|
+
|
|
60
|
+
// Right-click: select element first (if not already selected), then open menu.
|
|
61
|
+
const handleContextMenu = useCallback(
|
|
62
|
+
async (event: React.MouseEvent<HTMLDivElement>) => {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
|
|
65
|
+
// If no element is selected yet, resolve it from the pointer position first.
|
|
66
|
+
const currentSel = selectionRef.current;
|
|
67
|
+
let activeSel: DomEditSelection | null = currentSel;
|
|
68
|
+
if (!currentSel) {
|
|
69
|
+
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
|
|
70
|
+
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
|
|
71
|
+
if (!resolved) return; // Nothing under the cursor — skip menu.
|
|
72
|
+
onSelectionChangeRef.current(resolved, { revealPanel: true });
|
|
73
|
+
// Use `resolved` directly: React state (and therefore selectionRef) won't
|
|
74
|
+
// update synchronously after onSelectionChange — we'd be reading stale null.
|
|
75
|
+
activeSel = resolved;
|
|
76
|
+
} else {
|
|
77
|
+
// Check if the user right-clicked on an unselected element (hover target).
|
|
78
|
+
const hover = hoverSelectionRef.current;
|
|
79
|
+
if (hover && hover.element !== currentSel.element) {
|
|
80
|
+
onSelectionChangeRef.current(hover, { revealPanel: true });
|
|
81
|
+
activeSel = hover;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!activeSel) return;
|
|
86
|
+
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
|
|
87
|
+
},
|
|
88
|
+
[selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { contextMenu, closeContextMenu, handleContextMenu };
|
|
92
|
+
}
|