diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/commands/timeline/element/masks | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/commands/timeline/element/masks')
5 files changed, 445 insertions, 0 deletions
diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts new file mode 100644 index 00000000..1445b1f8 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -0,0 +1,131 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + getCustomMaskClosedStateAfterPointRemoval, + removeCustomMaskPoints, +} from "@/masks/custom-path"; +import type { CustomMask } from "@/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { MaskableElement, SceneTracks } from "@/timeline"; + +function deletePointsFromCustomMask({ + mask, + pointIds, +}: { + mask: CustomMask; + pointIds: string[]; +}): CustomMask { + const points = mask.params.path; + const nextPoints = removeCustomMaskPoints({ points, pointIds }); + if (nextPoints.length === points.length) { + return mask; + } + + return { + ...mask, + params: { + ...mask.params, + path: nextPoints, + closed: getCustomMaskClosedStateAfterPointRemoval({ + wasClosed: mask.params.closed, + remainingPointCount: nextPoints.length, + }), + }, + }; +} + +function deletePointsFromElementMask({ + element, + maskId, + pointIds, +}: { + element: MaskableElement; + maskId: string; + pointIds: string[]; +}): { element: MaskableElement; didDeletePoints: boolean } { + const currentMasks = element.masks ?? []; + let didDeletePoints = false; + const nextMasks = currentMasks.map((mask) => { + if (mask.id !== maskId || mask.type !== "custom") { + return mask; + } + + const nextMask = deletePointsFromCustomMask({ + mask, + pointIds, + }); + didDeletePoints ||= nextMask !== mask; + return nextMask; + }); + + return { + element: didDeletePoints ? { ...element, masks: nextMasks } : element, + didDeletePoints, + }; +} + +export class DeleteCustomMaskPointsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + private readonly pointIds: string[]; + + constructor({ + trackId, + elementId, + maskId, + pointIds, + }: { + trackId: string; + elementId: string; + maskId: string; + pointIds: string[]; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + this.pointIds = pointIds; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let didDeletePoints = false; + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => { + const result = deletePointsFromElementMask({ + element: element as MaskableElement, + maskId: this.maskId, + pointIds: this.pointIds, + }); + didDeletePoints ||= result.didDeletePoints; + return result.element; + }, + }); + + if (didDeletePoints) { + editor.timeline.updateTracks(updatedTracks); + return { + selection: { + selectedMaskPoints: null, + }, + }; + } + + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts new file mode 100644 index 00000000..dd6373b1 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -0,0 +1,4 @@ +export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { RemoveMaskCommand } from "./remove-mask"; +export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts new file mode 100644 index 00000000..58559af6 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -0,0 +1,169 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { CustomMask } from "@/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { MaskableElement, SceneTracks } from "@/timeline"; + +function insertPointIntoCustomMask({ + mask, + segmentIndex, + canvasPoint, + bounds, +}: { + mask: CustomMask; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): { mask: CustomMask; insertedPointId: string | null } { + const result = insertPointOnCustomMaskSegment({ + params: mask.params, + segmentIndex, + canvasPoint, + bounds, + }); + if (!result) { + return { + mask, + insertedPointId: null, + }; + } + + return { + mask: { + ...mask, + params: result.params, + }, + insertedPointId: result.pointId, + }; +} + +function insertPointIntoElementMask({ + element, + maskId, + segmentIndex, + canvasPoint, + bounds, +}: { + element: MaskableElement; + maskId: string; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): { + element: MaskableElement; + didInsertPoint: boolean; + insertedPointId: string | null; +} { + const currentMasks = element.masks ?? []; + let insertedPointId: string | null = null; + let didInsertPoint = false; + + const nextMasks = currentMasks.map((mask) => { + if (mask.id !== maskId || mask.type !== "custom") { + return mask; + } + + const result = insertPointIntoCustomMask({ + mask, + segmentIndex, + canvasPoint, + bounds, + }); + if (result.insertedPointId) { + insertedPointId = result.insertedPointId; + didInsertPoint = true; + } + return result.mask; + }); + + return { + element: didInsertPoint ? { ...element, masks: nextMasks } : element, + didInsertPoint, + insertedPointId, + }; +} + +export class InsertCustomMaskPointCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + private readonly segmentIndex: number; + private readonly canvasPoint: { x: number; y: number }; + private readonly bounds: ElementBounds; + + constructor({ + trackId, + elementId, + maskId, + segmentIndex, + canvasPoint, + bounds, + }: { + trackId: string; + elementId: string; + maskId: string; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + this.segmentIndex = segmentIndex; + this.canvasPoint = canvasPoint; + this.bounds = bounds; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let didInsertPoint = false; + let insertedPointId: string | null = null; + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => { + const result = insertPointIntoElementMask({ + element: element as MaskableElement, + maskId: this.maskId, + segmentIndex: this.segmentIndex, + canvasPoint: this.canvasPoint, + bounds: this.bounds, + }); + didInsertPoint ||= result.didInsertPoint; + insertedPointId ??= result.insertedPointId; + return result.element; + }, + }); + + if (!didInsertPoint || !insertedPointId) { + return undefined; + } + + editor.timeline.updateTracks(updatedTracks); + return { + selection: { + selectedMaskPoints: { + trackId: this.trackId, + elementId: this.elementId, + maskId: this.maskId, + pointIds: [insertedPointId], + }, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts new file mode 100644 index 00000000..b543bce1 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -0,0 +1,65 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, MaskableElement } from "@/timeline"; + +function removeMaskFromElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); + return { ...element, masks: filteredMasks }; +} + +export class RemoveMaskCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + removeMaskFromElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts new file mode 100644 index 00000000..fec9b923 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -0,0 +1,76 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { Mask } from "@/masks/types"; +import type { SceneTracks, MaskableElement } from "@/timeline"; + +export function toggleMaskInvertedOnElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({ + ...mask, + params: { + ...mask.params, + inverted: !mask.params.inverted, + }, + }); + const updatedMasks = currentMasks.map((mask) => + mask.id !== maskId ? mask : toggleMask(mask), + ); + + return { ...element, masks: updatedMasks }; +} + +export class ToggleMaskInvertedCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + toggleMaskInvertedOnElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} |
