From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- apps/web/src/lib/commands/base-command.ts | 4 +- .../commands/timeline/element/delete-elements.ts | 7 +- .../commands/timeline/element/insert-element.ts | 33 ++-- .../element/masks/delete-custom-mask-points.ts | 131 ++++++++++++++++ .../lib/commands/timeline/element/masks/index.ts | 2 + .../element/masks/insert-custom-mask-point.ts | 169 +++++++++++++++++++++ 6 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts create mode 100644 apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts (limited to 'apps/web/src/lib/commands') diff --git a/apps/web/src/lib/commands/base-command.ts b/apps/web/src/lib/commands/base-command.ts index a8ce1d34..2d0d04b7 100644 --- a/apps/web/src/lib/commands/base-command.ts +++ b/apps/web/src/lib/commands/base-command.ts @@ -1,7 +1,7 @@ -import type { ElementRef } from "@/lib/timeline/types"; +import type { EditorSelectionPatch } from "@/lib/selection/editor-selection"; export interface CommandResult { - select?: ElementRef[]; + selection?: EditorSelectionPatch; } export abstract class Command { diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 8d93c6ba..0fc7948f 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -54,7 +54,12 @@ export class DeleteElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks); return { - select: [], + selection: { + selectedElements: [], + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, }; } diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index 0ac11ebd..0e806d6e 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -102,18 +102,25 @@ export class InsertElementCommand extends Command { }); } - if (asset?.type === "video" && asset?.fps) { - editor.project.updateSettings({ - settings: { fps: floatToFrameRate(asset.fps) }, - pushHistory: false, - }); - } + if (asset?.type === "video" && asset?.fps) { + editor.project.updateSettings({ + settings: { fps: floatToFrameRate(asset.fps) }, + pushHistory: false, + }); + } } editor.timeline.updateTracks(updatedTracks); return { - select: [{ trackId: targetTrackId, elementId: this.elementId }], + selection: { + selectedElements: [ + { trackId: targetTrackId, elementId: this.elementId }, + ], + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, }; } @@ -173,7 +180,10 @@ export class InsertElementCommand extends Command { if (element.type === "graphic") { registerDefaultGraphics(); - if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) { + if ( + !element.definitionId || + !graphicsRegistry.has(element.definitionId) + ) { console.error("Graphic element must have a valid definitionId"); return false; } @@ -236,8 +246,8 @@ export class InsertElementCommand extends Command { const targetTrack = tracks.main.id === placement.trackId ? tracks.main - : tracks.overlay.find((track) => track.id === placement.trackId) ?? - tracks.audio.find((track) => track.id === placement.trackId); + : (tracks.overlay.find((track) => track.id === placement.trackId) ?? + tracks.audio.find((track) => track.id === placement.trackId)); if (!targetTrack) { console.error("Track not found:", placement.trackId); return null; @@ -257,8 +267,7 @@ export class InsertElementCommand extends Command { placementResult.kind === "existingTrack" ? { ...element, - startTime: - placementResult.adjustedStartTime ?? element.startTime, + startTime: placementResult.adjustedStartTime ?? element.startTime, } : element; diff --git a/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts new file mode 100644 index 00000000..346eb8d9 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -0,0 +1,131 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { + getCustomMaskClosedStateAfterPointRemoval, + removeCustomMaskPoints, +} from "@/lib/masks/custom-path"; +import type { CustomMask } from "@/lib/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { MaskableElement, SceneTracks } from "@/lib/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/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts index acc47184..dd6373b1 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/index.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts @@ -1,2 +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/lib/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts new file mode 100644 index 00000000..9f837836 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -0,0 +1,169 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { insertPointOnCustomMaskSegment } from "@/lib/masks/definitions/custom"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { CustomMask } from "@/lib/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { MaskableElement, SceneTracks } from "@/lib/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); + } + } +} -- cgit v1.3.1