summaryrefslogtreecommitdiff
path: root/apps/web/src/actions/use-editor-actions.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/actions/use-editor-actions.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/actions/use-editor-actions.ts')
-rw-r--r--apps/web/src/actions/use-editor-actions.ts488
1 files changed, 488 insertions, 0 deletions
diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts
new file mode 100644
index 00000000..e62a8ce0
--- /dev/null
+++ b/apps/web/src/actions/use-editor-actions.ts
@@ -0,0 +1,488 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import { useActionHandler } from "@/actions/use-action-handler";
+import { useEditor } from "@/editor/use-editor";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
+import { getElementsAtTime, hasMediaId } from "@/timeline";
+import { cancelInteraction } from "@/editor/cancel-interaction";
+import { invokeAction } from "@/actions";
+import { canToggleSourceAudio } from "@/timeline/audio-separation";
+import {
+ activateScope,
+ clearActiveScope,
+ type ScopeEntry,
+} from "@/selection/scope";
+
+export function useEditorActions() {
+ const editor = useEditor();
+ const { selectedElements, setElementSelection } = useElementSelection();
+ const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
+ const selectedMaskPointSelection = useEditor((e) =>
+ e.selection.getSelectedMaskPointSelection(),
+ );
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
+ const hasTimelineSelectionRef = useRef(false);
+ const clearTimelineSelectionRef = useRef(() => {});
+ const clearTimelineActiveSelectionRef = useRef(() => {});
+ const timelineScopeRef = useRef<ScopeEntry | null>(null);
+ const hasTimelineSelection =
+ selectedElements.length > 0 ||
+ selectedKeyframes.length > 0 ||
+ selectedMaskPointSelection !== null;
+
+ hasTimelineSelectionRef.current = hasTimelineSelection;
+ clearTimelineSelectionRef.current = () => {
+ editor.selection.clearSelection();
+ };
+ clearTimelineActiveSelectionRef.current = () => {
+ editor.selection.clearMostSpecificSelection();
+ };
+
+ if (!timelineScopeRef.current) {
+ timelineScopeRef.current = {
+ hasSelection: () => hasTimelineSelectionRef.current,
+ clear: () => {
+ clearTimelineSelectionRef.current();
+ },
+ clearActive: () => {
+ clearTimelineActiveSelectionRef.current();
+ },
+ };
+ }
+
+ useEffect(() => {
+ if (!hasTimelineSelection) {
+ return;
+ }
+
+ const timelineScope = timelineScopeRef.current;
+ if (!timelineScope) {
+ return;
+ }
+
+ return activateScope({ entry: timelineScope });
+ }, [hasTimelineSelection]);
+
+ useActionHandler(
+ "toggle-play",
+ () => {
+ editor.playback.toggle();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "stop-playback",
+ () => {
+ if (editor.playback.getIsPlaying()) {
+ editor.playback.toggle();
+ }
+ editor.playback.seek({ time: 0 });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "seek-forward",
+ (args) => {
+ const seconds = args?.seconds ?? 1;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + seconds,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "seek-backward",
+ (args) => {
+ const seconds = args?.seconds ?? 1;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "frame-step-forward",
+ () => {
+ const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + ticksPerFrame,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "frame-step-backward",
+ () => {
+ const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "jump-forward",
+ (args) => {
+ const seconds = args?.seconds ?? 5;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + seconds,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "jump-backward",
+ (args) => {
+ const seconds = args?.seconds ?? 5;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "goto-start",
+ () => {
+ editor.playback.seek({ time: 0 });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "goto-end",
+ () => {
+ editor.playback.seek({ time: editor.timeline.getTotalDuration() });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks,
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split-left",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks,
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ const rightSideElements = editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ retainSide: "right",
+ });
+
+ if (rippleEditingEnabled && rightSideElements.length > 0) {
+ const firstRightElement = editor.timeline.getElementsWithTracks({
+ elements: [rightSideElements[0]],
+ })[0];
+ if (firstRightElement) {
+ editor.playback.seek({ time: firstRightElement.element.startTime });
+ }
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split-right",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks,
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ retainSide: "left",
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "delete-selected",
+ () => {
+ switch (editor.selection.getActiveSelectionKind()) {
+ case "mask-points":
+ if (!selectedMaskPointSelection) {
+ return;
+ }
+ editor.timeline.deleteCustomMaskPoints({
+ trackId: selectedMaskPointSelection.trackId,
+ elementId: selectedMaskPointSelection.elementId,
+ maskId: selectedMaskPointSelection.maskId,
+ pointIds: selectedMaskPointSelection.pointIds,
+ });
+ return;
+ case "keyframes":
+ if (selectedKeyframes.length === 0) {
+ return;
+ }
+ editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
+ clearKeyframeSelection();
+ return;
+ case "elements":
+ if (selectedElements.length === 0) {
+ return;
+ }
+ editor.timeline.deleteElements({
+ elements: selectedElements,
+ });
+ return;
+ default:
+ return;
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-source-audio",
+ () => {
+ if (selectedElements.length !== 1) {
+ return;
+ }
+
+ const selectedElement = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ })[0];
+ if (!selectedElement) {
+ return;
+ }
+
+ const mediaAsset = (() => {
+ const { element } = selectedElement;
+ if (!hasMediaId(element)) {
+ return null;
+ }
+
+ return (
+ editor.media
+ .getAssets()
+ .find((asset) => asset.id === element.mediaId) ?? null
+ );
+ })();
+ if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
+ return;
+ }
+
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId: selectedElement.track.id,
+ elementId: selectedElement.element.id,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "select-all",
+ () => {
+ const scene = editor.scenes.getActiveScene();
+ const allElements = [
+ ...scene.tracks.overlay,
+ scene.tracks.main,
+ ...scene.tracks.audio,
+ ].flatMap((track) =>
+ track.elements.map((element) => ({
+ trackId: track.id,
+ elementId: element.id,
+ })),
+ );
+ setElementSelection({ elements: allElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "cancel-interaction",
+ () => {
+ if (!cancelInteraction()) {
+ invokeAction("deselect-all");
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "deselect-all",
+ () => {
+ if (!clearActiveScope()) {
+ editor.selection.clearMostSpecificSelection();
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "duplicate-selected",
+ () => {
+ editor.timeline.duplicateElements({
+ elements: selectedElements,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-elements-muted-selected",
+ () => {
+ editor.timeline.toggleElementsMuted({ elements: selectedElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-elements-visibility-selected",
+ () => {
+ editor.timeline.toggleElementsVisibility({ elements: selectedElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-bookmark",
+ () => {
+ editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "copy-selected",
+ () => {
+ editor.clipboard.copy();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "paste-copied",
+ () => {
+ editor.clipboard.paste();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-snapping",
+ () => {
+ toggleSnapping();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-ripple-editing",
+ () => {
+ toggleRippleEditing();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "undo",
+ () => {
+ editor.command.undo();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "redo",
+ () => {
+ editor.command.redo();
+ },
+ undefined,
+ );
+
+ // todo: potnetially unify these two actions:
+ useActionHandler(
+ "remove-media-asset",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAsset({
+ projectId: args.projectId,
+ id: args.assetId,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "remove-media-assets",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAssets({
+ projectId: args.projectId,
+ ids: args.assetIds,
+ });
+ },
+ undefined,
+ );
+}