summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 00:59:54 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 00:59:54 +0200
commitdc0a8e7c963823432d8c89b6970ead024f06b091 (patch)
tree1a30dff9c329e81d280cff0f437541569f65b874 /apps/web/src
parent3db30c72e76c1df16dc9723cf7382861211aa6a4 (diff)
downloadopencut-dc0a8e7c963823432d8c89b6970ead024f06b091.tar.gz
feat: graph editor supports multiple properties at once
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts480
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts78
-rw-r--r--apps/web/src/stores/keybindings-store.ts598
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.",
- });
- }
-
- 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.",
- });
+ message: "Select at most two adjacent keyframes per property.",
+ };
}
- 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],
+ }));
+
+ return {
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: resolvedKeyframeId,
+ secondaryKeyframeId,
+ options,
+ };
+}
- const nextSegmentContexts = visibleContexts.filter(
- ({ context }) => context.nextKey !== null,
+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,
};
}
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<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;
+}