feat: graph editor supports multiple properties at once

This commit is contained in:
Maze Winther 2026-04-15 00:59:54 +02:00
parent 3db30c72e7
commit dc0a8e7c96
3 changed files with 644 additions and 512 deletions

View File

@ -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<typeof resolveSegmentForOption>[] = [];
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,
};
}

View File

@ -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;
},

View File

@ -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<KeybindingsState>()(
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<KeybindingsState>()(
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;
}