mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
139 lines
5.2 KiB
TypeScript
139 lines
5.2 KiB
TypeScript
/**
|
|
* Promote-to-variable from the Design panel. Provides the same bind gesture the
|
|
* Variables tab offers (declare a variable defaulting to the element's current
|
|
* value + write the declarative binding), surfaced contextually on individual
|
|
* property controls. A control asks about its channel (text / src / a style
|
|
* prop) and gets back: whether it can be promoted, whether it is already bound,
|
|
* and callbacks to promote or to edit the bound variable's default in place.
|
|
*/
|
|
|
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
|
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
import {
|
|
applyBind,
|
|
buildBindActions,
|
|
rgbToHex,
|
|
type BindAction,
|
|
} from "../components/panels/VariablesBindElement";
|
|
import {
|
|
matchAction,
|
|
readBindingFrom,
|
|
uniqueId,
|
|
type PromoteChannel,
|
|
} from "./variablePromoteHelpers";
|
|
|
|
export type { PromoteChannel };
|
|
|
|
export interface ChannelPromote {
|
|
/** The bind action for this channel, present only when the element supports it. */
|
|
action: BindAction | null;
|
|
/** Id of the variable this channel is already bound to, or null. */
|
|
boundId: string | null;
|
|
/** The bound variable's declaration (for its type + current default), if bound. */
|
|
declaration: CompositionVariable | null;
|
|
/** Declare a new variable (default = current value) and bind this channel to it. */
|
|
promote: () => void;
|
|
/** Update the bound variable's default value in place. */
|
|
setDefault: (value: string) => void;
|
|
}
|
|
|
|
interface VariablePromoteContextValue {
|
|
session: Composition | null;
|
|
selection: DomEditSelection | null;
|
|
actions: BindAction[];
|
|
declarations: CompositionVariable[];
|
|
persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
|
|
}
|
|
|
|
const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
|
|
|
|
function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null {
|
|
const snapshot = session.getElement(hfId);
|
|
if (!snapshot) return null;
|
|
return readBindingFrom(snapshot, channel);
|
|
}
|
|
|
|
export function VariablePromoteProvider({
|
|
session,
|
|
selection,
|
|
persist,
|
|
children,
|
|
}: {
|
|
session: Composition | null;
|
|
selection: DomEditSelection | null;
|
|
persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
|
|
children: React.ReactNode;
|
|
}) {
|
|
// Re-derive actions/bindings after each persisted schema edit.
|
|
const [revision, setRevision] = useState(0);
|
|
useEffect(() => {
|
|
if (!session) return;
|
|
return session.on("change", () => setRevision((r) => r + 1));
|
|
}, [session]);
|
|
|
|
const actions = useMemo(() => {
|
|
if (!session || !selection) return [];
|
|
return buildBindActions(selection, session);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [session, selection, revision]);
|
|
|
|
const declarations = useMemo(() => {
|
|
if (!session) return [];
|
|
return session.getVariableDeclarations();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [session, revision]);
|
|
|
|
const value = useMemo<VariablePromoteContextValue>(
|
|
() => ({ session, selection, actions, declarations, persist }),
|
|
[session, selection, actions, declarations, persist],
|
|
);
|
|
|
|
return (
|
|
<VariablePromoteContext.Provider value={value}>{children}</VariablePromoteContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Binding state + actions for one control's channel. Returns null when there is
|
|
* no selection, no session, or the channel is neither bindable nor bound here —
|
|
* so a control renders plain unless promoting genuinely applies.
|
|
*/
|
|
export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromote | null {
|
|
const ctx = useContext(VariablePromoteContext);
|
|
const key = channel.kind === "style" ? `style:${channel.prop}` : channel.kind;
|
|
|
|
return useMemo(() => {
|
|
if (!ctx || !ctx.session || !ctx.selection?.hfId) return null;
|
|
const { session, selection, actions, declarations, persist } = ctx;
|
|
const hfId = selection.hfId!;
|
|
const action = matchAction(actions, channel);
|
|
const boundId = readBinding(session, hfId, channel);
|
|
if (!action && !boundId) return null;
|
|
const declaration = boundId ? (declarations.find((d) => d.id === boundId) ?? null) : null;
|
|
|
|
return {
|
|
action,
|
|
boundId,
|
|
declaration,
|
|
promote: () => {
|
|
if (!action) return;
|
|
// Right-click promote auto-names, so always mint a fresh id — unlike the
|
|
// Variables-tab card, where the user types the id and may intentionally
|
|
// reuse one. Auto-binding to a colliding pre-existing variable here would
|
|
// silently couple two unrelated elements.
|
|
const id = uniqueId(action.suggestedId, declarations);
|
|
void persist(`Bind ${action.label.toLowerCase()} to variable "${id}"`, (s) =>
|
|
applyBind(s, hfId, action, id),
|
|
);
|
|
},
|
|
setDefault: (raw: string) => {
|
|
if (!boundId || !declaration) return;
|
|
const next = declaration.type === "color" ? rgbToHex(raw) : raw;
|
|
void persist(`Set default for "${boundId}"`, (s) => s.setVariableValue(boundId, next));
|
|
},
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ctx, key]);
|
|
}
|