From dc0a8e7c963823432d8c89b6970ead024f06b091 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 00:59:54 +0200 Subject: [PATCH] feat: graph editor supports multiple properties at once --- .../panels/timeline/graph-editor/session.ts | 480 ++++++++++---- .../timeline/graph-editor/use-controller.ts | 78 ++- apps/web/src/stores/keybindings-store.ts | 598 ++++++++---------- 3 files changed, 644 insertions(+), 512 deletions(-) diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts index 60e4723e..cc0fb070 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -23,6 +23,9 @@ const LINEAR_CURVE_EPSILON = 1e-6; export type GraphEditorUnavailableReason = | "no-keyframe-selected" | "multiple-keyframes-selected" + | "selected-keyframes-span-multiple-elements" + | "selected-keyframes-are-not-adjacent" + | "selected-properties-have-no-shared-component" | "selected-element-missing" | "selected-element-has-no-animations" | "selected-keyframe-has-no-scalar-channel" @@ -36,6 +39,22 @@ export interface GraphEditorComponentOption { label: string; } +interface GraphEditorPropertyOption { + key: string; + label: string; + context: ScalarGraphKeyframeContext; + allContexts: ScalarGraphKeyframeContext[]; +} + +export interface GraphEditorResolvedSegment { + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + context: ScalarGraphKeyframeContext; + allContexts: ScalarGraphKeyframeContext[]; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +} + interface GraphEditorBaseSelectionState { componentOptions: GraphEditorComponentOption[]; activeComponentKey: string | null; @@ -52,24 +71,9 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { status: "ready"; trackId: string; elementId: string; - propertyPath: SelectedKeyframeRef["propertyPath"]; - keyframeId: string; element: TimelineElement; - /** Primary channel context, used for displaying the curve. */ - context: ScalarGraphKeyframeContext; - /** - * All channel contexts that share this curve. For independent-easing bindings - * this is [context]. For shared-easing bindings (e.g. color) this contains - * all component contexts so patches can be applied to every channel at once. - */ - allContexts: ScalarGraphKeyframeContext[]; + segments: GraphEditorResolvedSegment[]; cubicBezier: NormalizedCubicBezier; - /** - * Y-axis scale used for flat segments (where spanValue ≈ 0). Derived from - * the nearest non-flat adjacent segment so that handle positions correspond - * to a meaningful value range. Always positive. - */ - referenceSpanValue: number; } export type GraphEditorSelectionState = @@ -152,6 +156,40 @@ function findKeyframeTime({ return null; } +function groupSelectedKeyframesByProperty({ + selectedKeyframes, +}: { + selectedKeyframes: SelectedKeyframeRef[]; +}) { + const groups = new Map< + string, + { + trackId: string; + elementId: string; + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframes: SelectedKeyframeRef[]; + } + >(); + + for (const keyframe of selectedKeyframes) { + const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`; + const existingGroup = groups.get(groupKey); + if (existingGroup) { + existingGroup.keyframes.push(keyframe); + continue; + } + + groups.set(groupKey, { + trackId: keyframe.trackId, + elementId: keyframe.elementId, + propertyPath: keyframe.propertyPath, + keyframes: [keyframe], + }); + } + + return [...groups.values()]; +} + function getComponentLabel({ componentKey }: { componentKey: string }): string { switch (componentKey) { case "value": @@ -192,115 +230,82 @@ function getReferenceSpanValue({ return 1.0; } -function isLinearCurve({ - cubicBezier, -}: { - cubicBezier: NormalizedCubicBezier; -}): boolean { - return ( - Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && - Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && - Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && - Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON - ); +interface GraphEditorPropertySelection { + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + secondaryKeyframeId: string | null; + options: GraphEditorPropertyOption[]; } -export function resolveGraphEditorSelectionState({ - tracks, - selectedKeyframes, - preferredComponentKey, +function resolvePropertySelection({ + element, + propertyKeyframes, }: { - tracks: SceneTracks; - selectedKeyframes: SelectedKeyframeRef[]; - preferredComponentKey?: string | null; -}): GraphEditorSelectionState { - if (selectedKeyframes.length === 0) { - return createUnavailableState({ - reason: "no-keyframe-selected", - message: "Select a keyframe to edit its curve.", - }); - } - - if (selectedKeyframes.length > 2) { - return createUnavailableState({ + element: TimelineElement; + propertyKeyframes: ReturnType< + typeof groupSelectedKeyframesByProperty + >[number]; +}): + | GraphEditorPropertySelection + | { + reason: GraphEditorUnavailableReason; + message: string; + } { + if (propertyKeyframes.keyframes.length > 2) { + return { reason: "multiple-keyframes-selected", - message: "Select one or two adjacent keyframes to edit a curve.", - }); + message: "Select at most two adjacent keyframes per property.", + }; } - if (selectedKeyframes.length === 2) { - const [firstKeyframe, secondKeyframe] = selectedKeyframes; - if ( - firstKeyframe.trackId !== secondKeyframe.trackId || - firstKeyframe.elementId !== secondKeyframe.elementId || - firstKeyframe.propertyPath !== secondKeyframe.propertyPath - ) { - return createUnavailableState({ - reason: "multiple-keyframes-selected", - message: "Selected keyframes must be on the same element and property.", - }); - } - } - - const primaryKeyframe = selectedKeyframes[0]; - const secondaryKeyframeId = - selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null; - - const selectedElement = findElementByKeyframe({ - tracks, - keyframe: primaryKeyframe, - }); - if (!selectedElement) { - return createUnavailableState({ - reason: "selected-element-missing", - message: "The selected keyframe could not be resolved.", - }); - } - - if (!selectedElement.element.animations) { - return createUnavailableState({ + if (!element.animations) { + return { reason: "selected-element-has-no-animations", message: "The selected keyframe has no editable graph.", - }); + }; } const scalarResult = getEditableScalarChannels({ - animations: selectedElement.element.animations, - propertyPath: primaryKeyframe.propertyPath, + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, }); if (!scalarResult || scalarResult.channels.length === 0) { - return createUnavailableState({ + return { reason: "selected-keyframe-has-no-scalar-channel", message: "The selected keyframe has no editable graph channel.", - }); + }; } - const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; - // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment - // anchor so the graph editor edits the curve between the two selected keyframes. + const primaryKeyframe = propertyKeyframes.keyframes[0]; let resolvedKeyframeId = primaryKeyframe.keyframeId; + let secondaryKeyframeId = + propertyKeyframes.keyframes.length === 2 + ? propertyKeyframes.keyframes[1].keyframeId + : null; + if (secondaryKeyframeId !== null) { const time1 = findKeyframeTime({ - animations: selectedElement.element.animations, - propertyPath: primaryKeyframe.propertyPath, + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, keyframeId: primaryKeyframe.keyframeId, }); const time2 = findKeyframeTime({ - animations: selectedElement.element.animations, - propertyPath: primaryKeyframe.propertyPath, + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, keyframeId: secondaryKeyframeId, }); if (time2 !== null && (time1 === null || time2 < time1)) { resolvedKeyframeId = secondaryKeyframeId; + secondaryKeyframeId = primaryKeyframe.keyframeId; } } + const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; const easingMode = getEasingModeForKind(resolvedBinding.kind); - const contexts = scalarChannels.flatMap((channel) => { const context = getScalarKeyframeContext({ - animations: selectedElement.element.animations, - propertyPath: primaryKeyframe.propertyPath, + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, componentKey: channel.componentKey, keyframeId: resolvedKeyframeId, }); @@ -320,77 +325,290 @@ export function resolveGraphEditorSelectionState({ }); if (contexts.length === 0) { - return createUnavailableState({ + return { reason: "selected-keyframe-missing-on-channel", message: "The selected keyframe is not editable as a graph segment.", - }); + }; } // For shared-easing bindings (e.g. color), all components always use the same - // curve. Collapse to a single option so no per-component tabs are shown. - const visibleContexts = - easingMode === "shared" ? [contexts[0]] : contexts; - const allContexts = contexts.map(({ context }) => context); + // curve. Collapse them to a single "value" option so the key is compatible with + // single-component scalar bindings (e.g. opacity), enabling mixed selections. + const options = + easingMode === "shared" + ? [ + { + key: "value", + label: "Curve", + context: contexts[0].context, + allContexts: contexts.map(({ context }) => context), + }, + ] + : contexts.map(({ context, option }) => ({ + key: option.key, + label: option.label, + context, + allContexts: [context], + })); - const nextSegmentContexts = visibleContexts.filter( - ({ context }) => context.nextKey !== null, + return { + propertyPath: propertyKeyframes.propertyPath, + keyframeId: resolvedKeyframeId, + secondaryKeyframeId, + options, + }; +} + +function isLinearCurve({ + cubicBezier, +}: { + cubicBezier: NormalizedCubicBezier; +}): boolean { + return ( + Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON ); - const preferredContext = - visibleContexts.find(({ option }) => option.key === preferredComponentKey) ?? null; - const activeContext = - preferredContext ?? nextSegmentContexts[0] ?? visibleContexts[0]; - const componentOptions = visibleContexts.map(({ option }) => option); +} - if (!activeContext.context.nextKey) { - return createUnavailableState({ +function resolveSegmentForOption({ + propertySelection, + componentKey, +}: { + propertySelection: GraphEditorPropertySelection; + componentKey: string; +}): + | { + segment: GraphEditorResolvedSegment; + } + | { + reason: GraphEditorUnavailableReason; + message: string; + } { + const option = propertySelection.options.find( + (propertyOption) => propertyOption.key === componentKey, + ); + if (!option) { + return { + reason: "selected-properties-have-no-shared-component", + message: "Selected properties do not share a graph-editable channel.", + }; + } + + if (!option.context.nextKey) { + return { reason: "selected-keyframe-has-no-next-segment", message: "Select a keyframe that has an outgoing segment.", - componentOptions, - activeComponentKey: activeContext.option.key, - }); + }; } - if (activeContext.context.keyframe.segmentToNext === "step") { - return createUnavailableState({ + if ( + propertySelection.secondaryKeyframeId !== null && + option.context.nextKey.id !== propertySelection.secondaryKeyframeId + ) { + return { + reason: "selected-keyframes-are-not-adjacent", + message: "Selected keyframes must be adjacent on each property.", + }; + } + + if (option.context.keyframe.segmentToNext === "step") { + return { reason: "selected-segment-is-hold", - message: "Hold segments have a fixed value — easing has no effect here.", - componentOptions, - activeComponentKey: activeContext.option.key, - }); + message: "Hold segments have a fixed value - easing has no effect here.", + }; } - const referenceSpanValue = getReferenceSpanValue({ context: activeContext.context }); + const referenceSpanValue = getReferenceSpanValue({ context: option.context }); const cubicBezier = - activeContext.context.keyframe.segmentToNext === "linear" + option.context.keyframe.segmentToNext === "linear" ? GRAPH_LINEAR_CURVE : getNormalizedCubicBezierForScalarSegment({ - leftKey: activeContext.context.keyframe, - rightKey: activeContext.context.nextKey, + leftKey: option.context.keyframe, + rightKey: option.context.nextKey, referenceSpanValue, }); if (!cubicBezier) { - return createUnavailableState({ + return { reason: "selected-segment-is-flat", - message: "Cannot edit a segment where both keyframes are at the same time.", + message: + "Cannot edit a segment where both keyframes are at the same time.", + }; + } + + return { + segment: { + propertyPath: propertySelection.propertyPath, + keyframeId: propertySelection.keyframeId, + context: option.context, + allContexts: option.allContexts, + cubicBezier, + referenceSpanValue, + }, + }; +} + +export function resolveGraphEditorSelectionState({ + tracks, + selectedKeyframes, + preferredComponentKey, +}: { + tracks: SceneTracks; + selectedKeyframes: SelectedKeyframeRef[]; + preferredComponentKey?: string | null; +}): GraphEditorSelectionState { + if (selectedKeyframes.length === 0) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + const propertyKeyframes = groupSelectedKeyframesByProperty({ + selectedKeyframes, + }); + const primaryKeyframe = propertyKeyframes[0]?.keyframes[0]; + if (!primaryKeyframe) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + const selectedElement = findElementByKeyframe({ + tracks, + keyframe: primaryKeyframe, + }); + if (!selectedElement) { + return createUnavailableState({ + reason: "selected-element-missing", + message: "The selected keyframe could not be resolved.", + }); + } + + const spansMultipleElements = propertyKeyframes.some( + (propertySelection) => + propertySelection.trackId !== selectedElement.trackId || + propertySelection.elementId !== selectedElement.elementId, + ); + if (spansMultipleElements) { + return createUnavailableState({ + reason: "selected-keyframes-span-multiple-elements", + message: "Selected keyframes must be on the same element.", + }); + } + + const propertySelections = propertyKeyframes.map((propertySelection) => + resolvePropertySelection({ + element: selectedElement.element, + propertyKeyframes: propertySelection, + }), + ); + const unavailablePropertySelection = propertySelections.find( + (propertySelection) => "reason" in propertySelection, + ); + if ( + unavailablePropertySelection && + "reason" in unavailablePropertySelection + ) { + return createUnavailableState({ + reason: unavailablePropertySelection.reason, + message: unavailablePropertySelection.message, + }); + } + + const resolvedPropertySelections = propertySelections.filter( + (propertySelection): propertySelection is GraphEditorPropertySelection => + !("reason" in propertySelection), + ); + const sharedComponentOptions = + resolvedPropertySelections[0]?.options.filter((componentOption) => + resolvedPropertySelections.every((propertySelection) => + propertySelection.options.some( + (option) => option.key === componentOption.key, + ), + ), + ) ?? []; + const componentOptions = sharedComponentOptions.map(({ key, label }) => ({ + key, + label, + })); + if (componentOptions.length === 0) { + return createUnavailableState({ + reason: "selected-properties-have-no-shared-component", + message: "Selected properties do not share a graph-editable channel.", + }); + } + + // Try each component option in preference order (preferred first, then the rest) + // and stop at the first key where every property resolves to a valid segment. + // This single pass both selects the active key and produces the segment list. + const candidateKeys = [ + ...(preferredComponentKey && + componentOptions.some((option) => option.key === preferredComponentKey) + ? [preferredComponentKey] + : []), + ...componentOptions + .filter((option) => option.key !== preferredComponentKey) + .map((option) => option.key), + ]; + + let activeComponentKey = componentOptions[0].key; + let segmentResults: ReturnType[] = []; + + for (const candidateKey of candidateKeys) { + const results = resolvedPropertySelections.map((propertySelection) => + resolveSegmentForOption({ + propertySelection, + componentKey: candidateKey, + }), + ); + activeComponentKey = candidateKey; + segmentResults = results; + if (results.every((result) => "segment" in result)) { + break; + } + } + + const unavailableSegment = segmentResults.find( + (result) => !("segment" in result), + ); + if (unavailableSegment && !("segment" in unavailableSegment)) { + return createUnavailableState({ + reason: unavailableSegment.reason, + message: unavailableSegment.message, componentOptions, - activeComponentKey: activeContext.option.key, + activeComponentKey, + }); + } + + const segments = segmentResults.flatMap((result) => + "segment" in result ? [result.segment] : [], + ); + const primarySegment = segments[0]; + if (!primarySegment) { + return createUnavailableState({ + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + componentOptions, + activeComponentKey, }); } return { status: "ready", - message: "Edit graph", + message: + segments.length === 1 + ? "Edit graph" + : `Edit graph for ${segments.length} properties`, componentOptions, - activeComponentKey: activeContext.option.key, + activeComponentKey, trackId: selectedElement.trackId, elementId: selectedElement.elementId, - propertyPath: primaryKeyframe.propertyPath, - keyframeId: resolvedKeyframeId, element: selectedElement.element, - context: activeContext.context, - allContexts, - cubicBezier, - referenceSpanValue, + segments, + cubicBezier: primarySegment.cubicBezier, }; } diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts index cd4c792f..51d5a0ff 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -14,8 +14,9 @@ import { export function useGraphEditorController() { const editor = useEditor(); - const renderTracks = useEditor((currentEditor) => - currentEditor.timeline.getPreviewTracks() ?? + const renderTracks = useEditor( + (currentEditor) => + currentEditor.timeline.getPreviewTracks() ?? currentEditor.scenes.getActiveScene().tracks, ); const { selectedKeyframes } = useKeyframeSelection(); @@ -37,7 +38,12 @@ export function useGraphEditorController() { const stateKey = state.status === "ready" - ? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}` + ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments + .map( + (segment) => + `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`, + ) + .join("|")}` : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; const previousStateKeyRef = useRef(stateKey); @@ -96,16 +102,20 @@ export function useGraphEditorController() { return; } - const nextAnimations = state.allContexts.reduce( - (animations, context) => - applyGraphEditorCurvePreview({ - animations, - context, - cubicBezier: nextValue, - referenceSpanValue: state.referenceSpanValue, - }), - state.element.animations, - ); + const nextAnimations = state.segments.reduce( + (animations, segment) => + segment.allContexts.reduce( + (nextAnimationsForSegment, context) => + applyGraphEditorCurvePreview({ + animations: nextAnimationsForSegment, + context, + cubicBezier: nextValue, + referenceSpanValue: segment.referenceSpanValue, + }), + animations, + ), + state.element.animations, + ); editor.timeline.previewElements({ updates: [ { @@ -126,28 +136,28 @@ export function useGraphEditorController() { return; } - // Build patches from the primary context (all shared-easing channels have - // the same keyframe IDs, so the same patches apply to each). - const patches = buildGraphEditorCurvePatches({ - context: state.context, - cubicBezier: nextValue, - referenceSpanValue: state.referenceSpanValue, - }); - if (!patches) { - return; - } - editor.timeline.updateKeyframeCurves({ - keyframes: state.allContexts.flatMap((context) => - patches.map(({ keyframeId, patch }) => ({ - trackId: state.trackId, - elementId: state.elementId, - propertyPath: state.propertyPath, - componentKey: context.componentKey, - keyframeId, - patch, - })), - ), + keyframes: state.segments.flatMap((segment) => { + const patches = buildGraphEditorCurvePatches({ + context: segment.context, + cubicBezier: nextValue, + referenceSpanValue: segment.referenceSpanValue, + }); + if (!patches) { + return []; + } + + return segment.allContexts.flatMap((context) => + patches.map(({ keyframeId, patch }) => ({ + trackId: state.trackId, + elementId: state.elementId, + propertyPath: segment.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + })), + ); + }), }); hasPreviewRef.current = false; }, diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 630b287b..ff77e0b4 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -1,347 +1,251 @@ -"use client"; - -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { TActionWithOptionalArgs } from "@/lib/actions"; -import { getDefaultShortcuts } from "@/lib/actions"; -import { isTypableDOMElement } from "@/utils/browser"; -import { isAppleDevice } from "@/utils/platform"; -import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding"; -import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; - -const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); - -export interface KeybindingConflict { - key: ShortcutKey; - existingAction: TActionWithOptionalArgs; - newAction: TActionWithOptionalArgs; -} - -interface KeybindingsState { - keybindings: KeybindingConfig; - isCustomized: boolean; - overlayDepth: number; - openOverlayIds: string[]; - isLoadingProject: boolean; - isRecording: boolean; - - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; - removeKeybinding: (key: ShortcutKey) => void; - resetToDefaults: () => void; - importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; - openOverlay: (overlayId: string, source: string) => void; - closeOverlay: (overlayId: string, source: string) => void; - setLoadingProject: (loading: boolean) => void; - setIsRecording: (isRecording: boolean) => void; - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => KeybindingConflict | null; - getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; - getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; -} - -function isDOMElement(element: EventTarget | null): element is HTMLElement { - return element instanceof HTMLElement; -} - -export const useKeybindingsStore = create()( - persist( - (set, get) => ({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - overlayDepth: 0, - openOverlayIds: [], - isLoadingProject: false, - isRecording: false, - - openOverlay: (overlayId, source) => - set((s) => { - const openOverlayIds = s.openOverlayIds.includes(overlayId) - ? s.openOverlayIds - : [...s.openOverlayIds, overlayId]; - const nextOverlayDepth = openOverlayIds.length; - // #region agent log - fetch( - "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Debug-Session-Id": "3997d9", - }, - body: JSON.stringify({ - sessionId: "3997d9", - runId: "initial", - hypothesisId: "H2", - location: "keybindings-store.ts:openOverlay", - message: "Overlay depth incremented", - data: { - source, - overlayId, - overlayDepth: s.overlayDepth, - nextOverlayDepth, - openOverlayIds, - }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion - return { - openOverlayIds, - overlayDepth: nextOverlayDepth, - }; - }), - closeOverlay: (overlayId, source) => - set((s) => { - const openOverlayIds = s.openOverlayIds.filter( - (id) => id !== overlayId, - ); - const nextOverlayDepth = openOverlayIds.length; - // #region agent log - fetch( - "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Debug-Session-Id": "3997d9", - }, - body: JSON.stringify({ - sessionId: "3997d9", - runId: "initial", - hypothesisId: "H2", - location: "keybindings-store.ts:closeOverlay", - message: "Overlay depth decremented", - data: { - source, - overlayId, - overlayDepth: s.overlayDepth, - nextOverlayDepth, - openOverlayIds, - }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion - return { - openOverlayIds, - overlayDepth: nextOverlayDepth, - }; - }), - setLoadingProject: (loading) => { - // #region agent log - fetch( - "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Debug-Session-Id": "3997d9", - }, - body: JSON.stringify({ - sessionId: "3997d9", - runId: "initial", - hypothesisId: "H2", - location: "keybindings-store.ts:setLoadingProject", - message: "Loading gate updated", - data: { loading }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion - set({ isLoadingProject: loading }); - }, - - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, - - removeKeybinding: (key: ShortcutKey) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, - - resetToDefaults: () => { - set({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - }); - }, - - importKeybindings: (config: KeybindingConfig) => { - for (const [key] of Object.entries(config)) { - if (typeof key !== "string" || key.length === 0) { - throw new Error(`Invalid key format: ${key}`); - } - } - set({ - keybindings: { ...config }, - isCustomized: true, - }); - }, - - exportKeybindings: () => { - return get().keybindings; - }, - - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - - if (existingAction && existingAction !== action) { - return { - key, - existingAction, - newAction: action, - }; - } - - return null; - }, - setIsRecording: (isRecording: boolean) => { - // #region agent log - fetch( - "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Debug-Session-Id": "3997d9", - }, - body: JSON.stringify({ - sessionId: "3997d9", - runId: "initial", - hypothesisId: "H2", - location: "keybindings-store.ts:setIsRecording", - message: "Recording gate updated", - data: { isRecording }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion - set({ isRecording }); - }, - - getKeybindingsForAction: (action: TActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; - }, - - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, - }), - { - name: "opencut-keybindings", - version: CURRENT_VERSION, - partialize: (state) => ({ - keybindings: state.keybindings, - isCustomized: state.isCustomized, - }), - migrate: (persisted, version) => - runMigrations({ state: persisted, fromVersion: version }), - }, - ), -); - -function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { - const target = ev.target; - const modifierKey = getActiveModifier(ev); - const key = getPressedKey(ev); - if (!key) return null; - - if (modifierKey) { - if ( - modifierKey === "shift" && - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) { - return null; - } - - return `${modifierKey}+${key}` as ShortcutKey; - } - - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) - return null; - - return `${key}` as ShortcutKey; -} - -function getPressedKey(ev: KeyboardEvent): string | null { - const key = (ev.key ?? "").toLowerCase(); - const code = ev.code ?? ""; - - if (code === "Space" || key === " " || key === "spacebar" || key === "space") - return "space"; - - if (key.startsWith("arrow")) return key.slice(5); - - if (key === "escape") return "escape"; - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; - - if (code.startsWith("Key")) { - const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; - } - - // Use physical key position for AZERTY and other non-QWERTY layouts - if (code.startsWith("Digit")) { - const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; - } - - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; - - if (key === "/" || key === "." || key === "enter") return key; - - return null; -} - -function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; - - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); - - return activeModifier === "" ? null : activeModifier; -} +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TActionWithOptionalArgs } from "@/lib/actions"; +import { getDefaultShortcuts } from "@/lib/actions"; +import { isTypableDOMElement } from "@/utils/browser"; +import { isAppleDevice } from "@/utils/platform"; +import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding"; +import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; + +const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); + +export interface KeybindingConflict { + key: ShortcutKey; + existingAction: TActionWithOptionalArgs; + newAction: TActionWithOptionalArgs; +} + +interface KeybindingsState { + keybindings: KeybindingConfig; + isCustomized: boolean; + overlayDepth: number; + openOverlayIds: string[]; + isLoadingProject: boolean; + isRecording: boolean; + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + openOverlay: (overlayId: string) => void; + closeOverlay: (overlayId: string) => void; + setLoadingProject: (loading: boolean) => void; + setIsRecording: (isRecording: boolean) => void; + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; +} + +function isDOMElement(element: EventTarget | null): element is HTMLElement { + return element instanceof HTMLElement; +} + +export const useKeybindingsStore = create()( + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + overlayDepth: 0, + openOverlayIds: [], + isLoadingProject: false, + isRecording: false, + + openOverlay: (overlayId) => + set((s) => { + const openOverlayIds = s.openOverlayIds.includes(overlayId) + ? s.openOverlayIds + : [...s.openOverlayIds, overlayId]; + const nextOverlayDepth = openOverlayIds.length; + return { + openOverlayIds, + overlayDepth: nextOverlayDepth, + }; + }), + closeOverlay: (overlayId) => + set((s) => { + const openOverlayIds = s.openOverlayIds.filter( + (id) => id !== overlayId, + ); + const nextOverlayDepth = openOverlayIds.length; + return { + openOverlayIds, + overlayDepth: nextOverlayDepth, + }; + }), + setLoadingProject: (loading) => { + set({ isLoadingProject: loading }); + }, + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, + + importKeybindings: (config: KeybindingConfig) => { + for (const [key] of Object.entries(config)) { + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } + set({ + keybindings: { ...config }, + isCustomized: true, + }); + }, + + exportKeybindings: () => { + return get().keybindings; + }, + + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; + + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } + + return null; + }, + setIsRecording: (isRecording: boolean) => { + set({ isRecording }); + }, + + getKeybindingsForAction: (action: TActionWithOptionalArgs) => { + const { keybindings } = get(); + return Object.keys(keybindings).filter( + (key) => keybindings[key as ShortcutKey] === action, + ) as ShortcutKey[]; + }, + + getKeybindingString: (ev: KeyboardEvent) => { + return generateKeybindingString(ev) as ShortcutKey | null; + }, + }), + { + name: "opencut-keybindings", + version: CURRENT_VERSION, + partialize: (state) => ({ + keybindings: state.keybindings, + isCustomized: state.isCustomized, + }), + migrate: (persisted, version) => + runMigrations({ state: persisted, fromVersion: version }), + }, + ), +); + +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { + const target = ev.target; + const modifierKey = getActiveModifier(ev); + const key = getPressedKey(ev); + if (!key) return null; + + if (modifierKey) { + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) { + return null; + } + + return `${modifierKey}+${key}` as ShortcutKey; + } + + if ( + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) + return null; + + return `${key}` as ShortcutKey; +} + +function getPressedKey(ev: KeyboardEvent): string | null { + const key = (ev.key ?? "").toLowerCase(); + const code = ev.code ?? ""; + + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + + if (key.startsWith("arrow")) return key.slice(5); + + if (key === "escape") return "escape"; + if (key === "tab") return "tab"; + if (key === "home") return "home"; + if (key === "end") return "end"; + if (key === "delete") return "delete"; + if (key === "backspace") return "backspace"; + + if (code.startsWith("Key")) { + const letter = code.slice(3).toLowerCase(); + if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + } + + // Use physical key position for AZERTY and other non-QWERTY layouts + if (code.startsWith("Digit")) { + const digit = code.slice(5); + if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + } + + const isDigit = key.length === 1 && key >= "0" && key <= "9"; + if (isDigit) return key; + + if (key === "/" || key === "." || key === "enter") return key; + + return null; +} + +function getActiveModifier(ev: KeyboardEvent): string | null { + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + }; + + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("+"); + + return activeModifier === "" ? null : activeModifier; +}