Skip to content

Commit f61370b

Browse files
authored
Fix/snap alignment and selection (#77)
* refactor: convert PositionBuilder from class to standalone functions * refactor: snap to closest target per axis, canvas wins ties * refactor: use instance state instead of method parameters in clip component * refactor: extract containment filtering and coordinate conversion utilities * refactor: remove unused private fields in SelectionHandles * fix: improve snapping guides and timeline clip selection * test: refactor nested loops to it.each pattern
1 parent 1fa62c7 commit f61370b

9 files changed

Lines changed: 537 additions & 166 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.1.3] - 2026-03-10
6+
7+
### Fixed
8+
9+
- Better, more intuitive snapping and guide lines.
10+
- Timeline clip selection
11+
512
## [2.1.2] - 2026-03-06
613

714
### Fixed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"dazzatron",
77
"kratos2k7"
88
],
9-
"version": "2.1.2",
9+
"version": "2.1.3",
1010
"description": "A video editing library for creating and editing videos with Shotstack",
1111
"type": "module",
1212
"main": "dist/shotstack-studio.umd.js",
@@ -90,7 +90,7 @@
9090
},
9191
"dependencies": {
9292
"@shotstack/schemas": "1.8.7",
93-
"@shotstack/shotstack-canvas": "^2.0.13",
93+
"@shotstack/shotstack-canvas": "^2.0.14",
9494
"howler": "^2.2.4",
9595
"mediabunny": "^1.11.2",
9696
"opentype.js": "^1.3.4",

src/components/canvas/players/player.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from "@core/timing/types";
1717
import { Pointer } from "@inputs/pointer";
1818
import { type Size, type Vector } from "@layouts/geometry";
19-
import { PositionBuilder } from "@layouts/position-builder";
19+
import { relativeToAbsolute, absoluteToRelative } from "@layouts/position-builder";
2020
import { type Clip, type ResolvedClip, type Keyframe } from "@schemas";
2121
import * as pixi from "pixi.js";
2222

@@ -74,7 +74,6 @@ export abstract class Player extends Entity {
7474

7575
private resolvedTiming: ResolvedTiming;
7676

77-
private positionBuilder: PositionBuilder;
7877
private offsetXKeyframeBuilder?: ComposedKeyframeBuilder;
7978
private offsetYKeyframeBuilder?: ComposedKeyframeBuilder;
8079
private scaleKeyframeBuilder?: ComposedKeyframeBuilder;
@@ -97,7 +96,6 @@ export abstract class Player extends Entity {
9796
this.playerType = playerType;
9897

9998
this.clipConfiguration = clipConfiguration;
100-
this.positionBuilder = new PositionBuilder(edit.size);
10199

102100
this.resolvedTiming = { start: clipConfiguration.start, length: clipConfiguration.length };
103101

@@ -416,7 +414,7 @@ export abstract class Player extends Entity {
416414
y: this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0
417415
};
418416

419-
return this.positionBuilder.relativeToAbsolute(this.getSize(), this.clipConfiguration.position ?? "center", offset);
417+
return relativeToAbsolute(this.edit.size, this.getSize(), this.clipConfiguration.position ?? "center", offset);
420418
}
421419

422420
public getPivot(): Vector {
@@ -434,7 +432,7 @@ export abstract class Player extends Entity {
434432
const currentPos = this.getPosition();
435433
const newAbsolutePos = { x: currentPos.x + deltaX, y: currentPos.y + deltaY };
436434

437-
const relativePos = this.positionBuilder.absoluteToRelative(this.getSize(), this.clipConfiguration.position ?? "center", newAbsolutePos);
435+
const relativePos = absoluteToRelative(this.edit.size, this.getSize(), this.clipConfiguration.position ?? "center", newAbsolutePos);
438436

439437
return { x: relativePos.x, y: relativePos.y };
440438
}

src/components/timeline/components/clip/clip-component.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ export class ClipComponent {
4545
this.element = document.createElement("div");
4646
this.element.className = "ss-clip";
4747
this.options = options;
48-
this.buildElement(clip);
4948
this.currentState = clip;
49+
this.buildElement();
5050
this.element.dataset["clipId"] = clip.id;
5151
}
5252

53-
private buildElement(clip: ClipState): void {
53+
private buildElement(): void {
5454
// Content container
5555
const content = document.createElement("div");
5656
content.className = "ss-clip-content";
@@ -84,10 +84,10 @@ export class ClipComponent {
8484
this.element.appendChild(rightHandle);
8585

8686
// Set up interaction handlers
87-
this.setupInteraction(clip);
87+
this.setupInteraction();
8888
}
8989

90-
private setupInteraction(clip: ClipState): void {
90+
private setupInteraction(): void {
9191
this.element.addEventListener("pointerdown", e => {
9292
// Check if clicking on resize handle
9393
const target = e.target as HTMLElement;
@@ -96,9 +96,11 @@ export class ClipComponent {
9696
return;
9797
}
9898

99+
if (!this.currentState) return;
100+
99101
// Select clip
100102
const addToSelection = e.shiftKey || e.ctrlKey || e.metaKey;
101-
this.options.onSelect(clip.trackIndex, clip.clipIndex, addToSelection);
103+
this.options.onSelect(this.currentState.trackIndex, this.currentState.clipIndex, addToSelection);
102104
});
103105
}
104106

src/core/interaction/snap-system.ts

Lines changed: 82 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -249,41 +249,66 @@ export function snapToClips(position: Vector, clipSize: Size, otherClips: ClipBo
249249

250250
/**
251251
* Combined snap function that checks both canvas and clips.
252-
* Clip snapping takes priority over canvas snapping when both are within threshold.
252+
* Closest snap wins per axis. Canvas wins ties (centering is the most intentional action).
253253
* Pure function - no side effects.
254254
*/
255255
export function snap(position: Vector, context: SnapContext): SnapResult {
256256
const { clipSize, canvasSize, otherClips, config } = context;
257257
const { threshold, snapToCanvas: doSnapToCanvas, snapToClips: doSnapToClips } = config;
258258

259-
let result: SnapResult = { position: { ...position }, guides: [] };
259+
// Run both snap types on the original position so distances are comparable
260+
const canvasResult = doSnapToCanvas
261+
? snapToCanvas(position, clipSize, canvasSize, threshold)
262+
: { position: { ...position }, guides: [] };
260263

261-
// First apply canvas snapping
262-
if (doSnapToCanvas) {
263-
result = snapToCanvas(result.position, clipSize, canvasSize, threshold);
264-
}
264+
const clipResult = (doSnapToClips && otherClips.length > 0)
265+
? snapToClips(position, clipSize, otherClips, threshold)
266+
: { position: { ...position }, guides: [] };
265267

266-
// Then apply clip snapping (takes priority - will override canvas snap if closer)
267-
if (doSnapToClips && otherClips.length > 0) {
268-
const clipResult = snapToClips(result.position, clipSize, otherClips, threshold);
268+
const result: SnapResult = { position: { ...position }, guides: [] };
269269

270-
// Merge results - clip snaps take priority
271-
const hasClipSnapX = clipResult.guides.some(g => g.axis === "x");
272-
const hasClipSnapY = clipResult.guides.some(g => g.axis === "y");
270+
// X axis: closest snap wins (canvas wins ties)
271+
const hasCanvasX = canvasResult.guides.some(g => g.axis === "x");
272+
const hasClipX = clipResult.guides.some(g => g.axis === "x");
273273

274-
if (hasClipSnapX) {
274+
if (hasCanvasX && hasClipX) {
275+
const canvasDist = Math.abs(canvasResult.position.x - position.x);
276+
const clipDist = Math.abs(clipResult.position.x - position.x);
277+
if (clipDist < canvasDist) {
275278
result.position.x = clipResult.position.x;
276-
// Replace canvas X guide with clip X guide
277-
result.guides = result.guides.filter(g => g.axis !== "x");
279+
result.guides.push(...clipResult.guides.filter(g => g.axis === "x"));
280+
} else {
281+
result.position.x = canvasResult.position.x;
282+
result.guides.push(...canvasResult.guides.filter(g => g.axis === "x"));
278283
}
279-
if (hasClipSnapY) {
284+
} else if (hasClipX) {
285+
result.position.x = clipResult.position.x;
286+
result.guides.push(...clipResult.guides.filter(g => g.axis === "x"));
287+
} else if (hasCanvasX) {
288+
result.position.x = canvasResult.position.x;
289+
result.guides.push(...canvasResult.guides.filter(g => g.axis === "x"));
290+
}
291+
292+
// Y axis: closest snap wins (canvas wins ties)
293+
const hasCanvasY = canvasResult.guides.some(g => g.axis === "y");
294+
const hasClipY = clipResult.guides.some(g => g.axis === "y");
295+
296+
if (hasCanvasY && hasClipY) {
297+
const canvasDist = Math.abs(canvasResult.position.y - position.y);
298+
const clipDist = Math.abs(clipResult.position.y - position.y);
299+
if (clipDist < canvasDist) {
280300
result.position.y = clipResult.position.y;
281-
// Replace canvas Y guide with clip Y guide
282-
result.guides = result.guides.filter(g => g.axis !== "y");
301+
result.guides.push(...clipResult.guides.filter(g => g.axis === "y"));
302+
} else {
303+
result.position.y = canvasResult.position.y;
304+
result.guides.push(...canvasResult.guides.filter(g => g.axis === "y"));
283305
}
284-
285-
// Add clip guides
286-
result.guides.push(...clipResult.guides);
306+
} else if (hasClipY) {
307+
result.position.y = clipResult.position.y;
308+
result.guides.push(...clipResult.guides.filter(g => g.axis === "y"));
309+
} else if (hasCanvasY) {
310+
result.position.y = canvasResult.position.y;
311+
result.guides.push(...canvasResult.guides.filter(g => g.axis === "y"));
287312
}
288313

289314
return result;
@@ -323,6 +348,42 @@ export function snapRotation(
323348
return { angle, snapped: false };
324349
}
325350

351+
// ─── Containment Filtering ───────────────────────────────────────────────────
352+
353+
/**
354+
* Filter out clips where one fully contains the other (bidirectional).
355+
* Clips that are fully inside the dragged clip, or that fully contain the
356+
* dragged clip, are excluded from snap targets.
357+
* Pure function - no side effects.
358+
*/
359+
export function filterContainedClips(draggedBounds: ClipBounds, otherClips: ClipBounds[]): ClipBounds[] {
360+
return otherClips.filter(other =>
361+
!(other.left >= draggedBounds.left && other.right <= draggedBounds.right &&
362+
other.top >= draggedBounds.top && other.bottom <= draggedBounds.bottom) &&
363+
!(draggedBounds.left >= other.left && draggedBounds.right <= other.right &&
364+
draggedBounds.top >= other.top && draggedBounds.bottom <= other.bottom)
365+
);
366+
}
367+
368+
// ─── Coordinate Conversion ──────────────────────────────────────────────────
369+
370+
/**
371+
* Convert a visual-space position back to logical space.
372+
* Accounts for pivot offset when container scale ≠ 1.
373+
*
374+
* Derivation: visual = logical - pivot * (scale - 1)
375+
* → logical = visual + pivot * (scale - 1)
376+
*
377+
* When scale = 1 this is a no-op (visual === logical).
378+
* Pure function - no side effects.
379+
*/
380+
export function visualToLogical(visualPosition: Vector, pivot: Vector, scale: Vector): Vector {
381+
return {
382+
x: visualPosition.x + pivot.x * (scale.x - 1),
383+
y: visualPosition.y + pivot.y * (scale.y - 1)
384+
};
385+
}
386+
326387
// ─── Utility Functions ───────────────────────────────────────────────────────
327388

328389
/**

0 commit comments

Comments
 (0)