feat: graph editor supports multiple properties at once
This commit is contained in:
parent
3db30c72e7
commit
dc0a8e7c96
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue