@@ -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 */
255255export 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