feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties

This commit is contained in:
James
2026-07-09 13:31:04 -07:00
parent 7c144ecc30
commit 8de80bf369
19 changed files with 603 additions and 146 deletions
@@ -0,0 +1,159 @@
/**
* Variables tab section for compositions OTHER than the active one. A variable
* promoted into a sub-comp lives in that frame's file, not the active session —
* this surfaces every such file's declarations grouped by path, with per-file
* management (edit declaration / remove). Live-preview override for these is a
* follow-up (values are per-composition-scope), so no preview control is shown.
*/
import { useCallback, useState, type MutableRefObject } from "react";
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
import {
useEditVariablesInFile,
useProjectCompositionVariables,
type CompositionVariableGroup,
type RecordEditFn,
} from "../../hooks/useProjectCompositionVariables";
import {
DeclarationForm,
draftFromDeclaration,
mergeDeclarationEdit,
} from "./VariablesDeclarationForm";
import { RowAction } from "./VariablesRowAction";
function CompositionSection({
group,
editingKey,
onToggleEdit,
onSave,
onRemove,
}: {
group: CompositionVariableGroup;
editingKey: string | null;
onToggleEdit: (key: string | null) => void;
onSave: (path: string, decl: CompositionVariable) => void;
onRemove: (path: string, id: string) => void;
}) {
return (
<div className="space-y-1.5">
<p
className="truncate text-[9px] font-medium uppercase tracking-wider text-neutral-500"
title={group.path}
>
{group.path}
</p>
{group.variables.map((decl) => {
const key = `${group.path}::${decl.id}`;
const editing = editingKey === key;
return (
<div key={key} className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
<div className="flex items-center gap-1.5">
<span className="truncate text-[10px] font-medium text-neutral-300">
{decl.label}
</span>
<span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
{decl.type}
</span>
<span className="ml-auto flex items-center gap-1">
<RowAction
label="Edit"
title="Edit declaration"
onClick={() => onToggleEdit(editing ? null : key)}
/>
<RowAction
label="✕"
title="Remove declaration"
danger
onClick={() => onRemove(group.path, decl.id)}
/>
</span>
</div>
{decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
{editing && (
<DeclarationForm
initial={draftFromDeclaration(decl)}
submitLabel="Save"
onSubmit={(edited) => onSave(group.path, mergeDeclarationEdit(decl, edited))}
onCancel={() => onToggleEdit(null)}
/>
)}
</div>
);
})}
</div>
);
}
export function VariablesOtherCompositions({
fileTree,
excludePath,
refreshKey,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
}: {
fileTree: string[];
excludePath: string;
refreshKey: unknown;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: RecordEditFn;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
}) {
const [selfRefresh, setSelfRefresh] = useState(0);
const groups = useProjectCompositionVariables(
fileTree,
excludePath,
readProjectFile,
`${refreshKey}:${selfRefresh}`,
);
const editInFile = useEditVariablesInFile({
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
});
const [editingKey, setEditingKey] = useState<string | null>(null);
const onSave = useCallback(
(path: string, decl: CompositionVariable) => {
setEditingKey(null);
void editInFile(path, `Update variable "${decl.id}"`, (s: Composition) =>
s.updateVariableDeclaration(decl.id, decl),
).then(() => setSelfRefresh((r) => r + 1));
},
[editInFile],
);
const onRemove = useCallback(
(path: string, id: string) => {
void editInFile(path, `Remove variable "${id}"`, (s: Composition) =>
s.removeVariableDeclaration(id),
).then(() => setSelfRefresh((r) => r + 1));
},
[editInFile],
);
if (groups.length === 0) return null;
return (
<div className="space-y-3 border-t border-neutral-800 pt-3">
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-600">
Other compositions
</p>
{groups.map((group) => (
<CompositionSection
key={group.path}
group={group}
editingKey={editingKey}
onToggleEdit={setEditingKey}
onSave={onSave}
onRemove={onRemove}
/>
))}
</div>
);
}
@@ -11,6 +11,8 @@ import { useDomEditContext } from "../../contexts/DomEditContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
import { VariablesOtherCompositions } from "./VariablesOtherCompositions";
import { RowAction } from "./VariablesRowAction";
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
import {
DeclarationForm,
@@ -63,31 +65,6 @@ function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
);
}
function RowAction({
label,
title,
danger,
onClick,
}: {
label: string;
title: string;
danger?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
danger ? "hover:text-red-400" : "hover:text-neutral-200"
}`}
>
{label}
</button>
);
}
// fallow-ignore-next-line complexity
function VariableRow({
decl,
@@ -561,6 +538,16 @@ export const VariablesPanel = memo(function VariablesPanel({
+ Add variable
</button>
)}
<VariablesOtherCompositions
fileTree={fileTree}
excludePath={activeCompPath ?? "index.html"}
refreshKey={`${refreshKey}:${revision}`}
readProjectFile={readProjectFile}
writeProjectFile={writeProjectFile}
recordEdit={recordEdit}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
/>
</div>
</div>
);
@@ -0,0 +1,25 @@
/** Small text-button used in the Variables tab rows (Edit / Remove / Set default / Declare). */
export function RowAction({
label,
title,
danger,
onClick,
}: {
label: string;
title: string;
danger?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
danger ? "hover:text-red-400" : "hover:text-neutral-200"
}`}
>
{label}
</button>
);
}