mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties
This commit is contained in:
@@ -1,30 +1,44 @@
|
||||
/**
|
||||
* Wires the Design panel's promote-to-variable context: instantiates the
|
||||
* single-writer variables persist path and feeds it to VariablePromoteProvider,
|
||||
* so schema edits from Design-panel controls (declare + bind, or edit a bound
|
||||
* variable's default) flow through the same path the Variables tab uses.
|
||||
* Wires the Design panel's promote-to-variable context. Promote/bind operates
|
||||
* on the file the selected element actually lives in — a sub-composition file
|
||||
* when you select an element inside an inlined sub-comp, not the host. So we
|
||||
* open (and persist to) an SDK session keyed on `selection.sourceFile`, not the
|
||||
* host `activeCompPath`. Declaring a variable therefore lands in the sub-comp's
|
||||
* own file, making it a knob on that reusable frame everywhere it is used. When
|
||||
* nothing is selected (or the element is top-level) the target is the active
|
||||
* composition, so behavior there is unchanged.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
import { useSdkSession } from "../hooks/useSdkSession";
|
||||
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
|
||||
import { VariablePromoteProvider } from "../contexts/VariablePromoteContext";
|
||||
|
||||
/** Persist wiring minus the target — this provider derives the target from the selection. */
|
||||
type PersistDeps = Omit<UseVariablesPersistParams, "sdkSession" | "activeCompPath">;
|
||||
|
||||
export function DesignPanelPromoteProvider({
|
||||
selection,
|
||||
projectId,
|
||||
activeCompPath,
|
||||
children,
|
||||
...persistParams
|
||||
}: UseVariablesPersistParams & {
|
||||
...persistDeps
|
||||
}: PersistDeps & {
|
||||
selection: DomEditSelection | null;
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const persist = useVariablesPersist(persistParams);
|
||||
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
|
||||
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
|
||||
const persist = useVariablesPersist({
|
||||
...persistDeps,
|
||||
sdkSession: handle.session,
|
||||
activeCompPath: targetPath,
|
||||
});
|
||||
return (
|
||||
<VariablePromoteProvider
|
||||
session={persistParams.sdkSession}
|
||||
selection={selection}
|
||||
persist={persist}
|
||||
>
|
||||
<VariablePromoteProvider session={handle.session} selection={selection} persist={persist}>
|
||||
{children}
|
||||
</VariablePromoteProvider>
|
||||
);
|
||||
|
||||
@@ -345,7 +345,7 @@ export function StudioRightPanel({
|
||||
const propertyPanel = (
|
||||
<DesignPanelPromoteProvider
|
||||
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
sdkSession={sdkSession}
|
||||
projectId={projectId}
|
||||
activeCompPath={activeCompPath}
|
||||
readProjectFile={readProjectFile}
|
||||
writeProjectFile={writeProjectFile}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useState, type MutableRefObject } from "react";
|
||||
import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
|
||||
import { persistSdkSerialize } from "../utils/sdkCutover";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
/** Records an edit into the studio's undo history (label + kind + per-file before/after). */
|
||||
export type RecordEditFn = (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
|
||||
export interface CompositionVariableGroup {
|
||||
/** Project-relative file path, e.g. "compositions/frames/02-problem.html". */
|
||||
path: string;
|
||||
/** The composition's variable declarations (empty groups are dropped by the hook). */
|
||||
variables: CompositionVariable[];
|
||||
}
|
||||
|
||||
/** Read one composition file's declarations, or null to skip (unreadable / none / unparseable). */
|
||||
// fallow-ignore-next-line complexity
|
||||
async function readGroup(
|
||||
path: string,
|
||||
readProjectFile: (path: string) => Promise<string>,
|
||||
): Promise<CompositionVariableGroup | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readProjectFile(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!content.includes("data-composition-variables")) return null;
|
||||
try {
|
||||
const comp = await openComposition(content, { history: false });
|
||||
try {
|
||||
const variables = comp.getVariableDeclarations();
|
||||
return variables.length > 0 ? { path, variables } : null;
|
||||
} finally {
|
||||
comp.dispose();
|
||||
}
|
||||
} catch {
|
||||
return null; // Unparseable composition — skip rather than break the whole panel.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read variable declarations from every composition file in the project except
|
||||
* `excludePath` (the active composition, which the panel renders with its full
|
||||
* preview/add controls). Powers the Variables tab's "other compositions"
|
||||
* sections so a variable promoted into a sub-comp file is visible alongside the
|
||||
* host's own. Re-reads whenever `refreshKey` changes (after an edit or preview
|
||||
* reload). A cheap substring guard skips files with no declarations before the
|
||||
* full parse, so large projects don't pay N openComposition calls.
|
||||
*/
|
||||
export function useProjectCompositionVariables(
|
||||
fileTree: string[],
|
||||
excludePath: string | null,
|
||||
readProjectFile: (path: string) => Promise<string>,
|
||||
refreshKey: unknown,
|
||||
): CompositionVariableGroup[] {
|
||||
const [groups, setGroups] = useState<CompositionVariableGroup[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const htmlFiles = fileTree.filter((p) => p.endsWith(".html") && p !== excludePath);
|
||||
|
||||
void (async () => {
|
||||
const out: CompositionVariableGroup[] = [];
|
||||
for (const path of htmlFiles) {
|
||||
const group = await readGroup(path, readProjectFile);
|
||||
if (group) out.push(group);
|
||||
}
|
||||
if (!cancelled) setGroups(out);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileTree, excludePath, readProjectFile, refreshKey]);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
interface EditVariablesDeps {
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: RecordEditFn;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a variable-schema mutation to an arbitrary composition file (a sub-comp
|
||||
* that isn't the active SDK session) and persist it through the standard
|
||||
* single-writer path. Opens a throwaway session on the file, runs `mutate`,
|
||||
* and writes the serialized result — the same contract as useVariablesPersist,
|
||||
* but keyed on `path` rather than a live session.
|
||||
*/
|
||||
export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
||||
const { readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef } =
|
||||
deps;
|
||||
return useCallback(
|
||||
async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
|
||||
const originalContent = await readProjectFile(path);
|
||||
const comp = await openComposition(originalContent, { history: false });
|
||||
let after: string;
|
||||
try {
|
||||
mutate(comp);
|
||||
after = comp.serialize();
|
||||
} finally {
|
||||
comp.dispose();
|
||||
}
|
||||
if (after === originalContent) return;
|
||||
await persistSdkSerialize(
|
||||
after,
|
||||
path,
|
||||
originalContent,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: path,
|
||||
},
|
||||
{ label },
|
||||
);
|
||||
},
|
||||
[readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user