mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): promote to variable from the design panel
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
|
||||
import { VariablePromoteProvider } from "../contexts/VariablePromoteContext";
|
||||
|
||||
export function DesignPanelPromoteProvider({
|
||||
selection,
|
||||
children,
|
||||
...persistParams
|
||||
}: UseVariablesPersistParams & {
|
||||
selection: DomEditSelection | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const persist = useVariablesPersist(persistParams);
|
||||
return (
|
||||
<VariablePromoteProvider
|
||||
session={persistParams.sdkSession}
|
||||
selection={selection}
|
||||
persist={persist}
|
||||
>
|
||||
{children}
|
||||
</VariablePromoteProvider>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailabil
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
|
||||
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
|
||||
|
||||
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
@@ -342,63 +343,74 @@ export function StudioRightPanel({
|
||||
);
|
||||
|
||||
const propertyPanel = (
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
assets={assets}
|
||||
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onToggleElementHidden={onToggleElementHidden}
|
||||
onUngroup={handleUngroupSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
onSetAttributeLive={handleDomAttributeLiveCommit}
|
||||
onApplyColorGradingScope={handleApplyColorGradingScope}
|
||||
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
||||
onRemoveBackground={handleRemoveBackground}
|
||||
onSetManualOffset={handleDomPathOffsetCommit}
|
||||
onSetManualSize={handleDomBoxSizeCommit}
|
||||
onSetManualRotation={handleDomRotationCommit}
|
||||
onSetText={handleDomTextCommit}
|
||||
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
||||
onAddTextField={handleDomAddTextField}
|
||||
onRemoveTextField={handleDomRemoveTextField}
|
||||
onAskAgent={handleAskAgent}
|
||||
onImportAssets={handleImportFiles}
|
||||
fontAssets={fontAssets}
|
||||
onImportFonts={handleImportFonts}
|
||||
previewIframeRef={previewIframeRef}
|
||||
gsapAnimations={selectedGsapAnimations}
|
||||
gsapMultipleTimelines={gsapMultipleTimelines}
|
||||
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
||||
onUpdateGsapProperty={handleGsapUpdateProperty}
|
||||
onUpdateGsapMeta={handleGsapUpdateMeta}
|
||||
onDeleteGsapAnimation={handleGsapDeleteAnimation}
|
||||
onAddGsapProperty={handleGsapAddProperty}
|
||||
onRemoveGsapProperty={handleGsapRemoveProperty}
|
||||
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
|
||||
onAddGsapFromProperty={handleGsapAddFromProperty}
|
||||
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
||||
onAddGsapAnimation={handleGsapAddAnimation}
|
||||
onCommitAnimatedProperty={commitAnimatedProperty}
|
||||
onCommitAnimatedProperties={commitAnimatedProperties}
|
||||
onAddKeyframe={handleGsapAddKeyframe}
|
||||
onRemoveKeyframe={handleGsapRemoveKeyframe}
|
||||
onConvertToKeyframes={(animId, duration) =>
|
||||
handleGsapConvertToKeyframes(animId, undefined, duration)
|
||||
}
|
||||
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
||||
onSetArcPath={handleSetArcPath}
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
onUnroll={handleUnroll}
|
||||
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
||||
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
<DesignPanelPromoteProvider
|
||||
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
sdkSession={sdkSession}
|
||||
activeCompPath={activeCompPath}
|
||||
readProjectFile={readProjectFile}
|
||||
writeProjectFile={writeProjectFile}
|
||||
recordEdit={recordEdit}
|
||||
reloadPreview={reloadPreview}
|
||||
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
||||
>
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
assets={assets}
|
||||
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onToggleElementHidden={onToggleElementHidden}
|
||||
onUngroup={handleUngroupSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
onSetAttributeLive={handleDomAttributeLiveCommit}
|
||||
onApplyColorGradingScope={handleApplyColorGradingScope}
|
||||
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
||||
onRemoveBackground={handleRemoveBackground}
|
||||
onSetManualOffset={handleDomPathOffsetCommit}
|
||||
onSetManualSize={handleDomBoxSizeCommit}
|
||||
onSetManualRotation={handleDomRotationCommit}
|
||||
onSetText={handleDomTextCommit}
|
||||
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
||||
onAddTextField={handleDomAddTextField}
|
||||
onRemoveTextField={handleDomRemoveTextField}
|
||||
onAskAgent={handleAskAgent}
|
||||
onImportAssets={handleImportFiles}
|
||||
fontAssets={fontAssets}
|
||||
onImportFonts={handleImportFonts}
|
||||
previewIframeRef={previewIframeRef}
|
||||
gsapAnimations={selectedGsapAnimations}
|
||||
gsapMultipleTimelines={gsapMultipleTimelines}
|
||||
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
||||
onUpdateGsapProperty={handleGsapUpdateProperty}
|
||||
onUpdateGsapMeta={handleGsapUpdateMeta}
|
||||
onDeleteGsapAnimation={handleGsapDeleteAnimation}
|
||||
onAddGsapProperty={handleGsapAddProperty}
|
||||
onRemoveGsapProperty={handleGsapRemoveProperty}
|
||||
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
|
||||
onAddGsapFromProperty={handleGsapAddFromProperty}
|
||||
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
||||
onAddGsapAnimation={handleGsapAddAnimation}
|
||||
onCommitAnimatedProperty={commitAnimatedProperty}
|
||||
onCommitAnimatedProperties={commitAnimatedProperties}
|
||||
onAddKeyframe={handleGsapAddKeyframe}
|
||||
onRemoveKeyframe={handleGsapRemoveKeyframe}
|
||||
onConvertToKeyframes={(animId, duration) =>
|
||||
handleGsapConvertToKeyframes(animId, undefined, duration)
|
||||
}
|
||||
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
||||
onSetArcPath={handleSetArcPath}
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
onUnroll={handleUnroll}
|
||||
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
||||
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
/>
|
||||
</DesignPanelPromoteProvider>
|
||||
);
|
||||
|
||||
const renderQueuePanel = (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Wraps a Design-panel property control with the promote-to-variable gesture.
|
||||
* When a control can be promoted it shows a visible "◇ var" button; clicking it
|
||||
* declares a variable (default = current value, so the render is unchanged) and
|
||||
* binds this property to it. Once bound, the button is replaced by a "◆ {id}"
|
||||
* chip and edits route to the variable's default (edit-in-place). Controls that
|
||||
* aren't eligible (or render outside a promote context, or are disabled by the
|
||||
* caller) pass through untouched. Uses a render-prop so each control keeps its
|
||||
* own value/onCommit shape.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useVariablePromoteChannel,
|
||||
type PromoteChannel,
|
||||
} from "../../contexts/VariablePromoteContext";
|
||||
|
||||
interface RenderArgs {
|
||||
/** When bound, the variable's default to display; otherwise undefined. */
|
||||
value?: string;
|
||||
/** When bound, routes commits to the variable default; otherwise undefined. */
|
||||
onCommit?: (value: string) => void;
|
||||
bound: boolean;
|
||||
}
|
||||
|
||||
export function PromotableControl({
|
||||
channel,
|
||||
enabled = true,
|
||||
children,
|
||||
}: {
|
||||
channel: PromoteChannel;
|
||||
/**
|
||||
* Caller-side gate. Text-section controls only promote when the edited field
|
||||
* is the selected element's OWN text (source "self"); binding a child/text-
|
||||
* node field would target a different element than the control edits.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
children: (args: RenderArgs) => React.ReactNode;
|
||||
}) {
|
||||
const promote = useVariablePromoteChannel(channel);
|
||||
|
||||
// A binding attribute (`data-var-*` / `var(--id)`) pointing at a declaration
|
||||
// that no longer exists renders as a plain unbound control — a silent
|
||||
// fallback that leaves a dev wondering why "their binding isn't showing".
|
||||
// Surface it in the console so the dangling reference is discoverable.
|
||||
const danglingId =
|
||||
enabled && promote && promote.boundId != null && promote.declaration == null
|
||||
? promote.boundId
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (danglingId != null) {
|
||||
console.warn(
|
||||
`[hyperframes] Control is bound to variable "${danglingId}", but no such declaration exists. The element still carries the binding on disk — re-declare the variable or unbind the element.`,
|
||||
);
|
||||
}
|
||||
}, [danglingId]);
|
||||
|
||||
if (!promote || !enabled) return <>{children({ bound: false })}</>;
|
||||
|
||||
// A binding whose declaration was removed elsewhere is dangling: don't show
|
||||
// it as an editable bound control (setDefault would silently no-op) — let it
|
||||
// fall back to a plain, re-promotable control.
|
||||
const bound = promote.boundId != null && promote.declaration != null;
|
||||
const canPromote = promote.action != null && !bound;
|
||||
const defaultValue = promote.declaration?.default;
|
||||
|
||||
const rendered = children(
|
||||
bound
|
||||
? {
|
||||
// Only string defaults render inline; a FontValue/ImageValue object
|
||||
// falls back to the element's real value instead of "[object Object]".
|
||||
value: typeof defaultValue === "string" ? defaultValue : undefined,
|
||||
onCommit: promote.setDefault,
|
||||
bound: true,
|
||||
}
|
||||
: { bound: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`relative ${bound ? "rounded-lg ring-1 ring-studio-accent/40" : ""}`}>
|
||||
{rendered}
|
||||
{bound && (
|
||||
<span
|
||||
className="pointer-events-none absolute right-1.5 top-0 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
|
||||
title={`Bound to variable "${promote.boundId}"`}
|
||||
>
|
||||
◆ {promote.boundId}
|
||||
</span>
|
||||
)}
|
||||
{canPromote && (
|
||||
<button
|
||||
type="button"
|
||||
title="Make this a variable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
promote.promote();
|
||||
}}
|
||||
className="absolute right-1.5 top-0 z-10 inline-flex items-center gap-1 rounded bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
|
||||
>
|
||||
◇ var
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { FIELD, LABEL, normalizeTextMetricValue, RESPONSIVE_GRID } from "./prope
|
||||
import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { FontFamilyField } from "./propertyPanelFont";
|
||||
import { PromotableControl } from "./PromotableControl";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Text helpers (used only by text section components) */
|
||||
@@ -280,20 +281,31 @@ function TextFieldEditor({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TextAreaField
|
||||
key={field.key}
|
||||
label="Content"
|
||||
value={field.value}
|
||||
disabled={false}
|
||||
autoFocus={showRemove}
|
||||
onCommit={(next) => onSetText(next, field.key)}
|
||||
/>
|
||||
<ColorField
|
||||
label="Text color"
|
||||
value={getTextFieldColor(field, styles)}
|
||||
disabled={false}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
|
||||
/>
|
||||
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
|
||||
{({ value, onCommit }) => (
|
||||
<TextAreaField
|
||||
key={field.key}
|
||||
label="Content"
|
||||
value={value ?? field.value}
|
||||
disabled={false}
|
||||
autoFocus={showRemove}
|
||||
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<PromotableControl
|
||||
channel={{ kind: "style", prop: "color" }}
|
||||
enabled={field.source === "self"}
|
||||
>
|
||||
{({ value, onCommit }) => (
|
||||
<ColorField
|
||||
label="Text color"
|
||||
value={value ?? getTextFieldColor(field, styles)}
|
||||
disabled={false}
|
||||
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<MetricField
|
||||
label="Size"
|
||||
@@ -309,13 +321,22 @@ function TextFieldEditor({
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-weight", next)}
|
||||
/>
|
||||
</div>
|
||||
<FontFamilyField
|
||||
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
|
||||
disabled={false}
|
||||
importedFonts={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
|
||||
/>
|
||||
<PromotableControl
|
||||
channel={{ kind: "style", prop: "font-family" }}
|
||||
enabled={field.source === "self"}
|
||||
>
|
||||
{({ value, onCommit }) => (
|
||||
<FontFamilyField
|
||||
value={
|
||||
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
|
||||
}
|
||||
disabled={false}
|
||||
importedFonts={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<AdvancedTextControls
|
||||
field={field}
|
||||
inheritedStyles={styles}
|
||||
|
||||
@@ -43,7 +43,7 @@ function sanitizeId(raw: string): string {
|
||||
* #000000, which the picker can at least display. Unrecognized formats pass
|
||||
* through verbatim.
|
||||
*/
|
||||
function rgbToHex(value: string): string {
|
||||
export function rgbToHex(value: string): string {
|
||||
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value);
|
||||
if (!m) return value;
|
||||
return `#${m
|
||||
@@ -58,7 +58,10 @@ function firstFontFamily(value: string): string {
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function buildBindActions(selection: DomEditSelection, sdkSession: Composition): BindAction[] {
|
||||
export function buildBindActions(
|
||||
selection: DomEditSelection,
|
||||
sdkSession: Composition,
|
||||
): BindAction[] {
|
||||
const hfId = selection.hfId;
|
||||
if (!hfId) return [];
|
||||
// No snapshot = the session can't resolve this element (e.g. a sub-comp
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CompositionVariable } from "@hyperframes/sdk";
|
||||
import type { BindAction } from "../components/panels/VariablesBindElement";
|
||||
import {
|
||||
matchAction,
|
||||
parseVarId,
|
||||
readBindingFrom,
|
||||
toCamel,
|
||||
uniqueId,
|
||||
type PromoteChannel,
|
||||
} from "./variablePromoteHelpers";
|
||||
|
||||
function action(kind: BindAction["kind"], styleProp?: string): BindAction {
|
||||
return {
|
||||
key: `${kind}:${styleProp ?? ""}`,
|
||||
label: kind,
|
||||
kind,
|
||||
styleProp,
|
||||
suggestedId: "x",
|
||||
declaration: (id) => ({ id, type: "string", label: id, default: "" }),
|
||||
};
|
||||
}
|
||||
|
||||
function decl(id: string): CompositionVariable {
|
||||
return { id, type: "string", label: id, default: "" };
|
||||
}
|
||||
|
||||
describe("toCamel", () => {
|
||||
it("hyphenates CSS props to camelCase", () => {
|
||||
expect(toCamel("font-family")).toBe("fontFamily");
|
||||
expect(toCamel("background-color")).toBe("backgroundColor");
|
||||
expect(toCamel("color")).toBe("color");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVarId", () => {
|
||||
it("extracts the id from a var() reference", () => {
|
||||
expect(parseVarId("var(--headline-color)")).toBe("headline-color");
|
||||
expect(parseVarId(" var(--brand_1) ")).toBe("brand_1");
|
||||
});
|
||||
it("tolerates a simple fallback value and !important", () => {
|
||||
expect(parseVarId("var(--accent, #000)")).toBe("accent");
|
||||
expect(parseVarId("var(--accent, transparent)")).toBe("accent");
|
||||
expect(parseVarId("var(--accent) !important")).toBe("accent");
|
||||
});
|
||||
it("does not parse a nested-paren fallback (degrades to unbound, not a wrong id)", () => {
|
||||
// Rare hand-authored case; regex can't balance parens. Returning null means
|
||||
// the control shows as unbound rather than binding to a wrong id.
|
||||
expect(parseVarId("var(--accent, rgb(0,0,0))")).toBeNull();
|
||||
});
|
||||
it("returns null for non-var values", () => {
|
||||
expect(parseVarId("#00c3ff")).toBeNull();
|
||||
expect(parseVarId("rgb(0,0,0)")).toBeNull();
|
||||
expect(parseVarId(undefined)).toBeNull();
|
||||
expect(parseVarId("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBindingFrom", () => {
|
||||
const channels: Record<string, PromoteChannel> = {
|
||||
text: { kind: "text" },
|
||||
src: { kind: "src" },
|
||||
color: { kind: "style", prop: "color" },
|
||||
font: { kind: "style", prop: "font-family" },
|
||||
};
|
||||
|
||||
it("reads data-var-text / data-var-src from attributes", () => {
|
||||
const src = {
|
||||
attributes: { "data-var-text": "title", "data-var-src": "logo" },
|
||||
inlineStyles: {},
|
||||
};
|
||||
expect(readBindingFrom(src, channels.text)).toBe("title");
|
||||
expect(readBindingFrom(src, channels.src)).toBe("logo");
|
||||
});
|
||||
|
||||
it("reads a style-prop binding from inlineStyles var()", () => {
|
||||
const src = {
|
||||
attributes: {},
|
||||
inlineStyles: { color: "var(--accent)", fontFamily: "var(--brand-font)" },
|
||||
};
|
||||
expect(readBindingFrom(src, channels.color)).toBe("accent");
|
||||
expect(readBindingFrom(src, channels.font)).toBe("brand-font");
|
||||
});
|
||||
|
||||
it("returns null when the channel is not bound", () => {
|
||||
const src = { attributes: {}, inlineStyles: { color: "#fff" } };
|
||||
expect(readBindingFrom(src, channels.text)).toBeNull();
|
||||
expect(readBindingFrom(src, channels.color)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchAction", () => {
|
||||
const actions = [
|
||||
action("text"),
|
||||
action("src"),
|
||||
action("style", "color"),
|
||||
action("style", "font-family"),
|
||||
];
|
||||
|
||||
it("matches text/src by kind", () => {
|
||||
expect(matchAction(actions, { kind: "text" })?.kind).toBe("text");
|
||||
expect(matchAction(actions, { kind: "src" })?.kind).toBe("src");
|
||||
});
|
||||
it("matches a style channel by its prop", () => {
|
||||
expect(matchAction(actions, { kind: "style", prop: "color" })?.styleProp).toBe("color");
|
||||
expect(matchAction(actions, { kind: "style", prop: "font-family" })?.styleProp).toBe(
|
||||
"font-family",
|
||||
);
|
||||
});
|
||||
it("returns null when no action fits the channel", () => {
|
||||
expect(matchAction(actions, { kind: "style", prop: "background-color" })).toBeNull();
|
||||
expect(matchAction([], { kind: "text" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uniqueId", () => {
|
||||
it("returns the base when free", () => {
|
||||
expect(uniqueId("headline", [decl("other")])).toBe("headline");
|
||||
});
|
||||
it("suffixes to avoid collisions", () => {
|
||||
expect(uniqueId("headline", [decl("headline")])).toBe("headline-2");
|
||||
expect(uniqueId("headline", [decl("headline"), decl("headline-2")])).toBe("headline-3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Pure helpers for promote-to-variable from the Design panel. Kept free of React
|
||||
* and the SDK session so the binding-detection and id logic can be unit-tested.
|
||||
*/
|
||||
|
||||
import type { CompositionVariable } from "@hyperframes/sdk";
|
||||
import type { BindAction } from "../components/panels/VariablesBindElement";
|
||||
|
||||
export type PromoteChannel = { kind: "text" } | { kind: "src" } | { kind: "style"; prop: string };
|
||||
|
||||
/** Minimal element shape needed to read a binding — mirrors the SDK snapshot. */
|
||||
export interface BindingSource {
|
||||
attributes: Readonly<Record<string, string>>;
|
||||
inlineStyles: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** "font-family" → "fontFamily" to index inlineStyles (camelCase, per SDK convention). */
|
||||
export function toCamel(prop: string): string {
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* "var(--headline-color)" → "headline-color". Tolerates a fallback
|
||||
* ("var(--id, #fff)") and a trailing "!important"; anything else → null.
|
||||
*/
|
||||
export function parseVarId(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const m = /^var\(\s*--([A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)\s*(?:!important)?\s*$/.exec(value.trim());
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export function matchAction(actions: BindAction[], channel: PromoteChannel): BindAction | null {
|
||||
if (channel.kind === "style") {
|
||||
return actions.find((a) => a.kind === "style" && a.styleProp === channel.prop) ?? null;
|
||||
}
|
||||
return actions.find((a) => a.kind === channel.kind) ?? null;
|
||||
}
|
||||
|
||||
/** Id of the variable this channel is bound to on the element, or null. */
|
||||
export function readBindingFrom(source: BindingSource, channel: PromoteChannel): string | null {
|
||||
if (channel.kind === "text") return source.attributes["data-var-text"] ?? null;
|
||||
if (channel.kind === "src") return source.attributes["data-var-src"] ?? null;
|
||||
return parseVarId(source.inlineStyles[toCamel(channel.prop)]);
|
||||
}
|
||||
|
||||
/** Unique id from a suggested base, avoiding collisions with existing declarations. */
|
||||
export function uniqueId(base: string, existing: CompositionVariable[]): string {
|
||||
const taken = new Set(existing.map((d) => d.id));
|
||||
if (!taken.has(base)) return base;
|
||||
let n = 2;
|
||||
while (taken.has(`${base}-${n}`)) n += 1;
|
||||
return `${base}-${n}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* Round-trip proof for Design-panel promote-to-variable: a bind written by
|
||||
* applyBind against a real SDK session must be detected by readBindingFrom off
|
||||
* the resulting element snapshot — the same read the Design controls rely on to
|
||||
* show bound state.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
||||
import { applyBind, type BindAction } from "../components/panels/VariablesBindElement";
|
||||
import { readBindingFrom } from "./variablePromoteHelpers";
|
||||
|
||||
const HTML = /* html */ `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div data-hf-id="hf-title" style="color: rgb(255, 0, 0)">Hello</div>
|
||||
<img data-hf-id="hf-logo" src="logo.png" />
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function open() {
|
||||
return openComposition(HTML, { persist: createMemoryAdapter() });
|
||||
}
|
||||
|
||||
const styleAction: BindAction = {
|
||||
key: "color",
|
||||
label: "Text color",
|
||||
kind: "style",
|
||||
styleProp: "color",
|
||||
suggestedId: "title-color",
|
||||
declaration: (id) => ({ id, type: "color", label: "Title color", default: "#ff0000" }),
|
||||
};
|
||||
|
||||
const textAction: BindAction = {
|
||||
key: "text",
|
||||
label: "Text",
|
||||
kind: "text",
|
||||
suggestedId: "title-text",
|
||||
declaration: (id) => ({ id, type: "string", label: "Title text", default: "Hello" }),
|
||||
};
|
||||
|
||||
const srcAction: BindAction = {
|
||||
key: "src",
|
||||
label: "Image source",
|
||||
kind: "src",
|
||||
suggestedId: "logo",
|
||||
declaration: (id) => ({ id, type: "image", label: "Logo", default: "logo.png" }),
|
||||
};
|
||||
|
||||
describe("promote round-trip", () => {
|
||||
it("style bind writes var() and reads back the id", async () => {
|
||||
const comp = await open();
|
||||
applyBind(comp, "hf-title", styleAction, "title-color");
|
||||
const snap = comp.getElement("hf-title")!;
|
||||
expect(readBindingFrom(snap, { kind: "style", prop: "color" })).toBe("title-color");
|
||||
expect(comp.getVariableDeclarations().some((d) => d.id === "title-color")).toBe(true);
|
||||
});
|
||||
|
||||
it("text bind writes data-var-text and reads back the id", async () => {
|
||||
const comp = await open();
|
||||
applyBind(comp, "hf-title", textAction, "title-text");
|
||||
const snap = comp.getElement("hf-title")!;
|
||||
expect(readBindingFrom(snap, { kind: "text" })).toBe("title-text");
|
||||
});
|
||||
|
||||
it("src bind writes data-var-src and reads back the id", async () => {
|
||||
const comp = await open();
|
||||
applyBind(comp, "hf-logo", srcAction, "logo");
|
||||
const snap = comp.getElement("hf-logo")!;
|
||||
expect(readBindingFrom(snap, { kind: "src" })).toBe("logo");
|
||||
});
|
||||
|
||||
it("does not report a binding before promote", async () => {
|
||||
const comp = await open();
|
||||
const snap = comp.getElement("hf-title")!;
|
||||
expect(readBindingFrom(snap, { kind: "style", prop: "color" })).toBeNull();
|
||||
expect(readBindingFrom(snap, { kind: "text" })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user