@hyperframes/studio 0.7.46 → 0.7.48

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-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.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,321 @@
1
+ /**
2
+ * Add/edit form for a variable declaration. Builds a typed
3
+ * CompositionVariable from free-text drafts; structural validation beyond
4
+ * the field-level checks here is the SDK's job (can() on dispatch).
5
+ */
6
+
7
+ import { useState } from "react";
8
+ import type { CompositionVariable, CompositionVariableType } from "@hyperframes/sdk";
9
+ import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
10
+
11
+ const VARIABLE_TYPES: CompositionVariableType[] = [
12
+ "string",
13
+ "number",
14
+ "color",
15
+ "boolean",
16
+ "enum",
17
+ "font",
18
+ "image",
19
+ ];
20
+
21
+ export interface DeclarationDraft {
22
+ id: string;
23
+ label: string;
24
+ type: CompositionVariableType;
25
+ defaultRaw: string;
26
+ description: string;
27
+ min: string;
28
+ max: string;
29
+ step: string;
30
+ optionsRaw: string;
31
+ }
32
+
33
+ export const EMPTY_DRAFT: DeclarationDraft = {
34
+ id: "",
35
+ label: "",
36
+ type: "string",
37
+ defaultRaw: "",
38
+ description: "",
39
+ min: "",
40
+ max: "",
41
+ step: "",
42
+ optionsRaw: "",
43
+ };
44
+
45
+ // Per-type field mapping — one ternary per optional field.
46
+ // fallow-ignore-next-line complexity
47
+ export function draftFromDeclaration(decl: CompositionVariable): DeclarationDraft {
48
+ const numeric = decl.type === "number" ? decl : null;
49
+ return {
50
+ ...EMPTY_DRAFT,
51
+ id: decl.id,
52
+ label: decl.label,
53
+ type: decl.type,
54
+ defaultRaw: String(decl.default),
55
+ description: decl.description ?? "",
56
+ min: numeric?.min !== undefined ? String(numeric.min) : "",
57
+ max: numeric?.max !== undefined ? String(numeric.max) : "",
58
+ step: numeric?.step !== undefined ? String(numeric.step) : "",
59
+ optionsRaw:
60
+ decl.type === "enum" ? decl.options.map((o) => `${o.value}:${o.label}`).join("\n") : "",
61
+ };
62
+ }
63
+
64
+ function numberDeclFromDraft(
65
+ base: { id: string; label: string; description?: string },
66
+ draft: DeclarationDraft,
67
+ ): CompositionVariable | string {
68
+ const value = Number(draft.defaultRaw);
69
+ if (!Number.isFinite(value)) return "Default must be a number.";
70
+ const constraint = (key: "min" | "max" | "step") => {
71
+ const raw = draft[key].trim();
72
+ if (!raw) return {};
73
+ const parsed = Number(raw);
74
+ return Number.isFinite(parsed) ? { [key]: parsed } : {};
75
+ };
76
+ return {
77
+ ...base,
78
+ type: "number",
79
+ default: value,
80
+ ...constraint("min"),
81
+ ...constraint("max"),
82
+ ...constraint("step"),
83
+ };
84
+ }
85
+
86
+ // fallow-ignore-next-line complexity
87
+ function enumDeclFromDraft(
88
+ base: { id: string; label: string; description?: string },
89
+ draft: DeclarationDraft,
90
+ ): CompositionVariable | string {
91
+ const options = draft.optionsRaw
92
+ .split("\n")
93
+ .map((line) => line.trim())
94
+ .filter(Boolean)
95
+ .map((line) => {
96
+ const [value, ...rest] = line.split(":");
97
+ const v = (value ?? "").trim();
98
+ return { value: v, label: rest.join(":").trim() || v };
99
+ })
100
+ .filter((o) => o.value.length > 0);
101
+ if (options.length === 0) return "Enum needs at least one option (one per line, value:Label).";
102
+ const value = draft.defaultRaw.trim() || (options[0]?.value ?? "");
103
+ if (!options.some((o) => o.value === value)) return "Default must be one of the options.";
104
+ return { ...base, type: "enum", default: value, options };
105
+ }
106
+
107
+ /**
108
+ * Fields the form actually models. On an unchanged-type edit, every OTHER key
109
+ * of the original declaration (font source/default_name/default_source,
110
+ * brandRole, placeholder, maxLength, unit, …) must ride through untouched —
111
+ * updateVariableDeclaration replaces wholesale, so dropping them here would
112
+ * silently strip schema metadata on every Edit + Save.
113
+ */
114
+ const FORM_OWNED_KEYS = new Set([
115
+ "id",
116
+ "label",
117
+ "type",
118
+ "default",
119
+ "description",
120
+ "min",
121
+ "max",
122
+ "step",
123
+ "options",
124
+ ]);
125
+
126
+ export function mergeDeclarationEdit(
127
+ original: CompositionVariable,
128
+ edited: CompositionVariable,
129
+ ): CompositionVariable {
130
+ // Type changed → old type-specific metadata no longer applies.
131
+ if (original.type !== edited.type) return edited;
132
+ const passthrough: Record<string, unknown> = {};
133
+ for (const [key, value] of Object.entries(original)) {
134
+ if (!FORM_OWNED_KEYS.has(key)) passthrough[key] = value;
135
+ }
136
+ // Both sides are same-type declarations and edited owns every form key, so
137
+ // the merge preserves the declared shape.
138
+ return { ...passthrough, ...edited };
139
+ }
140
+
141
+ /** Build a typed declaration from the form draft; string on validation error. */
142
+ // fallow-ignore-next-line complexity
143
+ export function declarationFromDraft(draft: DeclarationDraft): CompositionVariable | string {
144
+ const id = draft.id.trim();
145
+ if (!id) return "Variable id is required.";
146
+ const label = draft.label.trim() || id;
147
+ const description = draft.description.trim() || undefined;
148
+ const base = { id, label, ...(description ? { description } : {}) };
149
+ switch (draft.type) {
150
+ case "number":
151
+ return numberDeclFromDraft(base, draft);
152
+ case "boolean":
153
+ return { ...base, type: "boolean", default: draft.defaultRaw.trim() === "true" };
154
+ case "enum":
155
+ return enumDeclFromDraft(base, draft);
156
+ default:
157
+ // string / color / font / image — string default, verbatim.
158
+ return { ...base, type: draft.type, default: draft.defaultRaw };
159
+ }
160
+ }
161
+
162
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
163
+ return (
164
+ <div className="space-y-1">
165
+ <label className="text-[9px] font-medium text-neutral-500">{label}</label>
166
+ {children}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ function DefaultField({
172
+ draft,
173
+ onChange,
174
+ }: {
175
+ draft: DeclarationDraft;
176
+ onChange: (defaultRaw: string) => void;
177
+ }) {
178
+ if (draft.type === "boolean") {
179
+ return (
180
+ <select
181
+ value={draft.defaultRaw === "true" ? "true" : "false"}
182
+ onChange={(e) => onChange(e.target.value)}
183
+ className={VARIABLES_INPUT_CLASS}
184
+ >
185
+ <option value="false">false</option>
186
+ <option value="true">true</option>
187
+ </select>
188
+ );
189
+ }
190
+ return (
191
+ <input
192
+ type="text"
193
+ value={draft.defaultRaw}
194
+ onChange={(e) => onChange(e.target.value)}
195
+ className={VARIABLES_INPUT_CLASS}
196
+ />
197
+ );
198
+ }
199
+
200
+ export function DeclarationForm({
201
+ initial,
202
+ submitLabel,
203
+ onSubmit,
204
+ onCancel,
205
+ }: {
206
+ initial: DeclarationDraft;
207
+ submitLabel: string;
208
+ onSubmit: (decl: CompositionVariable) => void;
209
+ onCancel: () => void;
210
+ }) {
211
+ const [draft, setDraft] = useState<DeclarationDraft>(initial);
212
+ const [error, setError] = useState<string | null>(null);
213
+ const editingExisting = initial.id.length > 0;
214
+ const set = (patch: Partial<DeclarationDraft>) => setDraft((d) => ({ ...d, ...patch }));
215
+
216
+ const submit = () => {
217
+ const result = declarationFromDraft(draft);
218
+ if (typeof result === "string") {
219
+ setError(result);
220
+ return;
221
+ }
222
+ setError(null);
223
+ onSubmit(result);
224
+ };
225
+
226
+ return (
227
+ <div className="space-y-2 rounded-lg border border-neutral-800 bg-neutral-900/60 p-2">
228
+ <div className="grid grid-cols-2 gap-2">
229
+ <Field label="ID">
230
+ <input
231
+ type="text"
232
+ value={draft.id}
233
+ disabled={editingExisting}
234
+ onChange={(e) => set({ id: e.target.value })}
235
+ placeholder="title"
236
+ className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`}
237
+ />
238
+ </Field>
239
+ <Field label="Label">
240
+ <input
241
+ type="text"
242
+ value={draft.label}
243
+ onChange={(e) => set({ label: e.target.value })}
244
+ placeholder="Title"
245
+ className={VARIABLES_INPUT_CLASS}
246
+ />
247
+ </Field>
248
+ </div>
249
+ <div className="grid grid-cols-2 gap-2">
250
+ <Field label="Type">
251
+ <select
252
+ value={draft.type}
253
+ onChange={(e) => {
254
+ const type = VARIABLE_TYPES.find((t) => t === e.target.value);
255
+ if (type) set({ type });
256
+ }}
257
+ className={VARIABLES_INPUT_CLASS}
258
+ >
259
+ {VARIABLE_TYPES.map((t) => (
260
+ <option key={t} value={t}>
261
+ {t}
262
+ </option>
263
+ ))}
264
+ </select>
265
+ </Field>
266
+ <Field label="Default">
267
+ <DefaultField draft={draft} onChange={(defaultRaw) => set({ defaultRaw })} />
268
+ </Field>
269
+ </div>
270
+ {draft.type === "number" && (
271
+ <div className="grid grid-cols-3 gap-2">
272
+ {(["min", "max", "step"] as const).map((key) => (
273
+ <Field key={key} label={key}>
274
+ <input
275
+ type="text"
276
+ value={draft[key]}
277
+ onChange={(e) => set({ [key]: e.target.value })}
278
+ className={`${VARIABLES_INPUT_CLASS} tabular-nums`}
279
+ />
280
+ </Field>
281
+ ))}
282
+ </div>
283
+ )}
284
+ {draft.type === "enum" && (
285
+ <Field label="Options (one per line, value:Label)">
286
+ <textarea
287
+ value={draft.optionsRaw}
288
+ onChange={(e) => set({ optionsRaw: e.target.value })}
289
+ rows={3}
290
+ className={`${VARIABLES_INPUT_CLASS} resize-y font-mono`}
291
+ />
292
+ </Field>
293
+ )}
294
+ <Field label="Description (optional)">
295
+ <input
296
+ type="text"
297
+ value={draft.description}
298
+ onChange={(e) => set({ description: e.target.value })}
299
+ className={VARIABLES_INPUT_CLASS}
300
+ />
301
+ </Field>
302
+ {error && <p className="text-[10px] text-red-400">{error}</p>}
303
+ <div className="flex items-center justify-end gap-2 pt-1">
304
+ <button
305
+ type="button"
306
+ onClick={onCancel}
307
+ className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
308
+ >
309
+ Cancel
310
+ </button>
311
+ <button
312
+ type="button"
313
+ onClick={submit}
314
+ className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700"
315
+ >
316
+ {submitLabel}
317
+ </button>
318
+ </div>
319
+ </div>
320
+ );
321
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Variables tab section for compositions OTHER than the active one. A variable
3
+ * promoted into a sub-comp lives in that frame's file, not the active session —
4
+ * this surfaces every such file's declarations grouped by path, with per-file
5
+ * management (edit declaration / remove). Live-preview override for these is a
6
+ * follow-up (values are per-composition-scope), so no preview control is shown.
7
+ */
8
+
9
+ import { useCallback, useState, type MutableRefObject } from "react";
10
+ import type { Composition, CompositionVariable } from "@hyperframes/sdk";
11
+ import {
12
+ useEditVariablesInFile,
13
+ useProjectCompositionVariables,
14
+ type CompositionVariableGroup,
15
+ type RecordEditFn,
16
+ } from "../../hooks/useProjectCompositionVariables";
17
+ import {
18
+ DeclarationForm,
19
+ draftFromDeclaration,
20
+ mergeDeclarationEdit,
21
+ } from "./VariablesDeclarationForm";
22
+ import { RowAction } from "./VariablesRowAction";
23
+
24
+ function CompositionSection({
25
+ group,
26
+ editingKey,
27
+ onToggleEdit,
28
+ onSave,
29
+ onRemove,
30
+ }: {
31
+ group: CompositionVariableGroup;
32
+ editingKey: string | null;
33
+ onToggleEdit: (key: string | null) => void;
34
+ onSave: (path: string, decl: CompositionVariable) => void;
35
+ onRemove: (path: string, id: string) => void;
36
+ }) {
37
+ return (
38
+ <div className="space-y-1.5">
39
+ <p
40
+ className="truncate text-[9px] font-medium uppercase tracking-wider text-neutral-500"
41
+ title={group.path}
42
+ >
43
+ {group.path}
44
+ </p>
45
+ {group.variables.map((decl) => {
46
+ const key = `${group.path}::${decl.id}`;
47
+ const editing = editingKey === key;
48
+ return (
49
+ <div key={key} className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
50
+ <div className="flex items-center gap-1.5">
51
+ <span className="truncate text-[10px] font-medium text-neutral-300">
52
+ {decl.label}
53
+ </span>
54
+ <span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
55
+ {decl.type}
56
+ </span>
57
+ <span className="ml-auto flex items-center gap-1">
58
+ <RowAction
59
+ label="Edit"
60
+ title="Edit declaration"
61
+ onClick={() => onToggleEdit(editing ? null : key)}
62
+ />
63
+ <RowAction
64
+ label="✕"
65
+ title="Remove declaration"
66
+ danger
67
+ onClick={() => onRemove(group.path, decl.id)}
68
+ />
69
+ </span>
70
+ </div>
71
+ {decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
72
+ {editing && (
73
+ <DeclarationForm
74
+ initial={draftFromDeclaration(decl)}
75
+ submitLabel="Save"
76
+ onSubmit={(edited) => onSave(group.path, mergeDeclarationEdit(decl, edited))}
77
+ onCancel={() => onToggleEdit(null)}
78
+ />
79
+ )}
80
+ </div>
81
+ );
82
+ })}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export function VariablesOtherCompositions({
88
+ fileTree,
89
+ excludePath,
90
+ refreshKey,
91
+ readProjectFile,
92
+ writeProjectFile,
93
+ recordEdit,
94
+ reloadPreview,
95
+ domEditSaveTimestampRef,
96
+ }: {
97
+ fileTree: string[];
98
+ excludePath: string;
99
+ refreshKey: unknown;
100
+ readProjectFile: (path: string) => Promise<string>;
101
+ writeProjectFile: (path: string, content: string) => Promise<void>;
102
+ recordEdit: RecordEditFn;
103
+ reloadPreview: () => void;
104
+ domEditSaveTimestampRef: MutableRefObject<number>;
105
+ }) {
106
+ const [selfRefresh, setSelfRefresh] = useState(0);
107
+ const groups = useProjectCompositionVariables(
108
+ fileTree,
109
+ excludePath,
110
+ readProjectFile,
111
+ `${refreshKey}:${selfRefresh}`,
112
+ );
113
+ const editInFile = useEditVariablesInFile({
114
+ readProjectFile,
115
+ writeProjectFile,
116
+ recordEdit,
117
+ reloadPreview,
118
+ domEditSaveTimestampRef,
119
+ });
120
+ const [editingKey, setEditingKey] = useState<string | null>(null);
121
+
122
+ const onSave = useCallback(
123
+ (path: string, decl: CompositionVariable) => {
124
+ setEditingKey(null);
125
+ void editInFile(path, `Update variable "${decl.id}"`, (s: Composition) =>
126
+ s.updateVariableDeclaration(decl.id, decl),
127
+ ).then(() => setSelfRefresh((r) => r + 1));
128
+ },
129
+ [editInFile],
130
+ );
131
+ const onRemove = useCallback(
132
+ (path: string, id: string) => {
133
+ void editInFile(path, `Remove variable "${id}"`, (s: Composition) =>
134
+ s.removeVariableDeclaration(id),
135
+ ).then(() => setSelfRefresh((r) => r + 1));
136
+ },
137
+ [editInFile],
138
+ );
139
+
140
+ if (groups.length === 0) return null;
141
+
142
+ return (
143
+ <div className="space-y-3 border-t border-neutral-800 pt-3">
144
+ <p className="text-[9px] font-medium uppercase tracking-wider text-neutral-600">
145
+ Other compositions
146
+ </p>
147
+ {groups.map((group) => (
148
+ <CompositionSection
149
+ key={group.path}
150
+ group={group}
151
+ editingKey={editingKey}
152
+ onToggleEdit={setEditingKey}
153
+ onSave={onSave}
154
+ onRemove={onRemove}
155
+ />
156
+ ))}
157
+ </div>
158
+ );
159
+ }