Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 3 additions & 5 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -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 };

Expand Down Expand Up @@ -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 {
Expand All @@ -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 };
}
Expand Down
12 changes: 7 additions & 5 deletions src/components/timeline/components/clip/clip-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});
}

Expand Down
103 changes: 82 additions & 21 deletions src/core/interaction/snap-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────

/**
Expand Down
Loading