@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.
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
Composition,
|
|
4
|
+
CompositionVariable,
|
|
5
|
+
VariableUsageReport,
|
|
6
|
+
VariableValidationIssue,
|
|
7
|
+
} from "@hyperframes/sdk";
|
|
8
|
+
import type { EditHistoryKind } from "../../utils/editHistory";
|
|
9
|
+
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
|
|
10
|
+
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
11
|
+
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
12
|
+
import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
|
|
13
|
+
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
|
|
14
|
+
import { VariablesOtherCompositions } from "./VariablesOtherCompositions";
|
|
15
|
+
import { RowAction } from "./VariablesRowAction";
|
|
16
|
+
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
|
|
17
|
+
import {
|
|
18
|
+
DeclarationForm,
|
|
19
|
+
draftFromDeclaration,
|
|
20
|
+
mergeDeclarationEdit,
|
|
21
|
+
EMPTY_DRAFT,
|
|
22
|
+
} from "./VariablesDeclarationForm";
|
|
23
|
+
import { PreviewValueControl } from "./VariablesValueControls";
|
|
24
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
25
|
+
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
|
|
26
|
+
import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
|
|
27
|
+
|
|
28
|
+
/** POSIX single-quote escaping so the copied command survives quotes in values. */
|
|
29
|
+
function shellSingleQuote(value: string): string {
|
|
30
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface VariablesPanelProps {
|
|
34
|
+
sdkSession: Composition | null;
|
|
35
|
+
reloadPreview: () => void;
|
|
36
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
37
|
+
recordEdit: (entry: {
|
|
38
|
+
label: string;
|
|
39
|
+
kind: EditHistoryKind;
|
|
40
|
+
files: Record<string, { before: string; after: string }>;
|
|
41
|
+
}) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatIssue(issue: VariableValidationIssue): string {
|
|
45
|
+
switch (issue.kind) {
|
|
46
|
+
case "undeclared":
|
|
47
|
+
return `"${issue.variableId}" is not declared.`;
|
|
48
|
+
case "type-mismatch":
|
|
49
|
+
return `"${issue.variableId}" expects ${issue.expected}, got ${issue.actual}.`;
|
|
50
|
+
case "enum-out-of-range":
|
|
51
|
+
return `"${issue.variableId}" must be one of: ${issue.allowed.join(", ")}.`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
|
|
56
|
+
if (issues.length === 0) return null;
|
|
57
|
+
return (
|
|
58
|
+
<div className="space-y-1 rounded-lg border border-red-900/60 bg-red-950/30 p-2">
|
|
59
|
+
{issues.map((issue) => (
|
|
60
|
+
<p key={`${issue.kind}:${issue.variableId}`} className="text-[10px] text-red-300">
|
|
61
|
+
{formatIssue(issue)}
|
|
62
|
+
</p>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// fallow-ignore-next-line complexity
|
|
69
|
+
function VariableRow({
|
|
70
|
+
decl,
|
|
71
|
+
value,
|
|
72
|
+
overridden,
|
|
73
|
+
unused,
|
|
74
|
+
editing,
|
|
75
|
+
onCommitPreview,
|
|
76
|
+
onSetDefault,
|
|
77
|
+
onToggleEdit,
|
|
78
|
+
onSaveEdit,
|
|
79
|
+
onRemove,
|
|
80
|
+
}: {
|
|
81
|
+
decl: CompositionVariable;
|
|
82
|
+
value: unknown;
|
|
83
|
+
overridden: boolean;
|
|
84
|
+
unused: boolean;
|
|
85
|
+
editing: boolean;
|
|
86
|
+
onCommitPreview: (value: unknown) => void;
|
|
87
|
+
onSetDefault: (value: string | number | boolean) => void;
|
|
88
|
+
onToggleEdit: () => void;
|
|
89
|
+
onSaveEdit: (decl: CompositionVariable) => void;
|
|
90
|
+
onRemove: () => void;
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
|
|
94
|
+
<div className="flex items-center gap-1.5">
|
|
95
|
+
<span className="truncate text-[10px] font-medium text-neutral-300">{decl.label}</span>
|
|
96
|
+
<span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
|
|
97
|
+
{decl.type}
|
|
98
|
+
</span>
|
|
99
|
+
{unused && (
|
|
100
|
+
<span
|
|
101
|
+
className="rounded bg-amber-900/40 px-1 py-px text-[8px] text-amber-400"
|
|
102
|
+
title="No script reads this variable"
|
|
103
|
+
>
|
|
104
|
+
unused
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
{overridden && <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" />}
|
|
108
|
+
<span className="ml-auto flex items-center gap-1">
|
|
109
|
+
{overridden && isScalar(value) && (
|
|
110
|
+
<RowAction
|
|
111
|
+
label="Set default"
|
|
112
|
+
title="Persist this value as the declared default"
|
|
113
|
+
onClick={() => onSetDefault(value)}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
<RowAction label="Edit" title="Edit declaration" onClick={onToggleEdit} />
|
|
117
|
+
<RowAction label="✕" title="Remove declaration" danger onClick={onRemove} />
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
{decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
|
|
121
|
+
{editing ? (
|
|
122
|
+
<DeclarationForm
|
|
123
|
+
initial={draftFromDeclaration(decl)}
|
|
124
|
+
submitLabel="Save"
|
|
125
|
+
onSubmit={(edited) => onSaveEdit(mergeDeclarationEdit(decl, edited))}
|
|
126
|
+
onCancel={onToggleEdit}
|
|
127
|
+
/>
|
|
128
|
+
) : (
|
|
129
|
+
<PreviewValueControl decl={decl} value={value} onCommit={onCommitPreview} />
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function UndeclaredReads({
|
|
136
|
+
usage,
|
|
137
|
+
onDeclare,
|
|
138
|
+
}: {
|
|
139
|
+
usage: VariableUsageReport | null;
|
|
140
|
+
onDeclare: (id: string) => void;
|
|
141
|
+
}) {
|
|
142
|
+
if (!usage || usage.undeclaredReads.length === 0) return null;
|
|
143
|
+
return (
|
|
144
|
+
<div className="space-y-1 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
|
|
145
|
+
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
|
|
146
|
+
Read by scripts, not declared
|
|
147
|
+
</p>
|
|
148
|
+
{usage.undeclaredReads.map((id) => (
|
|
149
|
+
<div key={id} className="flex items-center gap-2">
|
|
150
|
+
<code className="font-mono text-[10px] text-neutral-400">{id}</code>
|
|
151
|
+
<RowAction
|
|
152
|
+
label="Declare"
|
|
153
|
+
title="Declare as a string variable"
|
|
154
|
+
onClick={() => onDeclare(id)}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Preview-state pill + reset, shown in the panel header. */
|
|
163
|
+
function PreviewModeHeader({
|
|
164
|
+
overrideCount,
|
|
165
|
+
onReset,
|
|
166
|
+
}: {
|
|
167
|
+
overrideCount: number;
|
|
168
|
+
onReset: () => void;
|
|
169
|
+
}) {
|
|
170
|
+
const hasOverrides = overrideCount > 0;
|
|
171
|
+
return (
|
|
172
|
+
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
|
173
|
+
<div className="flex items-center gap-2">
|
|
174
|
+
<span className="text-[11px] font-semibold text-neutral-200">Variables</span>
|
|
175
|
+
<span
|
|
176
|
+
className={`rounded-full px-2 py-0.5 text-[9px] font-medium ${
|
|
177
|
+
hasOverrides
|
|
178
|
+
? "bg-studio-accent/20 text-studio-accent"
|
|
179
|
+
: "bg-neutral-800 text-neutral-500"
|
|
180
|
+
}`}
|
|
181
|
+
>
|
|
182
|
+
{hasOverrides ? `Previewing ${overrideCount} custom` : "Previewing defaults"}
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
{hasOverrides && (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={onReset}
|
|
189
|
+
className="h-6 rounded px-2 text-[10px] text-neutral-400 hover:text-neutral-200"
|
|
190
|
+
>
|
|
191
|
+
Reset
|
|
192
|
+
</button>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Developer/agent handoff: copy the effective values as JSON or as a
|
|
200
|
+
* ready-to-run render command mirroring exactly what the preview shows.
|
|
201
|
+
*/
|
|
202
|
+
function HandoffFooter({
|
|
203
|
+
effectiveValues,
|
|
204
|
+
compPath,
|
|
205
|
+
onCopy,
|
|
206
|
+
}: {
|
|
207
|
+
effectiveValues: Record<string, unknown>;
|
|
208
|
+
compPath: string;
|
|
209
|
+
onCopy: (text: string, what: string) => void;
|
|
210
|
+
}) {
|
|
211
|
+
const json = JSON.stringify(effectiveValues);
|
|
212
|
+
const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`;
|
|
213
|
+
return (
|
|
214
|
+
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
|
|
215
|
+
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
|
|
216
|
+
Use this template
|
|
217
|
+
</p>
|
|
218
|
+
<code className="block truncate font-mono text-[9px] text-neutral-500" title={command}>
|
|
219
|
+
{command}
|
|
220
|
+
</code>
|
|
221
|
+
<div className="flex items-center gap-2">
|
|
222
|
+
<RowAction
|
|
223
|
+
label="Copy render command"
|
|
224
|
+
title="CLI command rendering exactly what the preview shows"
|
|
225
|
+
onClick={() => onCopy(command, "Render command")}
|
|
226
|
+
/>
|
|
227
|
+
<RowAction
|
|
228
|
+
label="Copy values JSON"
|
|
229
|
+
title="Effective values (defaults merged with preview overrides)"
|
|
230
|
+
onClick={() => onCopy(json, "Values JSON")}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const EMPTY_STATE = (
|
|
238
|
+
<p className="text-[10px] leading-relaxed text-neutral-500">
|
|
239
|
+
No variables declared. Variables make parts of this composition dynamic — declare them here (or
|
|
240
|
+
in <code className="font-mono">data-composition-variables</code>), read them with{" "}
|
|
241
|
+
<code className="font-mono">getVariables()</code>, and pass values at render time with{" "}
|
|
242
|
+
<code className="font-mono">--variables</code>.
|
|
243
|
+
</p>
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
|
|
247
|
+
// fallow-ignore-next-line complexity
|
|
248
|
+
export const VariablesPanel = memo(function VariablesPanel({
|
|
249
|
+
sdkSession,
|
|
250
|
+
reloadPreview,
|
|
251
|
+
domEditSaveTimestampRef,
|
|
252
|
+
recordEdit,
|
|
253
|
+
}: VariablesPanelProps) {
|
|
254
|
+
const { activeCompPath, showToast } = useStudioShellContext();
|
|
255
|
+
const { refreshKey } = useStudioPlaybackContext();
|
|
256
|
+
const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
|
|
257
|
+
const { domEditSelection } = useDomEditContext();
|
|
258
|
+
// On the master view (no activeCompPath) the panel targets the project's real
|
|
259
|
+
// main composition — the first .html in the tree — not a hardcoded index.html
|
|
260
|
+
// that may not exist. This same path is used for the persist write target (so
|
|
261
|
+
// an edit never lands in a phantom index.html) AND the handoff render command.
|
|
262
|
+
// Null only when the project has no composition yet, in which case sdkSession
|
|
263
|
+
// is also null and the panel is inert.
|
|
264
|
+
const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree);
|
|
265
|
+
const previewValues = usePreviewVariablesStore((s) => s.values);
|
|
266
|
+
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
|
|
267
|
+
|
|
268
|
+
// Bumped after each persisted schema edit so declarations re-derive without
|
|
269
|
+
// waiting for the session reload round-trip.
|
|
270
|
+
const [revision, setRevision] = useState(0);
|
|
271
|
+
// Also bump on any session mutation (undo/redo, edits dispatched by other
|
|
272
|
+
// panels or agents) — the memos below must never trust refreshKey alone.
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!sdkSession) return;
|
|
275
|
+
return sdkSession.on("change", () => setRevision((r) => r + 1));
|
|
276
|
+
}, [sdkSession]);
|
|
277
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
278
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
279
|
+
|
|
280
|
+
const persistVariables = useVariablesPersist({
|
|
281
|
+
sdkSession,
|
|
282
|
+
activeCompPath: effectiveCompPath,
|
|
283
|
+
readProjectFile,
|
|
284
|
+
writeProjectFile,
|
|
285
|
+
recordEdit,
|
|
286
|
+
reloadPreview,
|
|
287
|
+
domEditSaveTimestampRef,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const declarations = useMemo(
|
|
291
|
+
() => sdkSession?.getVariableDeclarations() ?? [],
|
|
292
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
293
|
+
[sdkSession, refreshKey, revision],
|
|
294
|
+
);
|
|
295
|
+
const usage = useMemo(
|
|
296
|
+
() => sdkSession?.getVariableUsage() ?? null,
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
+
[sdkSession, refreshKey, revision],
|
|
299
|
+
);
|
|
300
|
+
const issues = useMemo(
|
|
301
|
+
() => (previewValues && sdkSession ? sdkSession.validateVariableValues(previewValues) : []),
|
|
302
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
303
|
+
[sdkSession, previewValues, refreshKey, revision],
|
|
304
|
+
);
|
|
305
|
+
const effectiveValues = useMemo(
|
|
306
|
+
() => sdkSession?.getVariableValues(previewValues ?? undefined) ?? {},
|
|
307
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
308
|
+
[sdkSession, previewValues, refreshKey, revision],
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const copyToClipboard = useCallback(
|
|
312
|
+
(text: string, what: string) => {
|
|
313
|
+
// Shared helper carries the execCommand fallback Safari needs.
|
|
314
|
+
void copyTextToClipboard(text).then((ok) =>
|
|
315
|
+
showToast(
|
|
316
|
+
ok ? `${what} copied` : `Couldn't copy ${what.toLowerCase()}`,
|
|
317
|
+
ok ? "info" : "error",
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
},
|
|
321
|
+
[showToast],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const dropPreviewOverride = useCallback(
|
|
325
|
+
(id: string) => {
|
|
326
|
+
if (previewValues && id in previewValues) {
|
|
327
|
+
const next = { ...previewValues };
|
|
328
|
+
delete next[id];
|
|
329
|
+
setPreviewValues(next);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
[previewValues, setPreviewValues],
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const commitPreviewValue = useCallback(
|
|
336
|
+
(id: string, value: unknown, declDefault: unknown) => {
|
|
337
|
+
const next = { ...(previewValues ?? {}) };
|
|
338
|
+
if (JSON.stringify(value) === JSON.stringify(declDefault)) {
|
|
339
|
+
delete next[id];
|
|
340
|
+
} else {
|
|
341
|
+
next[id] = value;
|
|
342
|
+
}
|
|
343
|
+
setPreviewValues(next);
|
|
344
|
+
reloadPreview();
|
|
345
|
+
},
|
|
346
|
+
[previewValues, setPreviewValues, reloadPreview],
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const runSchemaEdit = useCallback(
|
|
350
|
+
async (label: string, mutate: (session: Composition) => void): Promise<boolean> => {
|
|
351
|
+
try {
|
|
352
|
+
const changed = await persistVariables(label, mutate);
|
|
353
|
+
if (changed) setRevision((r) => r + 1);
|
|
354
|
+
else showToast(`${label}: no change applied`, "info");
|
|
355
|
+
return changed;
|
|
356
|
+
} catch (err) {
|
|
357
|
+
showToast(err instanceof Error ? err.message : String(err), "error");
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
[persistVariables, showToast],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const handleAdd = useCallback(
|
|
365
|
+
(decl: CompositionVariable) => {
|
|
366
|
+
if (!sdkSession) return;
|
|
367
|
+
const check = sdkSession.can({ type: "declareVariable", declaration: decl });
|
|
368
|
+
if (!check.ok) {
|
|
369
|
+
showToast(check.message, "error");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
setAddOpen(false);
|
|
373
|
+
void runSchemaEdit(`Declare variable "${decl.id}"`, (s) => s.declareVariable(decl));
|
|
374
|
+
},
|
|
375
|
+
[sdkSession, runSchemaEdit, showToast],
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const handleUpdate = useCallback(
|
|
379
|
+
(decl: CompositionVariable) => {
|
|
380
|
+
if (!sdkSession) return;
|
|
381
|
+
const check = sdkSession.can({
|
|
382
|
+
type: "updateVariableDeclaration",
|
|
383
|
+
id: decl.id,
|
|
384
|
+
declaration: decl,
|
|
385
|
+
});
|
|
386
|
+
if (!check.ok) {
|
|
387
|
+
showToast(check.message, "error");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
setEditingId(null);
|
|
391
|
+
void runSchemaEdit(`Edit variable "${decl.id}"`, (s) =>
|
|
392
|
+
s.updateVariableDeclaration(decl.id, decl),
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
[sdkSession, runSchemaEdit, showToast],
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const handleRemove = useCallback(
|
|
399
|
+
(id: string) => {
|
|
400
|
+
if (!sdkSession) return;
|
|
401
|
+
const check = sdkSession.can({ type: "removeVariableDeclaration", id });
|
|
402
|
+
if (!check.ok) {
|
|
403
|
+
showToast(check.message, "error");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Drop the preview override only if the declaration was actually removed —
|
|
407
|
+
// otherwise a rejected/failed edit would leave the row on disk but silently
|
|
408
|
+
// wipe the user's custom preview value.
|
|
409
|
+
void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id)).then(
|
|
410
|
+
(changed) => {
|
|
411
|
+
if (changed) dropPreviewOverride(id);
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
},
|
|
415
|
+
[sdkSession, runSchemaEdit, dropPreviewOverride, showToast],
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const handleSetDefault = useCallback(
|
|
419
|
+
(id: string, value: string | number | boolean) => {
|
|
420
|
+
void runSchemaEdit(`Set default for "${id}"`, (s) => s.setVariableValue(id, value));
|
|
421
|
+
// The override now equals the persisted default — drop it from preview state.
|
|
422
|
+
dropPreviewOverride(id);
|
|
423
|
+
},
|
|
424
|
+
[runSchemaEdit, dropPreviewOverride],
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const resetPreview = useCallback(() => {
|
|
428
|
+
setPreviewValues(null);
|
|
429
|
+
reloadPreview();
|
|
430
|
+
}, [setPreviewValues, reloadPreview]);
|
|
431
|
+
|
|
432
|
+
const handleBind = useCallback(
|
|
433
|
+
// Guard chain (session, selection, type-compat) — one branch per guard.
|
|
434
|
+
// fallow-ignore-next-line complexity
|
|
435
|
+
(action: BindAction, id: string) => {
|
|
436
|
+
if (!sdkSession || !domEditSelection?.hfId) return;
|
|
437
|
+
// Binding to an existing variable is allowed, but only when the types
|
|
438
|
+
// agree — wiring a color style to a string variable silently breaks
|
|
439
|
+
// the element's styling.
|
|
440
|
+
const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id);
|
|
441
|
+
const wanted = action.declaration(id).type;
|
|
442
|
+
if (existing && existing.type !== wanted) {
|
|
443
|
+
showToast(
|
|
444
|
+
`"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`,
|
|
445
|
+
"error",
|
|
446
|
+
);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const hfId = domEditSelection.hfId;
|
|
450
|
+
void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) =>
|
|
451
|
+
applyBind(s, hfId, action, id),
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
[sdkSession, domEditSelection, runSchemaEdit, showToast],
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// The bind gesture targets the composition the session models — a selection
|
|
458
|
+
// from another source file must not write bindings into this one.
|
|
459
|
+
const bindableSelection =
|
|
460
|
+
domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html")
|
|
461
|
+
? domEditSelection
|
|
462
|
+
: null;
|
|
463
|
+
|
|
464
|
+
if (!sdkSession) {
|
|
465
|
+
return (
|
|
466
|
+
<div className="flex h-full items-center justify-center px-6 text-center">
|
|
467
|
+
<p className="text-xs text-neutral-500">Open a composition to manage its variables.</p>
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<div className="flex h-full flex-col">
|
|
474
|
+
<PreviewModeHeader
|
|
475
|
+
overrideCount={previewValues ? Object.keys(previewValues).length : 0}
|
|
476
|
+
onReset={resetPreview}
|
|
477
|
+
/>
|
|
478
|
+
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
|
479
|
+
{bindableSelection && (
|
|
480
|
+
<VariablesBindElement
|
|
481
|
+
key={bindableSelection.hfId}
|
|
482
|
+
selection={bindableSelection}
|
|
483
|
+
sdkSession={sdkSession}
|
|
484
|
+
onBind={handleBind}
|
|
485
|
+
/>
|
|
486
|
+
)}
|
|
487
|
+
<ValidationStrip issues={issues} />
|
|
488
|
+
{declarations.length === 0 && !addOpen && EMPTY_STATE}
|
|
489
|
+
{/* fallow-ignore-next-line complexity */}
|
|
490
|
+
{declarations.map((decl) => (
|
|
491
|
+
<VariableRow
|
|
492
|
+
key={decl.id}
|
|
493
|
+
decl={decl}
|
|
494
|
+
value={
|
|
495
|
+
previewValues && decl.id in previewValues ? previewValues[decl.id] : decl.default
|
|
496
|
+
}
|
|
497
|
+
overridden={previewValues !== null && decl.id in previewValues}
|
|
498
|
+
unused={
|
|
499
|
+
usage !== null && !usage.scanIncomplete && usage.unusedDeclarations.includes(decl.id)
|
|
500
|
+
}
|
|
501
|
+
editing={editingId === decl.id}
|
|
502
|
+
onCommitPreview={(v) => commitPreviewValue(decl.id, v, decl.default)}
|
|
503
|
+
onSetDefault={(v) => handleSetDefault(decl.id, v)}
|
|
504
|
+
onToggleEdit={() => setEditingId(editingId === decl.id ? null : decl.id)}
|
|
505
|
+
onSaveEdit={handleUpdate}
|
|
506
|
+
onRemove={() => handleRemove(decl.id)}
|
|
507
|
+
/>
|
|
508
|
+
))}
|
|
509
|
+
<UndeclaredReads
|
|
510
|
+
usage={usage}
|
|
511
|
+
onDeclare={(id) => handleAdd({ id, type: "string", label: id, default: "" })}
|
|
512
|
+
/>
|
|
513
|
+
{usage?.scanIncomplete && (
|
|
514
|
+
<p className="text-[9px] text-neutral-600">
|
|
515
|
+
Scripts access variables dynamically — usage info may be incomplete.
|
|
516
|
+
</p>
|
|
517
|
+
)}
|
|
518
|
+
{declarations.length > 0 && (
|
|
519
|
+
<HandoffFooter
|
|
520
|
+
effectiveValues={effectiveValues}
|
|
521
|
+
compPath={effectiveCompPath ?? "index.html"}
|
|
522
|
+
onCopy={copyToClipboard}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
525
|
+
{addOpen ? (
|
|
526
|
+
<DeclarationForm
|
|
527
|
+
initial={EMPTY_DRAFT}
|
|
528
|
+
submitLabel="Add variable"
|
|
529
|
+
onSubmit={handleAdd}
|
|
530
|
+
onCancel={() => setAddOpen(false)}
|
|
531
|
+
/>
|
|
532
|
+
) : (
|
|
533
|
+
<button
|
|
534
|
+
type="button"
|
|
535
|
+
onClick={() => setAddOpen(true)}
|
|
536
|
+
className="h-7 w-full rounded-lg border border-dashed border-neutral-800 text-[10px] font-medium text-neutral-500 transition-colors hover:border-neutral-700 hover:text-neutral-300"
|
|
537
|
+
>
|
|
538
|
+
+ Add variable
|
|
539
|
+
</button>
|
|
540
|
+
)}
|
|
541
|
+
<VariablesOtherCompositions
|
|
542
|
+
fileTree={fileTree}
|
|
543
|
+
excludePath={activeCompPath ?? "index.html"}
|
|
544
|
+
refreshKey={`${refreshKey}:${revision}`}
|
|
545
|
+
readProjectFile={readProjectFile}
|
|
546
|
+
writeProjectFile={writeProjectFile}
|
|
547
|
+
recordEdit={recordEdit}
|
|
548
|
+
reloadPreview={reloadPreview}
|
|
549
|
+
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
550
|
+
/>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Small text-button used in the Variables tab rows (Edit / Remove / Set default / Declare). */
|
|
2
|
+
export function RowAction({
|
|
3
|
+
label,
|
|
4
|
+
title,
|
|
5
|
+
danger,
|
|
6
|
+
onClick,
|
|
7
|
+
}: {
|
|
8
|
+
label: string;
|
|
9
|
+
title: string;
|
|
10
|
+
danger?: boolean;
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={onClick}
|
|
17
|
+
title={title}
|
|
18
|
+
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
|
|
19
|
+
danger ? "hover:text-red-400" : "hover:text-neutral-200"
|
|
20
|
+
}`}
|
|
21
|
+
>
|
|
22
|
+
{label}
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|