"use client"; import * as React from "react"; import type { ControlChangeMeta } from "@repo/ui"; import { getToolcraftControlKeyframeCapability } from "../../../schema/keyframe-capability"; import type { ToolcraftControlSchema } from "../../../schema/types"; import type { ToolcraftStoreDependency } from "../../../state/toolcraft-external-store-dependencies"; import type { ToolcraftCanvasState, ToolcraftCommand, ToolcraftMediaAsset, ToolcraftState, ToolcraftTimelineKeyframeGroup, } from "../../../state/types"; import { useToolcraftDependencySelector } from "../../app-shell/toolcraft-selectors"; import { useToolcraftSourceAssetCoordinator } from "../../app-shell/toolcraft-source-asset-context"; import { useToolcraftStore } from "../../app-shell/toolcraft-store-context"; import type { ToolcraftControlRendererMap } from "../control-renderers"; import { getToolcraftControlConditionTargets, getToolcraftTargetValue, isToolcraftControlDisabled, isToolcraftControlVisible, } from "../conditions/control-conditions"; import { getControlsPanelTargetDependencies } from "../conditions/control-condition-dependencies"; import { createControlsPanelKeyframeActions } from "../keyframes/controls-panel-keyframes"; import { LiveCustomControl } from "../live-custom-control"; import type { ActionControlRunAction } from "../renderers/controls-panel-action-renderer"; import { renderActionControl } from "../renderers/controls-panel-action-renderer"; import { renderBasicControl } from "../renderers/controls-panel-basic-renderers"; import { renderCollectionControl } from "../renderers/controls-panel-collection-renderer"; import { renderCompoundColorGroup, renderCompoundControl, } from "../renderers/controls-panel-compound-renderers"; import { renderFileDropControl } from "../renderers/controls-panel-media-renderer"; import { getToolcraftControlRendererKind, getToolcraftControlRendererStateDependencies, } from "../renderers/controls-panel-renderer-registry"; import { renderSettingsTransferControl } from "../renderers/controls-panel-settings-transfer-renderer"; import { formatControlValueLabel } from "../values/controls-panel-values"; import { withControlLabelHelp } from "./controls-panel-help"; import { type ControlEntry, getControlName, shouldShowColorFieldLabel, withCompoundControlSectionDivider, } from "./controls-panel-layout"; export type ControlsPanelSetControlValue = ( target: string, value: unknown, label?: string, meta?: ControlChangeMeta, ) => void; export type ControlsPanelControlGroupProps = { controlRenderers?: ToolcraftControlRendererMap; dispatch: React.Dispatch; dispatchCommand: (command: ToolcraftCommand) => void; entries: readonly ControlEntry[]; headerKeyframeTarget: string | null; hideControlLabel: boolean; keyframesSupported: boolean; runAction: ActionControlRunAction; sectionHasOnlyColorFields: boolean; sectionTitle: string | undefined; setControlValue: ControlsPanelSetControlValue; vectorPadShape: "compact" | "square"; }; type ControlGroupSelection = { canvasSize: ToolcraftCanvasState["size"] | null; disabledById: Readonly>; keyframeControlsEnabled: boolean; keyframeGroups: readonly ToolcraftTimelineKeyframeGroup[]; mediaAssets: readonly ToolcraftMediaAsset[] | null; selectedKeyframeId: string | null; valuesByTarget: Readonly>; visible: boolean; }; function recordValuesEqual( previous: Readonly>, next: Readonly>, ): boolean { const keys = Object.keys(previous); return ( keys.length === Object.keys(next).length && keys.every((key) => Object.is(previous[key], next[key])) ); } function controlGroupSelectionsEqual( previous: ControlGroupSelection, next: ControlGroupSelection, ): boolean { return ( Object.is(previous.canvasSize, next.canvasSize) && recordValuesEqual(previous.disabledById, next.disabledById) && previous.keyframeControlsEnabled === next.keyframeControlsEnabled && previous.keyframeGroups.length === next.keyframeGroups.length && previous.keyframeGroups.every((group, index) => Object.is(group, next.keyframeGroups[index]), ) && Object.is(previous.mediaAssets, next.mediaAssets) && previous.selectedKeyframeId === next.selectedKeyframeId && recordValuesEqual(previous.valuesByTarget, next.valuesByTarget) && previous.visible === next.visible ); } function entriesEqual( previous: readonly ControlEntry[], next: readonly ControlEntry[], ): boolean { return ( previous.length === next.length && previous.every( ([id, control], index) => id === next[index]?.[0] && Object.is(control, next[index]?.[1]), ) ); } function controlGroupPropsEqual( previous: ControlsPanelControlGroupProps, next: ControlsPanelControlGroupProps, ): boolean { return ( entriesEqual(previous.entries, next.entries) && previous.controlRenderers === next.controlRenderers && previous.dispatch === next.dispatch && previous.dispatchCommand === next.dispatchCommand && previous.headerKeyframeTarget === next.headerKeyframeTarget && previous.hideControlLabel === next.hideControlLabel && previous.keyframesSupported === next.keyframesSupported && previous.runAction === next.runAction && previous.sectionHasOnlyColorFields === next.sectionHasOnlyColorFields && previous.sectionTitle === next.sectionTitle && previous.setControlValue === next.setControlValue && previous.vectorPadShape === next.vectorPadShape ); } function getSelectedKeyframeId( state: ToolcraftState, targets: ReadonlySet, ): string | null { const selectedKeyframeId = state.timeline.selectedKeyframeId; if (!selectedKeyframeId) { return null; } return state.timeline.keyframeGroups.some( (group) => targets.has(group.controlId) && group.keyframes.some((keyframe) => keyframe.id === selectedKeyframeId), ) ? selectedKeyframeId : null; } function getControlGroupDependencies({ entries, keyframeTargets, mediaPreviewEnabled, }: { entries: readonly ControlEntry[]; keyframeTargets: readonly string[]; mediaPreviewEnabled: boolean; }): ToolcraftStoreDependency[] { const conditionTargets = entries.flatMap(([, control]) => getToolcraftControlConditionTargets(control), ); const valueTargets = entries.flatMap(([, control]) => getToolcraftControlRendererStateDependencies(control.type).includes( "value", ) || keyframeTargets.includes(control.target) ? [control.target] : [], ); const rendererDependencies = entries.flatMap(([, control]) => getToolcraftControlRendererStateDependencies(control.type), ); return [ ...getControlsPanelTargetDependencies([ ...conditionTargets, ...valueTargets, ]), ...(rendererDependencies.includes("canvas.size") ? [{ kind: "canvas.size" } as const] : []), ...(mediaPreviewEnabled && rendererDependencies.includes("mediaAssets") ? [{ kind: "mediaAssets" } as const] : []), ...(keyframeTargets.length > 0 ? [{ kind: "timeline.expanded" } as const] : []), ...keyframeTargets.flatMap((target) => [ { kind: "keyframeGroup", target } as const, { kind: "keyframeSelection", target } as const, ]), ]; } function withControlTargetBoundary({ entries, node, }: { entries: readonly ControlEntry[]; node: React.ReactNode; }): React.ReactNode { const controlIds = [...new Set(entries.map(([id]) => id))]; const targets = [...new Set(entries.map(([, control]) => control.target))]; return (
1 ? JSON.stringify(controlIds) : undefined } data-toolcraft-control-target={ targets.length === 1 ? targets[0] : undefined } data-toolcraft-control-targets={ targets.length > 1 ? JSON.stringify(targets) : undefined } > {node}
); } export const ControlsPanelControlGroup = React.memo( function ControlsPanelControlGroup({ controlRenderers, dispatch, dispatchCommand, entries, headerKeyframeTarget, hideControlLabel, keyframesSupported, runAction, sectionHasOnlyColorFields, sectionTitle, setControlValue, vectorPadShape, }: ControlsPanelControlGroupProps): React.JSX.Element | null { const store = useToolcraftStore(); const sourceAssetCoordinator = useToolcraftSourceAssetCoordinator(); const mediaPreviewEnabled = !store.getCommittedState().schema.panels.layers; const keyframeTargets = React.useMemo( () => keyframesSupported ? entries.flatMap(([, control]) => getToolcraftControlKeyframeCapability(control).capable ? [control.target] : [], ) : [], [entries, keyframesSupported], ); const keyframeTargetSet = React.useMemo( () => new Set(keyframeTargets), [keyframeTargets], ); const dependencies = React.useMemo( () => getControlGroupDependencies({ entries, keyframeTargets, mediaPreviewEnabled, }), [entries, keyframeTargets, mediaPreviewEnabled], ); const selection = useToolcraftDependencySelector( React.useCallback( (state: ToolcraftState): ControlGroupSelection => { const rendererDependencies = entries.flatMap(([, control]) => getToolcraftControlRendererStateDependencies(control.type), ); return { canvasSize: rendererDependencies.includes("canvas.size") ? state.canvas.size : null, disabledById: Object.fromEntries( entries.map(([id, control]) => [ id, isToolcraftControlDisabled(state, control), ]), ), keyframeControlsEnabled: keyframeTargets.length > 0 && state.timeline.expanded, keyframeGroups: state.timeline.keyframeGroups.filter((group) => keyframeTargetSet.has(group.controlId), ), mediaAssets: mediaPreviewEnabled && rendererDependencies.includes("mediaAssets") ? state.mediaAssets : null, selectedKeyframeId: getSelectedKeyframeId(state, keyframeTargetSet), valuesByTarget: Object.fromEntries( entries.map(([, control]) => [ control.target, getToolcraftTargetValue(state, control.target) ?? control.defaultValue, ]), ), visible: entries.every(([, control]) => isToolcraftControlVisible(state, control), ), }; }, [ entries, keyframeTargetSet, keyframeTargets.length, mediaPreviewEnabled, ], ), controlGroupSelectionsEqual, dependencies, ); if (!selection.visible) { return null; } const getControlValue = (control: ToolcraftControlSchema): unknown => selection.valuesByTarget[control.target] ?? control.defaultValue; const keyframeActions = createControlsPanelKeyframeActions({ dispatchCommand, formatValueLabel: formatControlValueLabel, getControlName, getControlValue, keyframeControlsEnabled: selection.keyframeControlsEnabled, keyframedControlIds: new Set( selection.keyframeGroups.map((group) => group.controlId), ), keyframeGroups: selection.keyframeGroups, selectedKeyframeId: selection.selectedKeyframeId, }); if (entries.length > 1) { return withControlTargetBoundary({ entries, node: renderCompoundColorGroup({ entries, getControlName, getControlValue, headerKeyframeTarget, maybeUpsertControlKeyframe: keyframeActions.maybeUpsertControlKeyframe, sectionHasOnlyColorFields, setControlValue, shouldShowColorFieldLabel, withKeyframeLabelAction: keyframeActions.withKeyframeLabelAction, }), }) as React.JSX.Element; } const entry = entries[0]; if (!entry) { return null; } const [id, rawControl] = entry; const disabled = selection.disabledById[id] === true; const resolvedControl = disabled === Boolean(rawControl.disabled) ? rawControl : { ...rawControl, disabled }; const control = hideControlLabel && resolvedControl.label !== false ? { ...resolvedControl, label: false } : resolvedControl; const name = getControlName(id, resolvedControl.label); const value = getControlValue(control); const usesHeaderKeyframeAction = control.target === headerKeyframeTarget; const commitWithLabel = (label: string) => (nextValue: unknown, meta?: ControlChangeMeta): void => { setControlValue(control.target, nextValue, label, meta); keyframeActions.maybeUpsertControlKeyframe(control, label, nextValue); }; const commit = commitWithLabel(name); const rendererKind = getToolcraftControlRendererKind(control.type); let node: React.ReactNode = null; switch (rendererKind) { case "action": node = renderActionControl({ control, id, name, runAction }); break; case "basic": node = renderBasicControl({ commit, control, id, name, usesHeaderKeyframeAction, value, vectorPadShape, withKeyframeLabelAction: keyframeActions.withKeyframeLabelAction, }); break; case "canvas-handle": node = null; break; case "collection": node = renderCollectionControl({ control, name, setControlValue, value, }); break; case "compound": node = renderCompoundControl({ commit, commitWithLabel, control, id, name, sectionHasOnlyColorFields, shouldShowColorFieldLabel, usesHeaderKeyframeAction, value, withKeyframeLabelAction: keyframeActions.withKeyframeLabelAction, }); break; case "media": node = renderFileDropControl({ canvasSize: selection.canvasSize ?? store.getState().canvas.size, control, dispatchCommand, id, layerSelectionEnabled: !mediaPreviewEnabled, mediaAssets: mediaPreviewEnabled ? (selection.mediaAssets ?? []) : [], name, setControlValue, sourceAssetCoordinator, value, }); break; case "settings": node = renderSettingsTransferControl({ dispatch, getState: store.getState, id, }); break; case null: { const CustomControl = controlRenderers?.[control.type]; if (CustomControl) { node = keyframeActions.withKeyframeLabelAction({ children: ( keyframeActions.getKeyframeLabelAction( control, name, liveValue, ) } name={name} renderer={CustomControl} setValue={commit} /> ), control, disableAction: usesHeaderKeyframeAction, name, providerKey: id, value, }); } break; } } return withControlTargetBoundary({ entries, node: withCompoundControlSectionDivider({ children: withControlLabelHelp({ children: node, control, label: name, providerKey: id, sectionTitle, }), control, }), }) as React.JSX.Element; }, controlGroupPropsEqual, );