@hyperframes/studio 0.8.65 → 0.8.67
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-CwFrSySS.js → hyperframes-player-BF2Y9Rtn.js} +1 -1
- package/dist/assets/index-BBOktYKC.css +1 -0
- package/dist/assets/{index-a5nEhdAU.js → index-BXCEB5uw.js} +1 -1
- package/dist/assets/{index-Cs1lQrQ6.js → index-CGQ66fle.js} +200 -200
- package/dist/assets/{index-wQFEeTL3.js → index-Nnd43iYY.js} +1 -1
- package/dist/index.css +147 -16
- package/dist/index.css.map +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4451 -4193
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +1 -3
- package/src/components/StudioLeftPanels.tsx +1 -1
- package/src/components/dock/Dock.tsx +8 -0
- package/src/components/dock/DockStripActions.tsx +63 -0
- package/src/components/dock/DockTab.tsx +68 -0
- package/src/components/dock/DockTabs.test.tsx +255 -0
- package/src/components/dock/dock.css +185 -16
- package/src/components/dock/dockTabFill.ts +124 -0
- package/src/components/dock/dockTabs.contract.test.ts +80 -0
- package/src/components/editor/manualEdits.test.ts +100 -0
- package/src/components/editor/manualEdits.ts +17 -0
- package/src/components/nle/PreviewPane.tsx +3 -3
- package/src/components/nle/TimelinePane.tsx +1 -1
- package/src/components/ui/Menu.tsx +16 -0
- package/src/components/ui/index.ts +1 -0
- package/src/styles/theme.css +8 -0
- package/dist/assets/index-BnsYeuxA.css +0 -1
|
@@ -1,28 +1,197 @@
|
|
|
1
1
|
@import "dockview-react/dist/styles/dockview.css";
|
|
2
2
|
|
|
3
3
|
.hf-dock {
|
|
4
|
-
--dv-group-view-background-color: var(--
|
|
5
|
-
--dv-tabs-and-actions-container-background-color: var(--
|
|
6
|
-
--dv-
|
|
7
|
-
|
|
8
|
-
--dv-
|
|
9
|
-
--dv-
|
|
10
|
-
--dv-
|
|
11
|
-
--dv-
|
|
12
|
-
--dv-
|
|
13
|
-
--dv-
|
|
14
|
-
--dv-tab-
|
|
15
|
-
--dv-
|
|
16
|
-
--dv-
|
|
17
|
-
--dv-
|
|
4
|
+
--dv-group-view-background-color: var(--panel-card-bg);
|
|
5
|
+
--dv-tabs-and-actions-container-background-color: var(--panel-strip-bg);
|
|
6
|
+
--dv-tabs-and-actions-container-height: 36px;
|
|
7
|
+
/* The fill paints the shown tab, so every tab state is transparent. */
|
|
8
|
+
--dv-activegroup-visiblepanel-tab-background-color: transparent;
|
|
9
|
+
--dv-activegroup-hiddenpanel-tab-background-color: transparent;
|
|
10
|
+
--dv-inactivegroup-visiblepanel-tab-background-color: transparent;
|
|
11
|
+
--dv-inactivegroup-hiddenpanel-tab-background-color: transparent;
|
|
12
|
+
--dv-activegroup-visiblepanel-tab-color: var(--color-text-1);
|
|
13
|
+
--dv-activegroup-hiddenpanel-tab-color: var(--color-text-2);
|
|
14
|
+
--dv-inactivegroup-visiblepanel-tab-color: var(--color-text-1);
|
|
15
|
+
--dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-2);
|
|
16
|
+
--dv-tab-divider-color: transparent;
|
|
17
|
+
--dv-tab-margin: 0;
|
|
18
|
+
--dv-separator-border: transparent;
|
|
19
|
+
--dv-paneview-header-border-color: transparent;
|
|
20
|
+
--dv-sash-color: transparent;
|
|
18
21
|
--dv-active-sash-color: var(--color-panel-accent);
|
|
19
22
|
--dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent);
|
|
20
23
|
--dv-drag-over-border-color: var(--color-panel-accent);
|
|
21
|
-
--dv-tab-font-size:
|
|
22
|
-
--dv-tabs-and-actions-container-font-size:
|
|
24
|
+
--dv-tab-font-size: 12.5px;
|
|
25
|
+
--dv-tabs-and-actions-container-font-size: 12.5px;
|
|
26
|
+
background-color: var(--panel-shell-bg);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
.hf-dock .dv-sash:focus-visible {
|
|
26
30
|
background-color: var(--dv-active-sash-color);
|
|
27
31
|
outline: none;
|
|
28
32
|
}
|
|
33
|
+
|
|
34
|
+
.hf-dock .dv-dockview {
|
|
35
|
+
background-color: var(--panel-shell-bg);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Cards on the shell: 3px around each group's view makes a 6px gutter. The card owns the only border. */
|
|
39
|
+
.hf-dock .dv-view:has(> .dv-groupview) {
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
padding: 3px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.hf-dock .dv-groupview {
|
|
45
|
+
border: 1px solid var(--panel-card-border);
|
|
46
|
+
border-radius: 10px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hf-dock .dv-tabs-and-actions-container {
|
|
50
|
+
align-items: flex-end;
|
|
51
|
+
padding: 6px 0 0 4px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.hf-dock .dv-scrollable {
|
|
55
|
+
height: 30px;
|
|
56
|
+
min-width: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* dockview's own scrollbar lane; the strip scrolls by wheel and keeps the shown tab in view. */
|
|
60
|
+
.hf-dock .dv-scrollbar {
|
|
61
|
+
display: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.hf-dock .dv-tabs-container {
|
|
65
|
+
--hf-fade-start: 0px;
|
|
66
|
+
--hf-fade-end: 0px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* A mask adds a compositor render pass; only a strip that is actually clipped needs one. */
|
|
70
|
+
.hf-dock .dv-tabs-container[data-clipped] {
|
|
71
|
+
mask-image: linear-gradient(
|
|
72
|
+
to right,
|
|
73
|
+
transparent,
|
|
74
|
+
black var(--hf-fade-start),
|
|
75
|
+
black calc(100% - var(--hf-fade-end)),
|
|
76
|
+
transparent
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.hf-dock .dv-tabs-container[data-clipped~="start"] {
|
|
81
|
+
--hf-fade-start: 12px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.hf-dock .dv-tabs-container[data-clipped~="end"] {
|
|
85
|
+
--hf-fade-end: 12px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.hf-dock .dv-tab {
|
|
89
|
+
z-index: 1;
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
height: 30px;
|
|
93
|
+
padding: 0 6px;
|
|
94
|
+
border-radius: 6px 6px 0 0;
|
|
95
|
+
white-space: nowrap;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* dockview's own state rule paints tabs from these variables, so hover sets them. */
|
|
99
|
+
.hf-dock .dv-tab.dv-inactive-tab:hover {
|
|
100
|
+
--dv-activegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover);
|
|
101
|
+
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover);
|
|
102
|
+
--dv-activegroup-hiddenpanel-tab-color: var(--color-text-1);
|
|
103
|
+
--dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.hf-dock .dv-tab:focus-visible {
|
|
107
|
+
outline: 1.5px solid var(--color-accent);
|
|
108
|
+
outline-offset: -1.5px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.hf-dock .dv-tab:focus-visible::after,
|
|
112
|
+
.hf-dock .dv-tab:has(:focus-visible)::after {
|
|
113
|
+
outline: none !important;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.hf-dock-tab-fill {
|
|
117
|
+
position: absolute;
|
|
118
|
+
bottom: 0;
|
|
119
|
+
left: 0;
|
|
120
|
+
height: 30px;
|
|
121
|
+
border-radius: 6px 6px 0 0;
|
|
122
|
+
background-color: var(--panel-card-bg);
|
|
123
|
+
pointer-events: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.hf-dock-tab-fill[data-ready] {
|
|
127
|
+
transition:
|
|
128
|
+
transform 140ms ease-out,
|
|
129
|
+
width 140ms ease-out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Fills dockview's tab part, so the label and icon centre in the tab's full height. */
|
|
133
|
+
.hf-dock-tab {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 4px;
|
|
137
|
+
height: 100%;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.hf-dock .dv-inactive-tab .hf-dock-tab-icon,
|
|
141
|
+
.hf-dock .dv-inactive-tab .hf-dock-tab-close {
|
|
142
|
+
display: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.hf-dock-tab-icon {
|
|
146
|
+
flex: none;
|
|
147
|
+
animation: hf-dock-tab-icon-in 80ms ease-out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@keyframes hf-dock-tab-icon-in {
|
|
151
|
+
from {
|
|
152
|
+
opacity: 0;
|
|
153
|
+
transform: translateX(-2px);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Takes its space whenever the tab is shown, so hover never moves the label. */
|
|
158
|
+
.hf-dock-tab-close {
|
|
159
|
+
display: flex;
|
|
160
|
+
opacity: 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.dv-tab:hover .hf-dock-tab-close,
|
|
164
|
+
.dv-tab:focus-visible .hf-dock-tab-close {
|
|
165
|
+
opacity: 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Sized by the actions it draws, so a strip without them gives its tabs the whole width. */
|
|
169
|
+
.hf-dock .dv-right-actions-container {
|
|
170
|
+
flex: none;
|
|
171
|
+
align-self: flex-start;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.hf-dock-strip-actions {
|
|
175
|
+
display: flex;
|
|
176
|
+
margin-right: 4px;
|
|
177
|
+
opacity: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.dv-groupview:hover .hf-dock-strip-actions {
|
|
181
|
+
opacity: 0.5;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.hf-dock-strip-actions:hover,
|
|
185
|
+
.hf-dock-strip-actions:focus-within {
|
|
186
|
+
opacity: 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@media (prefers-reduced-motion: reduce) {
|
|
190
|
+
.hf-dock-tab-fill[data-ready] {
|
|
191
|
+
transition: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.hf-dock-tab-icon {
|
|
195
|
+
animation: none;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { DockviewApi } from "dockview-react";
|
|
2
|
+
|
|
3
|
+
const TAB_FILL_CLASS = "hf-dock-tab-fill";
|
|
4
|
+
|
|
5
|
+
/** The strip's one fill, created on first use. Dockview inserts tabs before tabs, so it stays first. */
|
|
6
|
+
function fillOf(list: HTMLElement): HTMLElement {
|
|
7
|
+
const existing = list.querySelector<HTMLElement>(`:scope > .${TAB_FILL_CLASS}`);
|
|
8
|
+
if (existing) return existing;
|
|
9
|
+
const fill = document.createElement("span");
|
|
10
|
+
fill.className = TAB_FILL_CLASS;
|
|
11
|
+
fill.setAttribute("aria-hidden", "true");
|
|
12
|
+
list.prepend(fill);
|
|
13
|
+
// Placed once without motion; only later moves slide.
|
|
14
|
+
requestAnimationFrame(() => fill.setAttribute("data-ready", ""));
|
|
15
|
+
return fill;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A strip's width and where its shown tab sits, as of the last pass. */
|
|
19
|
+
type StripShape = { width: number; tab: HTMLElement | null; left: number; size: number };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when the strip narrowed or its shown tab moved or resized since it was last visible. dockview
|
|
23
|
+
* reveals a tab only when it is activated, and the user's own scrolling changes none of these.
|
|
24
|
+
*/
|
|
25
|
+
function reshaped(before: StripShape | undefined, now: StripShape) {
|
|
26
|
+
if (!before || now.width === 0) return false;
|
|
27
|
+
if (now.width < before.width) return true;
|
|
28
|
+
return now.tab !== before.tab || now.left !== before.left || now.size !== before.size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The scroll that shows the whole shown tab, e.g. after the strip narrows because its actions appeared. */
|
|
32
|
+
function revealedScroll(shape: StripShape, scrollLeft: number) {
|
|
33
|
+
if (shape.left < scrollLeft) return shape.left;
|
|
34
|
+
// Offset sizes are rounded while the tab's edge is fractional; a pixel over is clamped at the end.
|
|
35
|
+
return Math.max(scrollLeft, shape.left + shape.size + 1 - shape.width);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. */
|
|
39
|
+
function measureFill(list: HTMLElement, shapes: WeakMap<HTMLElement, StripShape>): () => void {
|
|
40
|
+
const fill = fillOf(list);
|
|
41
|
+
const tab = list.querySelector<HTMLElement>(":scope > .dv-active-tab");
|
|
42
|
+
const shape = {
|
|
43
|
+
width: list.clientWidth,
|
|
44
|
+
tab,
|
|
45
|
+
left: tab?.offsetLeft ?? 0,
|
|
46
|
+
size: tab?.offsetWidth ?? 0,
|
|
47
|
+
};
|
|
48
|
+
const reveal = tab !== null && reshaped(shapes.get(list), shape);
|
|
49
|
+
// A hidden strip (another group maximised) keeps its scroll, so it is compared with its last visible shape.
|
|
50
|
+
if (shape.width > 0) shapes.set(list, shape);
|
|
51
|
+
const scrollLeft = list.scrollLeft;
|
|
52
|
+
const target = reveal ? revealedScroll(shape, scrollLeft) : scrollLeft;
|
|
53
|
+
return () => {
|
|
54
|
+
fill.hidden = !tab;
|
|
55
|
+
if (!tab) return;
|
|
56
|
+
fill.style.width = `${shape.size}px`;
|
|
57
|
+
fill.style.transform = `translateX(${shape.left}px)`;
|
|
58
|
+
if (target !== scrollLeft) list.scrollLeft = target;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Marks which edges of a scrolled strip are clipped, for the CSS edge fade. */
|
|
63
|
+
function markClippedEdges(list: HTMLElement) {
|
|
64
|
+
const edges = [];
|
|
65
|
+
if (list.scrollLeft > 0) edges.push("start");
|
|
66
|
+
if (list.scrollLeft + list.clientWidth < list.scrollWidth - 1) edges.push("end");
|
|
67
|
+
const value = edges.join(" ");
|
|
68
|
+
if (value) list.setAttribute("data-clipped", value);
|
|
69
|
+
else list.removeAttribute("data-clipped");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Slides one fill per tab strip under its shown tab, keeps that tab in view, and keeps the
|
|
74
|
+
* clipped-edge marks current.
|
|
75
|
+
* Returns a disposer.
|
|
76
|
+
*/
|
|
77
|
+
export function installTabFill(api: DockviewApi, root: HTMLElement): () => void {
|
|
78
|
+
const lists = () => root.querySelectorAll<HTMLElement>(".dv-tabs-container");
|
|
79
|
+
// Tab widths also change with no dockview event: a renamed title, a late web font.
|
|
80
|
+
const resizeObserver = new ResizeObserver(() => placeAll());
|
|
81
|
+
const observed = new Set<Element>();
|
|
82
|
+
function observe(elements: Iterable<Element>) {
|
|
83
|
+
for (const element of observed) {
|
|
84
|
+
if (element.isConnected) continue;
|
|
85
|
+
resizeObserver.unobserve(element);
|
|
86
|
+
observed.delete(element);
|
|
87
|
+
}
|
|
88
|
+
for (const element of elements) {
|
|
89
|
+
if (observed.has(element)) continue;
|
|
90
|
+
resizeObserver.observe(element);
|
|
91
|
+
observed.add(element);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const shapes = new WeakMap<HTMLElement, StripShape>();
|
|
95
|
+
function placeAll() {
|
|
96
|
+
const strips = [...lists()];
|
|
97
|
+
const writes = strips.map((list) => measureFill(list, shapes));
|
|
98
|
+
for (const write of writes) write();
|
|
99
|
+
for (const list of strips) markClippedEdges(list);
|
|
100
|
+
observe(strips.flatMap((list) => [list, ...list.querySelectorAll(":scope > .dv-tab")]));
|
|
101
|
+
}
|
|
102
|
+
const onScroll = (event: Event) => {
|
|
103
|
+
const list = event.target;
|
|
104
|
+
if (list instanceof HTMLElement && list.classList.contains("dv-tabs-container")) {
|
|
105
|
+
markClippedEdges(list);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const subscriptions = [
|
|
109
|
+
api.onDidActivePanelChange(placeAll),
|
|
110
|
+
api.onDidAddPanel(placeAll),
|
|
111
|
+
api.onDidRemovePanel(placeAll),
|
|
112
|
+
api.onDidMovePanel(placeAll),
|
|
113
|
+
api.onDidLayoutChange(placeAll),
|
|
114
|
+
api.onDidLayoutFromJSON(placeAll),
|
|
115
|
+
];
|
|
116
|
+
// Scroll does not bubble; a capturing listener still sees every strip's.
|
|
117
|
+
root.addEventListener("scroll", onScroll, true);
|
|
118
|
+
placeAll();
|
|
119
|
+
return () => {
|
|
120
|
+
root.removeEventListener("scroll", onScroll, true);
|
|
121
|
+
for (const subscription of subscriptions) subscription.dispose();
|
|
122
|
+
resizeObserver.disconnect();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8").replace(
|
|
6
|
+
/@import[^;]*;/,
|
|
7
|
+
"",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/** Each top-level rule's selector and body, with the reduced-motion block kept whole. */
|
|
11
|
+
function rules(css: string): { selector: string; body: string }[] {
|
|
12
|
+
const found: { selector: string; body: string }[] = [];
|
|
13
|
+
let depth = 0;
|
|
14
|
+
let start = 0;
|
|
15
|
+
let selector = "";
|
|
16
|
+
for (let index = 0; index < css.length; index++) {
|
|
17
|
+
if (css[index] === "{") {
|
|
18
|
+
if (depth === 0) {
|
|
19
|
+
selector = css
|
|
20
|
+
.slice(start, index)
|
|
21
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
22
|
+
.trim();
|
|
23
|
+
start = index + 1;
|
|
24
|
+
}
|
|
25
|
+
depth++;
|
|
26
|
+
} else if (css[index] === "}") {
|
|
27
|
+
depth--;
|
|
28
|
+
if (depth === 0) {
|
|
29
|
+
found.push({ selector, body: css.slice(start, index) });
|
|
30
|
+
start = index + 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const bodyOf = (selector: string) =>
|
|
38
|
+
rules(dockCss).find((rule) => rule.selector === selector)?.body;
|
|
39
|
+
|
|
40
|
+
describe("dock panel strip contract", () => {
|
|
41
|
+
it("draws one border per surface: the card's, and no divider, separator or tab outline", () => {
|
|
42
|
+
const bordered = rules(dockCss)
|
|
43
|
+
.filter((rule) => /(^|[\s;{])border(-(top|right|bottom|left))?\s*:/.test(rule.body))
|
|
44
|
+
.map((rule) => rule.selector);
|
|
45
|
+
expect(bordered).toEqual([".hf-dock .dv-groupview"]);
|
|
46
|
+
const dock = bodyOf(".hf-dock");
|
|
47
|
+
expect(dock).toContain("--dv-tab-divider-color: transparent");
|
|
48
|
+
expect(dock).toContain("--dv-separator-border: transparent");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("slides only transform and width, 140ms ease-out, and reveals the icon in 80ms", () => {
|
|
52
|
+
expect(bodyOf(".hf-dock-tab-fill[data-ready]")?.replace(/\s+/g, " ").trim()).toBe(
|
|
53
|
+
"transition: transform 140ms ease-out, width 140ms ease-out;",
|
|
54
|
+
);
|
|
55
|
+
expect(bodyOf(".hf-dock-tab-icon")).toContain("animation: hf-dock-tab-icon-in 80ms ease-out");
|
|
56
|
+
const animated = rules(dockCss).filter(
|
|
57
|
+
(rule) => !rule.selector.startsWith("@") && /transition|animation/.test(rule.body),
|
|
58
|
+
);
|
|
59
|
+
expect(animated.map((rule) => rule.selector)).toEqual([
|
|
60
|
+
".hf-dock-tab-fill[data-ready]",
|
|
61
|
+
".hf-dock-tab-icon",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("turns the slide and the icon reveal off under reduced motion", () => {
|
|
66
|
+
const reduced = bodyOf("@media (prefers-reduced-motion: reduce)")?.replace(/\s+/g, " ");
|
|
67
|
+
expect(reduced).toContain(".hf-dock-tab-fill[data-ready] { transition: none; }");
|
|
68
|
+
expect(reduced).toContain(".hf-dock-tab-icon { animation: none; }");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("centres the tab's icon and label in the tab's full height", () => {
|
|
72
|
+
const tab = bodyOf(".hf-dock-tab");
|
|
73
|
+
expect(tab).toContain("height: 100%");
|
|
74
|
+
expect(tab).toContain("align-items: center");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("takes every colour from a token", () => {
|
|
78
|
+
expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/i);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -474,6 +474,106 @@ describe("studio manual edits", () => {
|
|
|
474
474
|
expect(frames).toHaveLength(0);
|
|
475
475
|
});
|
|
476
476
|
|
|
477
|
+
it("does not reapply every frame while the player is paused over unpaused scene timelines", () => {
|
|
478
|
+
const window = new Window();
|
|
479
|
+
const frames: FrameRequestCallback[] = [];
|
|
480
|
+
let playing = false;
|
|
481
|
+
const previewWindow = window as unknown as Parameters<
|
|
482
|
+
typeof installStudioManualEditSeekReapply
|
|
483
|
+
>[0] & {
|
|
484
|
+
__player: Record<string, unknown>;
|
|
485
|
+
__timelines: Record<string, Record<string, unknown>>;
|
|
486
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
487
|
+
};
|
|
488
|
+
previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
|
489
|
+
frames.push(callback);
|
|
490
|
+
return frames.length;
|
|
491
|
+
};
|
|
492
|
+
previewWindow.__player = {
|
|
493
|
+
play: () => {
|
|
494
|
+
playing = true;
|
|
495
|
+
},
|
|
496
|
+
isPlaying: () => playing,
|
|
497
|
+
};
|
|
498
|
+
// A GSAP child of a paused master: not paused itself, inactive, time left.
|
|
499
|
+
const master = { paused: () => true };
|
|
500
|
+
previewWindow.__timelines = {
|
|
501
|
+
scene0: {
|
|
502
|
+
parent: master,
|
|
503
|
+
play: () => {},
|
|
504
|
+
paused: () => false,
|
|
505
|
+
isActive: () => false,
|
|
506
|
+
time: () => 0,
|
|
507
|
+
duration: () => 5,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
expect(installStudioManualEditSeekReapply(previewWindow, () => {})).toBe(true);
|
|
512
|
+
expect(frames).toHaveLength(0);
|
|
513
|
+
|
|
514
|
+
(previewWindow.__player.play as () => void)();
|
|
515
|
+
expect(frames).toHaveLength(1);
|
|
516
|
+
playing = false;
|
|
517
|
+
frames.shift()?.(16);
|
|
518
|
+
expect(frames).toHaveLength(0);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("tolerates a timeline whose parent chain loops", () => {
|
|
522
|
+
const window = new Window();
|
|
523
|
+
const previewWindow = window as unknown as Parameters<
|
|
524
|
+
typeof installStudioManualEditSeekReapply
|
|
525
|
+
>[0] & {
|
|
526
|
+
__timelines: Record<string, Record<string, unknown>>;
|
|
527
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
528
|
+
};
|
|
529
|
+
previewWindow.requestAnimationFrame = () => 1;
|
|
530
|
+
const loop: Record<string, unknown> = { paused: () => false };
|
|
531
|
+
loop.parent = loop;
|
|
532
|
+
previewWindow.__timelines = {
|
|
533
|
+
scene0: {
|
|
534
|
+
parent: loop,
|
|
535
|
+
play: () => {},
|
|
536
|
+
paused: () => false,
|
|
537
|
+
time: () => 0,
|
|
538
|
+
duration: () => 5,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
expect(() => installStudioManualEditSeekReapply(previewWindow, () => {})).not.toThrow();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("keeps reapplying while Studio drives a timeline directly and the player reads idle", () => {
|
|
546
|
+
const window = new Window();
|
|
547
|
+
const frames: FrameRequestCallback[] = [];
|
|
548
|
+
let paused = true;
|
|
549
|
+
const previewWindow = window as unknown as Parameters<
|
|
550
|
+
typeof installStudioManualEditSeekReapply
|
|
551
|
+
>[0] & {
|
|
552
|
+
__player: Record<string, unknown>;
|
|
553
|
+
__timeline: Record<string, unknown>;
|
|
554
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
555
|
+
};
|
|
556
|
+
previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
|
557
|
+
frames.push(callback);
|
|
558
|
+
return frames.length;
|
|
559
|
+
};
|
|
560
|
+
previewWindow.__player = { play: () => {}, isPlaying: () => false };
|
|
561
|
+
previewWindow.__timeline = {
|
|
562
|
+
play: () => {
|
|
563
|
+
paused = false;
|
|
564
|
+
},
|
|
565
|
+
paused: () => paused,
|
|
566
|
+
isActive: () => !paused,
|
|
567
|
+
time: () => 1,
|
|
568
|
+
duration: () => 5,
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
expect(installStudioManualEditSeekReapply(previewWindow, () => {})).toBe(true);
|
|
572
|
+
(previewWindow.__timeline.play as () => void)();
|
|
573
|
+
frames.shift()?.(16);
|
|
574
|
+
expect(frames).toHaveLength(1);
|
|
575
|
+
});
|
|
576
|
+
|
|
477
577
|
it("stops playback reapply after an unpaused timeline has completed", () => {
|
|
478
578
|
const window = new Window();
|
|
479
579
|
const frames: FrameRequestCallback[] = [];
|
|
@@ -120,6 +120,21 @@ function hasRemainingTimelineTime(owner: Record<string, unknown>): boolean {
|
|
|
120
120
|
return time < duration;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
function hasPausedAncestor(owner: Record<string, unknown>): boolean {
|
|
124
|
+
let ancestor = owner.parent;
|
|
125
|
+
for (let depth = 0; depth < 32 && ancestor && typeof ancestor === "object"; depth++) {
|
|
126
|
+
const node = ancestor as Record<string, unknown>;
|
|
127
|
+
const paused = node.paused;
|
|
128
|
+
try {
|
|
129
|
+
if (typeof paused === "function" && paused.call(node)) return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
ancestor = node.parent;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
function isTimelinePlaying(owner: Record<string, unknown> | undefined): boolean {
|
|
124
139
|
if (!owner) return false;
|
|
125
140
|
const isPlaying = owner.isPlaying;
|
|
@@ -148,6 +163,8 @@ function isTimelinePlaying(owner: Record<string, unknown> | undefined): boolean
|
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
// A GSAP child of a paused timeline reads unpaused with time left; only its ancestors know.
|
|
167
|
+
if (hasPausedAncestor(owner)) return false;
|
|
151
168
|
return hasRemainingTimelineTime(owner);
|
|
152
169
|
}
|
|
153
170
|
|
|
@@ -115,10 +115,10 @@ export function PreviewPane({
|
|
|
115
115
|
return (
|
|
116
116
|
<div
|
|
117
117
|
ref={containerRef}
|
|
118
|
-
//
|
|
119
|
-
// fills the screen edge-to-edge.
|
|
118
|
+
// A recessed well inset in its dock card; dropped in fullscreen so the
|
|
119
|
+
// preview fills the screen edge-to-edge.
|
|
120
120
|
className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
|
|
121
|
-
isFullscreen ? "" : "rounded-
|
|
121
|
+
isFullscreen ? "" : "m-3 rounded-md border border-neutral-800/50"
|
|
122
122
|
}`}
|
|
123
123
|
data-studio-fullscreen-target=""
|
|
124
124
|
>
|
|
@@ -85,7 +85,7 @@ export function TimelinePane({
|
|
|
85
85
|
aria-disabled={timelineDisabled || undefined}
|
|
86
86
|
>
|
|
87
87
|
<div
|
|
88
|
-
className="flex flex-col flex-1 min-h-0 overflow-hidden
|
|
88
|
+
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
|
89
89
|
onDoubleClick={(e) => {
|
|
90
90
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
91
91
|
if (timelineDisabled) return;
|
|
@@ -220,6 +220,22 @@ export function MenuRadioItem({ className, children, ...props }: MenuRadioItemPr
|
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/** An on/off row; the tick renders only while `checked`. */
|
|
224
|
+
export function MenuCheckboxItem({
|
|
225
|
+
className,
|
|
226
|
+
children,
|
|
227
|
+
...props
|
|
228
|
+
}: StyledProps<typeof BaseMenu.CheckboxItem>) {
|
|
229
|
+
return (
|
|
230
|
+
<BaseMenu.CheckboxItem className={cn(itemBase, className)} {...props}>
|
|
231
|
+
<span className="truncate">{children}</span>
|
|
232
|
+
<span className="flex size-3 shrink-0 items-center justify-center text-accent">
|
|
233
|
+
<BaseMenu.CheckboxItemIndicator>✓</BaseMenu.CheckboxItemIndicator>
|
|
234
|
+
</span>
|
|
235
|
+
</BaseMenu.CheckboxItem>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
223
239
|
/** A hairline between two groups of items. */
|
|
224
240
|
export function MenuSeparator({ className, ...props }: StyledProps<typeof BaseMenu.Separator>) {
|
|
225
241
|
return <BaseMenu.Separator className={cn("my-1 h-px bg-hairline", className)} {...props} />;
|
package/src/styles/theme.css
CHANGED
|
@@ -273,6 +273,14 @@
|
|
|
273
273
|
--color-panel-media: var(--color-media);
|
|
274
274
|
--color-panel-container: var(--color-container);
|
|
275
275
|
|
|
276
|
+
/* Dock panels are cards on the shell. The card owns the only border; its tab
|
|
277
|
+
* strip sits below the card body and the shown tab is a fill in the body colour. */
|
|
278
|
+
--panel-shell-bg: var(--color-bg-1);
|
|
279
|
+
--panel-card-bg: var(--color-surface);
|
|
280
|
+
--panel-card-border: #303034;
|
|
281
|
+
--panel-strip-bg: var(--color-bg-0);
|
|
282
|
+
--panel-tab-bg-hover: #202023;
|
|
283
|
+
|
|
276
284
|
/* Names Studio's markup invented that no config ever defined, so the classes
|
|
277
285
|
* compiled to nothing. Aliased to the token that was meant, rather than
|
|
278
286
|
* given values of their own, so there is still one source per decision.
|