@hyperframes/studio 0.7.46 → 0.7.47

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.
Files changed (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -0,0 +1,266 @@
1
+ /**
2
+ * "Bind selected element" card for the Variables panel — the promote-a-
3
+ * property-to-variable gesture. Each action declares a variable whose default
4
+ * is the element's current value, so binding to a NEW id never changes the
5
+ * render. Binding to an EXISTING id instead wires the element to that
6
+ * variable's current default (which came from another element) — the card
7
+ * warns before committing, since that does change the render. The binding the
8
+ * runtime resolves is written as a data-var-src / data-var-text attribute, or
9
+ * a `<prop>: var(--id)` style.
10
+ */
11
+
12
+ import { useMemo, useState } from "react";
13
+ import type { Composition, CompositionVariable } from "@hyperframes/sdk";
14
+ import type { DomEditSelection } from "../editor/domEditingTypes";
15
+
16
+ import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
17
+
18
+ // <source> is deliberately excluded: rewriting a <source> child's src after
19
+ // the parent media element ran resource selection is a spec no-op.
20
+ const MEDIA_TAGS = new Set(["img", "video", "audio"]);
21
+
22
+ export interface BindAction {
23
+ key: string;
24
+ label: string;
25
+ /** Binding channel: data-var-src / data-var-text attribute, or a style prop. */
26
+ kind: "src" | "text" | "style";
27
+ styleProp?: string;
28
+ suggestedId: string;
29
+ declaration: (id: string) => CompositionVariable;
30
+ }
31
+
32
+ function sanitizeId(raw: string): string {
33
+ const cleaned = raw
34
+ .trim()
35
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")
36
+ .replace(/^-+|-+$/g, "");
37
+ return cleaned || "variable";
38
+ }
39
+
40
+ /**
41
+ * "rgb(0, 195, 255)" / "rgba(0, 195, 255, 0.4)" → "#00c3ff". Alpha is dropped
42
+ * (color variables are hex) — a fully transparent computed color maps to
43
+ * #000000, which the picker can at least display. Unrecognized formats pass
44
+ * through verbatim.
45
+ */
46
+ export function rgbToHex(value: string): string {
47
+ const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value);
48
+ if (!m) return value;
49
+ return `#${m
50
+ .slice(1, 4)
51
+ .map((n) => Number(n).toString(16).padStart(2, "0"))
52
+ .join("")}`;
53
+ }
54
+
55
+ function firstFontFamily(value: string): string {
56
+ const first = value.split(",")[0] ?? "";
57
+ return first.trim().replace(/^["']|["']$/g, "") || "sans-serif";
58
+ }
59
+
60
+ // fallow-ignore-next-line complexity
61
+ export function buildBindActions(
62
+ selection: DomEditSelection,
63
+ sdkSession: Composition,
64
+ ): BindAction[] {
65
+ const hfId = selection.hfId;
66
+ if (!hfId) return [];
67
+ // No snapshot = the session can't resolve this element (e.g. a sub-comp
68
+ // element that missed the source map) — a bind would target the wrong
69
+ // document or dead-end, so offer nothing.
70
+ const snapshot = sdkSession.getElement(hfId);
71
+ if (!snapshot) return [];
72
+ const base = sanitizeId(snapshot.attributes.id ?? selection.label ?? hfId);
73
+ const actions: BindAction[] = [];
74
+
75
+ const tag = selection.tagName.toLowerCase();
76
+ if (MEDIA_TAGS.has(tag)) {
77
+ const currentSrc = snapshot.attributes.src ?? "";
78
+ actions.push({
79
+ key: "src",
80
+ label: tag === "img" ? "Image source" : "Media source",
81
+ kind: "src",
82
+ suggestedId: base,
83
+ declaration: (id) =>
84
+ tag === "img"
85
+ ? { id, type: "image", label: `${selection.label} image`, default: currentSrc }
86
+ : { id, type: "string", label: `${selection.label} source`, default: currentSrc },
87
+ });
88
+ }
89
+
90
+ // Text binds only on leaf elements: the runtime preserves children, but a
91
+ // container's "own text" default rarely matches what the user sees, so the
92
+ // promote-never-changes-the-render guarantee only holds for leaves.
93
+ const text = (snapshot.text ?? "").trim();
94
+ if (text && snapshot.children.length === 0 && !selection.isCompositionHost) {
95
+ actions.push({
96
+ key: "text",
97
+ label: "Text",
98
+ kind: "text",
99
+ suggestedId: `${base}-text`,
100
+ declaration: (id) => ({
101
+ id,
102
+ type: "string",
103
+ label: `${selection.label} text`,
104
+ default: text,
105
+ }),
106
+ });
107
+ }
108
+
109
+ if (selection.capabilities.canEditStyles) {
110
+ const computed = selection.computedStyles;
111
+ actions.push(
112
+ {
113
+ key: "color",
114
+ label: "Text color",
115
+ kind: "style",
116
+ styleProp: "color",
117
+ suggestedId: `${base}-color`,
118
+ declaration: (id) => ({
119
+ id,
120
+ type: "color",
121
+ label: `${selection.label} color`,
122
+ default: rgbToHex(computed["color"] ?? "#000000"),
123
+ }),
124
+ },
125
+ {
126
+ key: "background",
127
+ label: "Background",
128
+ kind: "style",
129
+ styleProp: "background-color",
130
+ suggestedId: `${base}-bg`,
131
+ declaration: (id) => ({
132
+ id,
133
+ type: "color",
134
+ label: `${selection.label} background`,
135
+ default: rgbToHex(computed["background-color"] ?? "#000000"),
136
+ }),
137
+ },
138
+ {
139
+ key: "font",
140
+ label: "Font",
141
+ kind: "style",
142
+ styleProp: "font-family",
143
+ suggestedId: `${base}-font`,
144
+ declaration: (id) => ({
145
+ id,
146
+ type: "font",
147
+ label: `${selection.label} font`,
148
+ default: firstFontFamily(computed["font-family"] ?? "sans-serif"),
149
+ }),
150
+ },
151
+ );
152
+ }
153
+
154
+ return actions;
155
+ }
156
+
157
+ /** One batched schema edit: declare (unless the id already exists) + bind. */
158
+ export function applyBind(
159
+ session: Composition,
160
+ hfId: string,
161
+ action: BindAction,
162
+ id: string,
163
+ ): void {
164
+ session.batch(() => {
165
+ if (!session.getVariableDeclarations().some((d) => d.id === id)) {
166
+ session.declareVariable(action.declaration(id));
167
+ }
168
+ if (action.kind === "style" && action.styleProp) {
169
+ session.setStyle(hfId, { [action.styleProp]: `var(--${id})` });
170
+ } else {
171
+ session.setAttribute(hfId, `data-var-${action.kind}`, id);
172
+ }
173
+ });
174
+ }
175
+
176
+ export function VariablesBindElement({
177
+ selection,
178
+ sdkSession,
179
+ onBind,
180
+ }: {
181
+ selection: DomEditSelection;
182
+ sdkSession: Composition;
183
+ onBind: (action: BindAction, id: string) => void;
184
+ }) {
185
+ const [activeKey, setActiveKey] = useState<string | null>(null);
186
+ const [idDraft, setIdDraft] = useState("");
187
+ const actions = useMemo(() => buildBindActions(selection, sdkSession), [selection, sdkSession]);
188
+ if (actions.length === 0) return null;
189
+ const active = actions.find((a) => a.key === activeKey) ?? null;
190
+
191
+ const trimmedId = sanitizeId(idDraft);
192
+ const idIsEmpty = idDraft.trim().length === 0;
193
+ // Binding to an id that already exists wires the element to THAT variable's
194
+ // existing default (declared from a different element), so the render changes
195
+ // silently. Surface it before the user commits rather than after.
196
+ const existingDecl = active
197
+ ? sdkSession.getVariableDeclarations().find((d) => d.id === trimmedId)
198
+ : undefined;
199
+
200
+ return (
201
+ <div className="space-y-1.5 rounded-lg border border-studio-accent/30 bg-neutral-900/40 p-2">
202
+ <p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
203
+ Bind selected: <span className="normal-case text-neutral-300">{selection.label}</span>
204
+ </p>
205
+ {active ? (
206
+ <div className="space-y-1.5">
207
+ <label className="text-[9px] font-medium text-neutral-500">
208
+ Variable id for {active.label.toLowerCase()}
209
+ </label>
210
+ <input
211
+ type="text"
212
+ value={idDraft}
213
+ onChange={(e) => setIdDraft(e.target.value)}
214
+ onKeyDown={(e) => e.key === "Escape" && setActiveKey(null)}
215
+ className={`${VARIABLES_INPUT_CLASS} font-mono`}
216
+ />
217
+ {existingDecl && (
218
+ <p className="text-[9px] leading-snug text-amber-400/90">
219
+ "{trimmedId}" already exists. This element will use its current value
220
+ {existingDecl.default !== undefined && (
221
+ <span className="font-mono"> ({String(existingDecl.default)})</span>
222
+ )}
223
+ , not the element's own — binding won't change "{trimmedId}".
224
+ </p>
225
+ )}
226
+ <div className="flex items-center justify-end gap-2">
227
+ <button
228
+ type="button"
229
+ onClick={() => setActiveKey(null)}
230
+ className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
231
+ >
232
+ Cancel
233
+ </button>
234
+ <button
235
+ type="button"
236
+ disabled={idIsEmpty}
237
+ onClick={() => {
238
+ setActiveKey(null);
239
+ onBind(active, trimmedId);
240
+ }}
241
+ className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40"
242
+ >
243
+ {existingDecl ? "Bind anyway" : "Bind"}
244
+ </button>
245
+ </div>
246
+ </div>
247
+ ) : (
248
+ <div className="flex flex-wrap gap-1.5">
249
+ {actions.map((action) => (
250
+ <button
251
+ key={action.key}
252
+ type="button"
253
+ onClick={() => {
254
+ setActiveKey(action.key);
255
+ setIdDraft(action.suggestedId);
256
+ }}
257
+ className="h-6 rounded-md border border-neutral-800 px-2 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
258
+ >
259
+ {action.label} →&nbsp;variable
260
+ </button>
261
+ ))}
262
+ </div>
263
+ )}
264
+ </div>
265
+ );
266
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure form-logic guards for the Variables declaration form:
3
+ * mergeDeclarationEdit (unmodeled-key passthrough on same-type edits) and
4
+ * declarationFromDraft (per-type parsing + validation).
5
+ */
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import {
9
+ mergeDeclarationEdit,
10
+ declarationFromDraft,
11
+ draftFromDeclaration,
12
+ EMPTY_DRAFT,
13
+ } from "./VariablesDeclarationForm.js";
14
+ import type { CompositionVariable } from "@hyperframes/core/variables";
15
+
16
+ describe("mergeDeclarationEdit", () => {
17
+ it("preserves unmodeled keys on a same-type edit", () => {
18
+ // The form owns id/label/type/default/description/min/max/step/options; every
19
+ // other key (source, brandRole, unit, maxLength, …) must ride through a Save.
20
+ const original = {
21
+ id: "brand",
22
+ type: "font",
23
+ label: "Brand font",
24
+ default: "Inter",
25
+ source: "brand-kit",
26
+ default_name: "Inter",
27
+ brandRole: "heading",
28
+ } as unknown as CompositionVariable;
29
+ const edited = {
30
+ id: "brand",
31
+ type: "font",
32
+ label: "Brand heading font",
33
+ default: "Inter",
34
+ } as CompositionVariable;
35
+
36
+ const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
37
+ expect(merged.label).toBe("Brand heading font");
38
+ expect(merged.source).toBe("brand-kit");
39
+ expect(merged.default_name).toBe("Inter");
40
+ expect(merged.brandRole).toBe("heading");
41
+ });
42
+
43
+ it("drops old type-specific metadata when the type changes", () => {
44
+ const original = {
45
+ id: "count",
46
+ type: "number",
47
+ label: "Count",
48
+ default: 3,
49
+ min: 0,
50
+ max: 10,
51
+ } as CompositionVariable;
52
+ const edited = {
53
+ id: "count",
54
+ type: "string",
55
+ label: "Count",
56
+ default: "3",
57
+ } as CompositionVariable;
58
+
59
+ const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
60
+ expect(merged).toEqual(edited);
61
+ expect(merged.min).toBeUndefined();
62
+ expect(merged.max).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("declarationFromDraft", () => {
67
+ it("requires a non-empty id", () => {
68
+ expect(declarationFromDraft({ ...EMPTY_DRAFT, id: " " })).toBe("Variable id is required.");
69
+ });
70
+
71
+ it("parses a string variable verbatim and defaults label to id", () => {
72
+ const decl = declarationFromDraft({ ...EMPTY_DRAFT, id: "title", defaultRaw: "Hello" });
73
+ expect(decl).toEqual({ id: "title", label: "title", type: "string", default: "Hello" });
74
+ });
75
+
76
+ it("rejects a non-numeric number default", () => {
77
+ expect(
78
+ declarationFromDraft({ ...EMPTY_DRAFT, id: "n", type: "number", defaultRaw: "abc" }),
79
+ ).toBe("Default must be a number.");
80
+ });
81
+
82
+ it("parses a number variable with min/max/step and drops blank constraints", () => {
83
+ const decl = declarationFromDraft({
84
+ ...EMPTY_DRAFT,
85
+ id: "n",
86
+ type: "number",
87
+ defaultRaw: "5",
88
+ min: "0",
89
+ max: "",
90
+ step: "0.5",
91
+ });
92
+ expect(decl).toEqual({ id: "n", label: "n", type: "number", default: 5, min: 0, step: 0.5 });
93
+ });
94
+
95
+ it("rejects an enum with no options and an off-list default", () => {
96
+ expect(declarationFromDraft({ ...EMPTY_DRAFT, id: "e", type: "enum", optionsRaw: "" })).toBe(
97
+ "Enum needs at least one option (one per line, value:Label).",
98
+ );
99
+ expect(
100
+ declarationFromDraft({
101
+ ...EMPTY_DRAFT,
102
+ id: "e",
103
+ type: "enum",
104
+ optionsRaw: "wide:Wide\ntall:Tall",
105
+ defaultRaw: "square",
106
+ }),
107
+ ).toBe("Default must be one of the options.");
108
+ });
109
+
110
+ it("parses a boolean from the 'true' sentinel", () => {
111
+ expect(
112
+ declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "true" }),
113
+ ).toMatchObject({
114
+ type: "boolean",
115
+ default: true,
116
+ });
117
+ expect(
118
+ declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "" }),
119
+ ).toMatchObject({
120
+ default: false,
121
+ });
122
+ });
123
+
124
+ it("round-trips a declaration through draftFromDeclaration → declarationFromDraft", () => {
125
+ const original: CompositionVariable = {
126
+ id: "count",
127
+ type: "number",
128
+ label: "Count",
129
+ default: 3,
130
+ min: 0,
131
+ max: 10,
132
+ };
133
+ expect(declarationFromDraft(draftFromDeclaration(original))).toEqual(original);
134
+ });
135
+ });