diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3e0023..8d795fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [2.1.3] - 2026-03-10 + +### Fixed + +- Better, more intuitive snapping and guide lines. +- Timeline clip selection + ## [2.1.2] - 2026-03-06 ### Fixed diff --git a/package.json b/package.json index 1c6dab8e..b392c616 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dazzatron", "kratos2k7" ], - "version": "2.1.2", + "version": "2.1.3", "description": "A video editing library for creating and editing videos with Shotstack", "type": "module", "main": "dist/shotstack-studio.umd.js", @@ -90,7 +90,7 @@ }, "dependencies": { "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.0.13", + "@shotstack/shotstack-canvas": "^2.0.14", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index da31cac7..cfcbe5c6 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -16,7 +16,7 @@ import { } from "@core/timing/types"; import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; -import { PositionBuilder } from "@layouts/position-builder"; +import { relativeToAbsolute, absoluteToRelative } from "@layouts/position-builder"; import { type Clip, type ResolvedClip, type Keyframe } from "@schemas"; import * as pixi from "pixi.js"; @@ -74,7 +74,6 @@ export abstract class Player extends Entity { private resolvedTiming: ResolvedTiming; - private positionBuilder: PositionBuilder; private offsetXKeyframeBuilder?: ComposedKeyframeBuilder; private offsetYKeyframeBuilder?: ComposedKeyframeBuilder; private scaleKeyframeBuilder?: ComposedKeyframeBuilder; @@ -97,7 +96,6 @@ export abstract class Player extends Entity { this.playerType = playerType; this.clipConfiguration = clipConfiguration; - this.positionBuilder = new PositionBuilder(edit.size); this.resolvedTiming = { start: clipConfiguration.start, length: clipConfiguration.length }; @@ -416,7 +414,7 @@ export abstract class Player extends Entity { y: this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0 }; - return this.positionBuilder.relativeToAbsolute(this.getSize(), this.clipConfiguration.position ?? "center", offset); + return relativeToAbsolute(this.edit.size, this.getSize(), this.clipConfiguration.position ?? "center", offset); } public getPivot(): Vector { @@ -434,7 +432,7 @@ export abstract class Player extends Entity { const currentPos = this.getPosition(); const newAbsolutePos = { x: currentPos.x + deltaX, y: currentPos.y + deltaY }; - const relativePos = this.positionBuilder.absoluteToRelative(this.getSize(), this.clipConfiguration.position ?? "center", newAbsolutePos); + const relativePos = absoluteToRelative(this.edit.size, this.getSize(), this.clipConfiguration.position ?? "center", newAbsolutePos); return { x: relativePos.x, y: relativePos.y }; } diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts index 83190a32..de375987 100644 --- a/src/components/timeline/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -45,12 +45,12 @@ export class ClipComponent { this.element = document.createElement("div"); this.element.className = "ss-clip"; this.options = options; - this.buildElement(clip); this.currentState = clip; + this.buildElement(); this.element.dataset["clipId"] = clip.id; } - private buildElement(clip: ClipState): void { + private buildElement(): void { // Content container const content = document.createElement("div"); content.className = "ss-clip-content"; @@ -84,10 +84,10 @@ export class ClipComponent { this.element.appendChild(rightHandle); // Set up interaction handlers - this.setupInteraction(clip); + this.setupInteraction(); } - private setupInteraction(clip: ClipState): void { + private setupInteraction(): void { this.element.addEventListener("pointerdown", e => { // Check if clicking on resize handle const target = e.target as HTMLElement; @@ -96,9 +96,11 @@ export class ClipComponent { return; } + if (!this.currentState) return; + // Select clip const addToSelection = e.shiftKey || e.ctrlKey || e.metaKey; - this.options.onSelect(clip.trackIndex, clip.clipIndex, addToSelection); + this.options.onSelect(this.currentState.trackIndex, this.currentState.clipIndex, addToSelection); }); } diff --git a/src/core/interaction/snap-system.ts b/src/core/interaction/snap-system.ts index 419a0511..fa080ebb 100644 --- a/src/core/interaction/snap-system.ts +++ b/src/core/interaction/snap-system.ts @@ -249,41 +249,66 @@ export function snapToClips(position: Vector, clipSize: Size, otherClips: ClipBo /** * Combined snap function that checks both canvas and clips. - * Clip snapping takes priority over canvas snapping when both are within threshold. + * Closest snap wins per axis. Canvas wins ties (centering is the most intentional action). * Pure function - no side effects. */ export function snap(position: Vector, context: SnapContext): SnapResult { const { clipSize, canvasSize, otherClips, config } = context; const { threshold, snapToCanvas: doSnapToCanvas, snapToClips: doSnapToClips } = config; - let result: SnapResult = { position: { ...position }, guides: [] }; + // Run both snap types on the original position so distances are comparable + const canvasResult = doSnapToCanvas + ? snapToCanvas(position, clipSize, canvasSize, threshold) + : { position: { ...position }, guides: [] }; - // First apply canvas snapping - if (doSnapToCanvas) { - result = snapToCanvas(result.position, clipSize, canvasSize, threshold); - } + const clipResult = (doSnapToClips && otherClips.length > 0) + ? snapToClips(position, clipSize, otherClips, threshold) + : { position: { ...position }, guides: [] }; - // Then apply clip snapping (takes priority - will override canvas snap if closer) - if (doSnapToClips && otherClips.length > 0) { - const clipResult = snapToClips(result.position, clipSize, otherClips, threshold); + const result: SnapResult = { position: { ...position }, guides: [] }; - // Merge results - clip snaps take priority - const hasClipSnapX = clipResult.guides.some(g => g.axis === "x"); - const hasClipSnapY = clipResult.guides.some(g => g.axis === "y"); + // X axis: closest snap wins (canvas wins ties) + const hasCanvasX = canvasResult.guides.some(g => g.axis === "x"); + const hasClipX = clipResult.guides.some(g => g.axis === "x"); - if (hasClipSnapX) { + if (hasCanvasX && hasClipX) { + const canvasDist = Math.abs(canvasResult.position.x - position.x); + const clipDist = Math.abs(clipResult.position.x - position.x); + if (clipDist < canvasDist) { result.position.x = clipResult.position.x; - // Replace canvas X guide with clip X guide - result.guides = result.guides.filter(g => g.axis !== "x"); + result.guides.push(...clipResult.guides.filter(g => g.axis === "x")); + } else { + result.position.x = canvasResult.position.x; + result.guides.push(...canvasResult.guides.filter(g => g.axis === "x")); } - if (hasClipSnapY) { + } else if (hasClipX) { + result.position.x = clipResult.position.x; + result.guides.push(...clipResult.guides.filter(g => g.axis === "x")); + } else if (hasCanvasX) { + result.position.x = canvasResult.position.x; + result.guides.push(...canvasResult.guides.filter(g => g.axis === "x")); + } + + // Y axis: closest snap wins (canvas wins ties) + const hasCanvasY = canvasResult.guides.some(g => g.axis === "y"); + const hasClipY = clipResult.guides.some(g => g.axis === "y"); + + if (hasCanvasY && hasClipY) { + const canvasDist = Math.abs(canvasResult.position.y - position.y); + const clipDist = Math.abs(clipResult.position.y - position.y); + if (clipDist < canvasDist) { result.position.y = clipResult.position.y; - // Replace canvas Y guide with clip Y guide - result.guides = result.guides.filter(g => g.axis !== "y"); + result.guides.push(...clipResult.guides.filter(g => g.axis === "y")); + } else { + result.position.y = canvasResult.position.y; + result.guides.push(...canvasResult.guides.filter(g => g.axis === "y")); } - - // Add clip guides - result.guides.push(...clipResult.guides); + } else if (hasClipY) { + result.position.y = clipResult.position.y; + result.guides.push(...clipResult.guides.filter(g => g.axis === "y")); + } else if (hasCanvasY) { + result.position.y = canvasResult.position.y; + result.guides.push(...canvasResult.guides.filter(g => g.axis === "y")); } return result; @@ -323,6 +348,42 @@ export function snapRotation( return { angle, snapped: false }; } +// ─── Containment Filtering ─────────────────────────────────────────────────── + +/** + * Filter out clips where one fully contains the other (bidirectional). + * Clips that are fully inside the dragged clip, or that fully contain the + * dragged clip, are excluded from snap targets. + * Pure function - no side effects. + */ +export function filterContainedClips(draggedBounds: ClipBounds, otherClips: ClipBounds[]): ClipBounds[] { + return otherClips.filter(other => + !(other.left >= draggedBounds.left && other.right <= draggedBounds.right && + other.top >= draggedBounds.top && other.bottom <= draggedBounds.bottom) && + !(draggedBounds.left >= other.left && draggedBounds.right <= other.right && + draggedBounds.top >= other.top && draggedBounds.bottom <= other.bottom) + ); +} + +// ─── Coordinate Conversion ────────────────────────────────────────────────── + +/** + * Convert a visual-space position back to logical space. + * Accounts for pivot offset when container scale ≠ 1. + * + * Derivation: visual = logical - pivot * (scale - 1) + * → logical = visual + pivot * (scale - 1) + * + * When scale = 1 this is a no-op (visual === logical). + * Pure function - no side effects. + */ +export function visualToLogical(visualPosition: Vector, pivot: Vector, scale: Vector): Vector { + return { + x: visualPosition.x + pivot.x * (scale.x - 1), + y: visualPosition.y + pivot.y * (scale.y - 1) + }; +} + // ─── Utility Functions ─────────────────────────────────────────────────────── /** diff --git a/src/core/layouts/position-builder.ts b/src/core/layouts/position-builder.ts index a7c6cee3..f35b80c2 100644 --- a/src/core/layouts/position-builder.ts +++ b/src/core/layouts/position-builder.ts @@ -2,112 +2,104 @@ import { type ClipAnchor } from "@schemas"; import { type Size, type Vector } from "./geometry"; -export class PositionBuilder { - private containerSize: Size; - - constructor(containerSize: Size) { - this.containerSize = containerSize; - } - - public relativeToAbsolute(entitySize: Size, anchor: ClipAnchor, relativePosition: Vector): Vector { - switch (anchor) { - case "topLeft": - return { - x: relativePosition.x * this.containerSize.width, - y: -relativePosition.y * this.containerSize.height - }; - case "topRight": - return { - x: (relativePosition.x + 1) * this.containerSize.width - entitySize.width, - y: -relativePosition.y * this.containerSize.height - }; - case "bottomLeft": - return { - x: relativePosition.x * this.containerSize.width, - y: (-relativePosition.y + 1) * this.containerSize.height - entitySize.height - }; - case "bottomRight": - return { - x: (relativePosition.x + 1) * this.containerSize.width - entitySize.width, - y: (-relativePosition.y + 1) * this.containerSize.height - entitySize.height - }; - case "left": - return { - x: relativePosition.x * this.containerSize.width, - y: (-relativePosition.y + 0.5) * this.containerSize.height - entitySize.height / 2 - }; - case "right": - return { - x: (relativePosition.x + 1) * this.containerSize.width - entitySize.width, - y: (-relativePosition.y + 0.5) * this.containerSize.height - entitySize.height / 2 - }; - case "top": - return { - x: (relativePosition.x + 0.5) * this.containerSize.width - entitySize.width / 2, - y: -relativePosition.y * this.containerSize.height - }; - case "bottom": - return { - x: (relativePosition.x + 0.5) * this.containerSize.width - entitySize.width / 2, - y: (-relativePosition.y + 1) * this.containerSize.height - entitySize.height - }; - case "center": - default: - return { - x: (relativePosition.x + 0.5) * this.containerSize.width - entitySize.width / 2, - y: (-relativePosition.y + 0.5) * this.containerSize.height - entitySize.height / 2 - }; - } +export function relativeToAbsolute(containerSize: Size, entitySize: Size, anchor: ClipAnchor, relativePosition: Vector): Vector { + switch (anchor) { + case "topLeft": + return { + x: relativePosition.x * containerSize.width, + y: -relativePosition.y * containerSize.height + }; + case "topRight": + return { + x: (relativePosition.x + 1) * containerSize.width - entitySize.width, + y: -relativePosition.y * containerSize.height + }; + case "bottomLeft": + return { + x: relativePosition.x * containerSize.width, + y: (-relativePosition.y + 1) * containerSize.height - entitySize.height + }; + case "bottomRight": + return { + x: (relativePosition.x + 1) * containerSize.width - entitySize.width, + y: (-relativePosition.y + 1) * containerSize.height - entitySize.height + }; + case "left": + return { + x: relativePosition.x * containerSize.width, + y: (-relativePosition.y + 0.5) * containerSize.height - entitySize.height / 2 + }; + case "right": + return { + x: (relativePosition.x + 1) * containerSize.width - entitySize.width, + y: (-relativePosition.y + 0.5) * containerSize.height - entitySize.height / 2 + }; + case "top": + return { + x: (relativePosition.x + 0.5) * containerSize.width - entitySize.width / 2, + y: -relativePosition.y * containerSize.height + }; + case "bottom": + return { + x: (relativePosition.x + 0.5) * containerSize.width - entitySize.width / 2, + y: (-relativePosition.y + 1) * containerSize.height - entitySize.height + }; + case "center": + default: + return { + x: (relativePosition.x + 0.5) * containerSize.width - entitySize.width / 2, + y: (-relativePosition.y + 0.5) * containerSize.height - entitySize.height / 2 + }; } +} - public absoluteToRelative(entitySize: Size, anchor: ClipAnchor, absolutePosition: Vector): Vector { - switch (anchor) { - case "topLeft": - return { - x: absolutePosition.x / this.containerSize.width, - y: -(absolutePosition.y / this.containerSize.height) - }; - case "topRight": - return { - x: (absolutePosition.x + entitySize.width) / this.containerSize.width - 1, - y: -(absolutePosition.y / this.containerSize.height) - }; - case "bottomLeft": - return { - x: absolutePosition.x / this.containerSize.width, - y: -((absolutePosition.y + entitySize.height) / this.containerSize.height - 1) - }; - case "bottomRight": - return { - x: (absolutePosition.x + entitySize.width) / this.containerSize.width - 1, - y: -((absolutePosition.y + entitySize.height) / this.containerSize.height - 1) - }; - case "left": - return { - x: absolutePosition.x / this.containerSize.width, - y: -((absolutePosition.y + entitySize.height / 2) / this.containerSize.height - 0.5) - }; - case "right": - return { - x: (absolutePosition.x + entitySize.width) / this.containerSize.width - 1, - y: -((absolutePosition.y + entitySize.height / 2) / this.containerSize.height - 0.5) - }; - case "top": - return { - x: (absolutePosition.x + entitySize.width / 2) / this.containerSize.width - 0.5, - y: -(absolutePosition.y / this.containerSize.height) - }; - case "bottom": - return { - x: (absolutePosition.x + entitySize.width / 2) / this.containerSize.width - 0.5, - y: -((absolutePosition.y + entitySize.height) / this.containerSize.height - 1) - }; - case "center": - default: - return { - x: (absolutePosition.x + entitySize.width / 2) / this.containerSize.width - 0.5, - y: -((absolutePosition.y + entitySize.height / 2) / this.containerSize.height - 0.5) - }; - } +export function absoluteToRelative(containerSize: Size, entitySize: Size, anchor: ClipAnchor, absolutePosition: Vector): Vector { + switch (anchor) { + case "topLeft": + return { + x: absolutePosition.x / containerSize.width, + y: -(absolutePosition.y / containerSize.height) + }; + case "topRight": + return { + x: (absolutePosition.x + entitySize.width) / containerSize.width - 1, + y: -(absolutePosition.y / containerSize.height) + }; + case "bottomLeft": + return { + x: absolutePosition.x / containerSize.width, + y: -((absolutePosition.y + entitySize.height) / containerSize.height - 1) + }; + case "bottomRight": + return { + x: (absolutePosition.x + entitySize.width) / containerSize.width - 1, + y: -((absolutePosition.y + entitySize.height) / containerSize.height - 1) + }; + case "left": + return { + x: absolutePosition.x / containerSize.width, + y: -((absolutePosition.y + entitySize.height / 2) / containerSize.height - 0.5) + }; + case "right": + return { + x: (absolutePosition.x + entitySize.width) / containerSize.width - 1, + y: -((absolutePosition.y + entitySize.height / 2) / containerSize.height - 0.5) + }; + case "top": + return { + x: (absolutePosition.x + entitySize.width / 2) / containerSize.width - 0.5, + y: -(absolutePosition.y / containerSize.height) + }; + case "bottom": + return { + x: (absolutePosition.x + entitySize.width / 2) / containerSize.width - 0.5, + y: -((absolutePosition.y + entitySize.height) / containerSize.height - 1) + }; + case "center": + default: + return { + x: (absolutePosition.x + entitySize.width / 2) / containerSize.width - 0.5, + y: -((absolutePosition.y + entitySize.height / 2) / containerSize.height - 0.5) + }; } } diff --git a/src/core/ui/selection-handles.ts b/src/core/ui/selection-handles.ts index 081641e4..55c68259 100644 --- a/src/core/ui/selection-handles.ts +++ b/src/core/ui/selection-handles.ts @@ -10,11 +10,11 @@ import { detectEdgeZone } from "@core/interaction/clip-interaction"; import { SELECTION_CONSTANTS, CURSOR_BASE_ANGLES, type CornerName, buildResizeCursor } from "@core/interaction/selection-overlay"; -import { type ClipBounds, createClipBounds, createSnapContext, snap, snapRotation } from "@core/interaction/snap-system"; +import { type ClipBounds, createClipBounds, createSnapContext, filterContainedClips, snap, snapRotation, visualToLogical } from "@core/interaction/snap-system"; import { updateSvgViewBox, isSimpleRectSvg } from "@core/shared/svg-utils"; import { Pointer } from "@inputs/pointer"; -import type { Vector } from "@layouts/geometry"; -import { PositionBuilder } from "@layouts/position-builder"; +import type { Size, Vector } from "@layouts/geometry"; +import { absoluteToRelative } from "@layouts/position-builder"; import type { ResolvedClip, SvgAsset } from "@schemas"; import * as pixi from "pixi.js"; @@ -45,7 +45,6 @@ export class SelectionHandles implements CanvasOverlayRegistration { private handles: Map; private edgeHandles: Map; private app: pixi.Application | null = null; - private positionBuilder: PositionBuilder; // Dimension label shown during resize (lives in overlay parent, not rotated container) private dimensionContainer: pixi.Container; @@ -55,8 +54,6 @@ export class SelectionHandles implements CanvasOverlayRegistration { // Selection state private selectedPlayer: Player | null = null; private selectedClipId: string | null = null; - private selectedTrackIndex = -1; - private selectedClipIndex = -1; // Interaction state private isHovering = false; @@ -71,7 +68,6 @@ export class SelectionHandles implements CanvasOverlayRegistration { private isRotating = false; private rotationStart: number | null = null; private initialRotation = 0; - private rotationCorner: CornerName | null = null; private initialClipConfiguration: ResolvedClip | null = null; @@ -115,8 +111,6 @@ export class SelectionHandles implements CanvasOverlayRegistration { this.dimensionContainer.addChild(this.dimensionBackground); this.dimensionContainer.addChild(this.dimensionLabel); - this.positionBuilder = new PositionBuilder(edit.size); - // Bind event handlers this.onClipSelectedBound = this.onClipSelected.bind(this); this.onSelectionClearedBound = this.onSelectionCleared.bind(this); @@ -212,15 +206,11 @@ export class SelectionHandles implements CanvasOverlayRegistration { private onClipSelected({ trackIndex, clipIndex }: { trackIndex: number; clipIndex: number }): void { this.selectedPlayer = this.edit.getPlayerClip(trackIndex, clipIndex); this.selectedClipId = this.selectedPlayer?.clipId ?? null; - this.selectedTrackIndex = trackIndex; - this.selectedClipIndex = clipIndex; } private onSelectionCleared(): void { this.selectedPlayer = null; this.selectedClipId = null; - this.selectedTrackIndex = -1; - this.selectedClipIndex = -1; this.resetDragState(); } @@ -503,9 +493,17 @@ export class SelectionHandles implements CanvasOverlayRegistration { x: timelinePoint.x - this.dragOffset.x, y: timelinePoint.y - this.dragOffset.y }; - const rawPosition: Vector = { - x: cursorPosition.x - pivot.x, - y: cursorPosition.y - pivot.y + + // Compute visual (scaled) bounds for snap — accounts for container scale from fit mode + const dragScale = this.selectedPlayer.getContainer().scale; + const dragSize = this.selectedPlayer.getSize(); + const visualPosition: Vector = { + x: cursorPosition.x - pivot.x * dragScale.x, + y: cursorPosition.y - pivot.y * dragScale.y + }; + const visualSize: Size = { + width: dragSize.width * dragScale.x, + height: dragSize.height * dragScale.y }; // Clear and recalculate snap guides @@ -515,21 +513,31 @@ export class SelectionHandles implements CanvasOverlayRegistration { const otherClipBounds: ClipBounds[] = otherPlayers.map(other => { const pos = other.getContainer().position; const size = other.getSize(); - return createClipBounds({ x: pos.x, y: pos.y }, size); + const otherPivot = other.getPivot(); + const otherScale = other.getContainer().scale; + return createClipBounds( + { x: pos.x - otherPivot.x * otherScale.x, y: pos.y - otherPivot.y * otherScale.y }, + { width: size.width * otherScale.x, height: size.height * otherScale.y } + ); }); - const snapContext = createSnapContext(this.selectedPlayer.getSize(), this.edit.size, otherClipBounds); - const snapResult = snap(rawPosition, snapContext); + // Filter out clips where one fully contains the other + const draggedBounds = createClipBounds(visualPosition, visualSize); + const snapTargets = filterContainedClips(draggedBounds, otherClipBounds); + + const snapContext = createSnapContext(visualSize, this.edit.size, snapTargets); + const snapResult = snap(visualPosition, snapContext); // Draw alignment guides for (const guide of snapResult.guides) { this.edit.showAlignmentGuide(guide.type, guide.axis, guide.position, guide.bounds); } - // Calculate new offset position + // Convert snapped visual position back to logical space for offset calculation + const snappedLogicalPosition = visualToLogical(snapResult.position, pivot, dragScale); const size = this.selectedPlayer.getSize(); const position = this.selectedPlayer.clipConfiguration.position ?? "center"; - const updatedRelative = this.positionBuilder.absoluteToRelative(size, position, snapResult.position); + const updatedRelative = absoluteToRelative(this.edit.size, size, position, snappedLogicalPosition); // Store final state locally this.finalDragState = { @@ -625,11 +633,10 @@ export class SelectionHandles implements CanvasOverlayRegistration { this.showDimensionLabel(rounded.width, rounded.height); } - private startRotation(event: pixi.FederatedPointerEvent, corner: CornerName): void { + private startRotation(event: pixi.FederatedPointerEvent, _corner: CornerName): void { if (!this.selectedPlayer) return; this.isRotating = true; - this.rotationCorner = corner; const center = this.getContentCenter(); this.rotationStart = Math.atan2(event.globalY - center.y, event.globalX - center.x); @@ -817,7 +824,6 @@ export class SelectionHandles implements CanvasOverlayRegistration { this.originalDimensions = null; this.isRotating = false; this.rotationStart = null; - this.rotationCorner = null; this.initialClipConfiguration = null; this.hideDimensionLabel(); } diff --git a/tests/position-builder.test.ts b/tests/position-builder.test.ts new file mode 100644 index 00000000..1e557671 --- /dev/null +++ b/tests/position-builder.test.ts @@ -0,0 +1,78 @@ +import { relativeToAbsolute, absoluteToRelative } from "@core/layouts/position-builder"; + +describe("PositionBuilder", () => { + describe("relativeToAbsolute", () => { + it("centers entity with zero offset on 1920x1080", () => { + const container = { width: 1920, height: 1080 }; + const entity = { width: 200, height: 100 }; + const result = relativeToAbsolute(container, entity, "center", { x: 0, y: 0 }); + + expect(result.x).toBe(860); // (0.5 * 1920) - 100 + expect(result.y).toBe(490); // (0.5 * 1080) - 50 + }); + + it("centers entity with zero offset on 1080x1920 (vertical)", () => { + const container = { width: 1080, height: 1920 }; + const entity = { width: 200, height: 100 }; + const result = relativeToAbsolute(container, entity, "center", { x: 0, y: 0 }); + + expect(result.x).toBe(440); // (0.5 * 1080) - 100 + expect(result.y).toBe(910); // (0.5 * 1920) - 50 + }); + + it("positions at topLeft with zero offset", () => { + const container = { width: 1920, height: 1080 }; + const entity = { width: 200, height: 100 }; + const result = relativeToAbsolute(container, entity, "topLeft", { x: 0, y: 0 }); + + expect(result.x).toBeCloseTo(0); + expect(result.y).toBeCloseTo(0); + }); + + it("positions at bottomRight with zero offset", () => { + const container = { width: 1920, height: 1080 }; + const entity = { width: 200, height: 100 }; + const result = relativeToAbsolute(container, entity, "bottomRight", { x: 0, y: 0 }); + + expect(result.x).toBe(1720); // 1920 - 200 + expect(result.y).toBe(980); // 1080 - 100 + }); + }); + + describe("round-trip: relativeToAbsolute → absoluteToRelative", () => { + const anchors = ["center", "topLeft", "topRight", "bottomLeft", "bottomRight", "left", "right", "top", "bottom"] as const; + const containers = [ + { width: 1920, height: 1080 }, + { width: 1080, height: 1920 }, + { width: 800, height: 600 } + ]; + + const cases = anchors.flatMap(anchor => + containers.map(container => ({ anchor, container })) + ); + + it.each(cases)("round-trips for anchor=$anchor at $container.width×$container.height", ({ anchor, container }) => { + const entity = { width: 300, height: 150 }; + const original = { x: 0.15, y: -0.25 }; + + const absolute = relativeToAbsolute(container, entity, anchor, original); + const roundTripped = absoluteToRelative(container, entity, anchor, absolute); + + expect(roundTripped.x).toBeCloseTo(original.x, 10); + expect(roundTripped.y).toBeCloseTo(original.y, 10); + }); + }); + + describe("different container sizes produce different positions", () => { + it("same relative offset yields different absolute positions for different resolutions", () => { + const entity = { width: 200, height: 100 }; + const offset = { x: 0, y: 0 }; + + const horizontal = relativeToAbsolute({ width: 1920, height: 1080 }, entity, "center", offset); + const vertical = relativeToAbsolute({ width: 1080, height: 1920 }, entity, "center", offset); + + expect(horizontal.x).not.toBe(vertical.x); + expect(horizontal.y).not.toBe(vertical.y); + }); + }); +}); diff --git a/tests/snap-system.test.ts b/tests/snap-system.test.ts index d707a7f7..3d600145 100644 --- a/tests/snap-system.test.ts +++ b/tests/snap-system.test.ts @@ -21,7 +21,9 @@ import { snap, snapRotation, createClipBounds, - createSnapContext + createSnapContext, + filterContainedClips, + visualToLogical } from "@core/interaction/snap-system"; describe("SnapSystem", () => { @@ -433,7 +435,7 @@ describe("SnapSystem", () => { expect(result.position).toEqual({ x: 205, y: 100 }); }); - it("clip snapping takes priority over canvas snapping when both match", () => { + it("canvas snapping wins over clip snapping when equidistant", () => { // Position a clip right at the canvas center const otherClip: ClipBounds = { left: 935, @@ -451,12 +453,31 @@ describe("SnapSystem", () => { const result = snap(position, context); - // Should have clip guide, not canvas guide (clip takes priority) + // Canvas wins ties — centering is the most intentional action const xGuide = result.guides.find(g => g.axis === "x"); - expect(xGuide?.type).toBe("clip"); + expect(xGuide?.type).toBe("canvas"); expect(result.position.x).toBe(910); // Center aligned at 960 }); + it("clip snapping wins when strictly closer than canvas snap", () => { + // Other clip's right edge at 900 + const otherClip: ClipBounds = { + left: 800, right: 900, top: 400, bottom: 500, + centerX: 850, centerY: 450 + }; + const context = createSnapContext(clipSize, canvasSize, [otherClip], { threshold: 20 }); + // My left edge at 903 → 3px from clip right (900), + // my center at 953 → 7px from canvas center (960) + const position = { x: 903, y: 500 }; + + const result = snap(position, context); + + // Clip snap adjustment = 3, canvas snap adjustment = 7 → clip wins + expect(result.position.x).toBe(900); + const xGuide = result.guides.find(g => g.axis === "x"); + expect(xGuide?.type).toBe("clip"); + }); + it("uses canvas snap when clip snap is further away", () => { // Clip that's slightly offset from canvas center const otherClip: ClipBounds = { @@ -750,4 +771,210 @@ describe("SnapSystem", () => { expect(result.guides).toBeDefined(); }); }); + + // ─── Containment Filtering Tests ───────────────────────────────────────── + + describe("filterContainedClips", () => { + it("filters out clips fully inside the dragged clip", () => { + const dragged: ClipBounds = { left: 0, right: 500, top: 0, bottom: 500, centerX: 250, centerY: 250 }; + const inside: ClipBounds = { left: 100, right: 200, top: 100, bottom: 200, centerX: 150, centerY: 150 }; + + const result = filterContainedClips(dragged, [inside]); + + expect(result).toHaveLength(0); + }); + + it("filters out clips that fully contain the dragged clip", () => { + const dragged: ClipBounds = { left: 100, right: 200, top: 100, bottom: 200, centerX: 150, centerY: 150 }; + const container: ClipBounds = { left: 0, right: 500, top: 0, bottom: 500, centerX: 250, centerY: 250 }; + + const result = filterContainedClips(dragged, [container]); + + expect(result).toHaveLength(0); + }); + + it("keeps partially overlapping clips", () => { + const dragged: ClipBounds = { left: 0, right: 200, top: 0, bottom: 200, centerX: 100, centerY: 100 }; + const overlapping: ClipBounds = { left: 100, right: 300, top: 100, bottom: 300, centerX: 200, centerY: 200 }; + + const result = filterContainedClips(dragged, [overlapping]); + + expect(result).toHaveLength(1); + }); + + it("keeps non-overlapping clips", () => { + const dragged: ClipBounds = { left: 0, right: 100, top: 0, bottom: 100, centerX: 50, centerY: 50 }; + const separate: ClipBounds = { left: 500, right: 600, top: 500, bottom: 600, centerX: 550, centerY: 550 }; + + const result = filterContainedClips(dragged, [separate]); + + expect(result).toHaveLength(1); + }); + + it("filters out clips with exact same bounds", () => { + const dragged: ClipBounds = { left: 100, right: 300, top: 100, bottom: 300, centerX: 200, centerY: 200 }; + const same: ClipBounds = { left: 100, right: 300, top: 100, bottom: 300, centerX: 200, centerY: 200 }; + + const result = filterContainedClips(dragged, [same]); + + expect(result).toHaveLength(0); + }); + + it("handles mixed containment across multiple clips", () => { + const dragged: ClipBounds = { left: 100, right: 400, top: 100, bottom: 400, centerX: 250, centerY: 250 }; + const inside: ClipBounds = { left: 150, right: 200, top: 150, bottom: 200, centerX: 175, centerY: 175 }; + const outside: ClipBounds = { left: 0, right: 1920, top: 0, bottom: 1080, centerX: 960, centerY: 540 }; + const partial: ClipBounds = { left: 350, right: 500, top: 350, bottom: 500, centerX: 425, centerY: 425 }; + const separate: ClipBounds = { left: 800, right: 900, top: 800, bottom: 900, centerX: 850, centerY: 850 }; + + const result = filterContainedClips(dragged, [inside, outside, partial, separate]); + + // inside → filtered (dragged contains it) + // outside → filtered (it contains dragged) + // partial → kept (overlapping) + // separate → kept (non-overlapping) + expect(result).toHaveLength(2); + expect(result).toContain(partial); + expect(result).toContain(separate); + }); + + it("returns empty array when given empty clips", () => { + const dragged: ClipBounds = { left: 0, right: 100, top: 0, bottom: 100, centerX: 50, centerY: 50 }; + + const result = filterContainedClips(dragged, []); + + expect(result).toHaveLength(0); + }); + + it("keeps edge-touching clips that are not contained", () => { + const dragged: ClipBounds = { left: 0, right: 100, top: 0, bottom: 100, centerX: 50, centerY: 50 }; + // Shares right edge but extends beyond on Y + const edgeTouching: ClipBounds = { left: 100, right: 200, top: 50, bottom: 150, centerX: 150, centerY: 100 }; + + const result = filterContainedClips(dragged, [edgeTouching]); + + expect(result).toHaveLength(1); + }); + }); + + // ─── Coordinate Conversion Tests ───────────────────────────────────────── + + describe("visualToLogical", () => { + it("returns identity when scale is 1", () => { + const visual = { x: 100, y: 200 }; + const pivot = { x: 50, y: 50 }; + const scale = { x: 1, y: 1 }; + + const result = visualToLogical(visual, pivot, scale); + + expect(result).toEqual({ x: 100, y: 200 }); + }); + + it("applies correct offset when scale is 0.5", () => { + const visual = { x: 100, y: 200 }; + const pivot = { x: 50, y: 60 }; + const scale = { x: 0.5, y: 0.5 }; + + const result = visualToLogical(visual, pivot, scale); + + // x: 100 + 50 * (0.5 - 1) = 100 + 50 * -0.5 = 100 - 25 = 75 + // y: 200 + 60 * (0.5 - 1) = 200 + 60 * -0.5 = 200 - 30 = 170 + expect(result).toEqual({ x: 75, y: 170 }); + }); + + it("handles non-uniform scale independently per axis", () => { + const visual = { x: 100, y: 200 }; + const pivot = { x: 40, y: 60 }; + const scale = { x: 2, y: 0.5 }; + + const result = visualToLogical(visual, pivot, scale); + + // x: 100 + 40 * (2 - 1) = 100 + 40 = 140 + // y: 200 + 60 * (0.5 - 1) = 200 - 30 = 170 + expect(result).toEqual({ x: 140, y: 170 }); + }); + + it("returns identity when pivot is at origin regardless of scale", () => { + const visual = { x: 100, y: 200 }; + const pivot = { x: 0, y: 0 }; + const scale = { x: 3, y: 0.1 }; + + const result = visualToLogical(visual, pivot, scale); + + expect(result).toEqual({ x: 100, y: 200 }); + }); + + it("round-trip: logicalToVisual then visualToLogical preserves position", () => { + const logical = { x: 300, y: 400 }; + const pivot = { x: 80, y: 60 }; + const scale = { x: 0.75, y: 1.5 }; + + // logicalToVisual: visual = logical - pivot * (scale - 1) + // (inverse of visualToLogical) + const visual = { + x: logical.x - pivot.x * (scale.x - 1), + y: logical.y - pivot.y * (scale.y - 1) + }; + + const result = visualToLogical(visual, pivot, scale); + + expect(result.x).toBeCloseTo(logical.x, 10); + expect(result.y).toBeCloseTo(logical.y, 10); + }); + }); + + // ─── Additional Snap Priority Edge Cases ───────────────────────────────── + + describe("snap priority edge cases", () => { + const clipSize = { width: 100, height: 100 }; + const canvasSize = { width: 1920, height: 1080 }; + + it("returns unchanged position and no guides when nothing is within threshold", () => { + const context = createSnapContext(clipSize, canvasSize, [], { threshold: 1 }); + // Position far from any canvas edge or center + const position = { x: 500, y: 300 }; + + const result = snap(position, context); + + expect(result.position).toEqual({ x: 500, y: 300 }); + expect(result.guides).toHaveLength(0); + }); + + it("snaps only on one axis when only one axis is within threshold", () => { + const context = createSnapContext(clipSize, canvasSize, [], { threshold: 5 }); + // X: left edge at 3 → 3px from canvas left (0), within threshold + // Y: top edge at 300 → far from any canvas snap point + const position = { x: 3, y: 300 }; + + const result = snap(position, context); + + expect(result.position.x).toBe(0); // Snapped to canvas left + expect(result.position.y).toBe(300); // Unchanged + expect(result.guides).toHaveLength(1); + expect(result.guides[0].axis).toBe("x"); + }); + + it("clip snap wins at sub-pixel distance closer than canvas", () => { + const otherClip: ClipBounds = { + left: 49.5, right: 149.5, top: 400, bottom: 500, + centerX: 99.5, centerY: 450 + }; + const context = createSnapContext(clipSize, canvasSize, [otherClip], { threshold: 5 }); + // My left edge at 51 → 1.5px from clip right (49.5 — wait, need edge-to-edge) + // Actually: my left=52, clip right=149.5 → 97.5px, too far + // Let's use: my right edge (52+100=152) vs clip right (149.5) → 2.5px + // vs canvas: no canvas point near 152 + // Better approach: my left edge near clip right edge + // my left = 148 → 1.5px from clip right (149.5) + // my center = 198 → far from canvas center (960) + const position = { x: 148, y: 300 }; + + const result = snap(position, context); + + // left edge (148) → 1.5px from clip right edge (149.5) → clip wins + expect(result.position.x).toBeCloseTo(149.5, 5); + const xGuide = result.guides.find(g => g.axis === "x"); + expect(xGuide?.type).toBe("clip"); + }); + }); });