@@ -5,7 +5,11 @@ import type { LabelObject } from "../../types/Group";
55import { dotsToPx , pxToDots } from "../../lib/coordinates" ;
66import { constrainLine , type ConstrainMode } from "../../lib/lineConstrain" ;
77import { useColorScheme } from "../../lib/useColorScheme" ;
8- import { computePointSnap , type SnapRect } from "../../lib/snapGuides" ;
8+ import {
9+ computePointSnap ,
10+ computeResizeSnap ,
11+ type SnapRect ,
12+ } from "../../lib/snapGuides" ;
913import { diagonalPolygonPoints } from "../../lib/shapeGeometry" ;
1014import { selectionHandlers , type KonvaObjectProps } from "./konvaObjectProps" ;
1115
@@ -223,6 +227,90 @@ export function LineObject({
223227 setGuides ?.( [ ] ) ;
224228 }
225229
230+ /**
231+ * Smart-align snap for the thickness side-handle. Treats the band as a
232+ * resize bbox with only the extrusion edge active (`bottom` for horizontal
233+ * lines, `right` for vertical) and delegates to `computeResizeSnap`, the
234+ * same path the Transformer uses for free resizes. Returns the snapped
235+ * thickness in dots. No-ops on diagonals (band axis isn't stage-aligned),
236+ * rotated views (AABB conversion would mislabel the active edge), or
237+ * shift-bypass.
238+ */
239+ function snapThickness (
240+ rawT : number ,
241+ shift : boolean ,
242+ parent : Konva . Node | null ,
243+ ) : number {
244+ const absRot = parent ? Math . abs ( parent . getAbsoluteRotation ( ) % 360 ) : 0 ;
245+ const rotated = absRot > 0.1 && absRot < 359.9 ;
246+ if (
247+ ! isAxisAligned ||
248+ shift ||
249+ rotated ||
250+ ! getOthersSnapshot ||
251+ ! labelRect ||
252+ ! setGuides ||
253+ ! parent
254+ ) {
255+ setGuides ?.( [ ] ) ;
256+ return rawT ;
257+ }
258+ if ( othersSnapshotRef . current === null ) {
259+ othersSnapshotRef . current = getOthersSnapshot ( obj . id ) ;
260+ }
261+ const transform = parent . getAbsoluteTransform ( ) ;
262+ const thicknessPx = Math . max ( dotsToPx ( rawT , scale , dpmm ) , 1 ) ;
263+ // Band bbox in label-local coords, then mapped to stage so the others
264+ // snapshot (already in stage frame) can be compared apples-to-apples.
265+ const localBand = isHorizontal
266+ ? {
267+ x : Math . min ( dispX1 , dispX2 ) ,
268+ y : lineCenterY ,
269+ width : visualLenPx ,
270+ height : thicknessPx ,
271+ }
272+ : {
273+ x : lineCenterX ,
274+ y : Math . min ( dispY1 , dispY2 ) ,
275+ width : thicknessPx ,
276+ height : visualLenPx ,
277+ } ;
278+ const tl = transform . point ( { x : localBand . x , y : localBand . y } ) ;
279+ const br = transform . point ( {
280+ x : localBand . x + localBand . width ,
281+ y : localBand . y + localBand . height ,
282+ } ) ;
283+ const stageBand : SnapRect = {
284+ id : obj . id ,
285+ x : Math . min ( tl . x , br . x ) ,
286+ y : Math . min ( tl . y , br . y ) ,
287+ width : Math . abs ( br . x - tl . x ) ,
288+ height : Math . abs ( br . y - tl . y ) ,
289+ } ;
290+ const activeEdges = isHorizontal
291+ ? { top : false , bottom : true , left : false , right : false }
292+ : { top : false , bottom : false , left : false , right : true } ;
293+ const result = computeResizeSnap (
294+ stageBand ,
295+ othersSnapshotRef . current ,
296+ activeEdges ,
297+ undefined ,
298+ labelRect ,
299+ labelRect ,
300+ ) ;
301+ const snappedExtPx = isHorizontal ? result . height : result . width ;
302+ const cappedT = Math . min (
303+ p . length ,
304+ Math . max ( 1 , Math . round ( pxToDots ( snappedExtPx , scale , dpmm ) ) ) ,
305+ ) ;
306+ // If the t ≤ length cap forced the band shorter than the snap target,
307+ // the guide would point past the visible band edge — clear it so the
308+ // hint matches what the user sees commit.
309+ const cappedExtPx = dotsToPx ( cappedT , scale , dpmm ) ;
310+ setGuides ( Math . abs ( cappedExtPx - snappedExtPx ) < 0.5 ? result . guides : [ ] ) ;
311+ return cappedT ;
312+ }
313+
226314 /**
227315 * Full endpoint-drag pipeline: axis constraint → object snap → final
228316 * geometry derivation. Returns the same shape as `project` so call
@@ -575,14 +663,19 @@ export function LineObject({
575663 // promotion regime where thickness exceeds length and
576664 // Labelary would print `t × t` rather than the band the
577665 // user is dragging.
578- const newT = Math . min (
666+ const rawT = Math . min (
579667 p . length ,
580668 Math . max ( 1 , Math . round ( pxToDots ( extPx , scale , dpmm ) ) ) ,
581669 ) ;
670+ const newT = snapThickness (
671+ rawT ,
672+ e . evt . shiftKey ,
673+ e . target . getParent ( ) ,
674+ ) ;
582675 setLiveThicknessDots ( newT ) ;
583- // Pin the Rect to the (possibly-clamped) anchor so
584- // dragging past the minimum doesn't decouple the handle
585- // from the band edge.
676+ // Pin the Rect to the (possibly-clamped, possibly-snapped)
677+ // anchor so dragging past the minimum doesn't decouple the
678+ // handle from the band edge.
586679 const newStroke = Math . max ( dotsToPx ( newT , scale , dpmm ) , 1 ) ;
587680 e . target . position ( {
588681 x :
@@ -598,6 +691,7 @@ export function LineObject({
598691 onDragEnd = { ( ) => {
599692 const committed = liveThicknessDots ;
600693 setLiveThicknessDots ( null ) ;
694+ clearSnap ( ) ;
601695 if ( committed !== null && committed !== p . thickness ) {
602696 onChange ( { props : { thickness : committed } } ) ;
603697 }
0 commit comments