@@ -14,6 +14,78 @@ import {
1414 type BoundingBox ,
1515} from "../transformerGeometry" ;
1616import { modelPositionFromRenderedTopLeft } from "../transformPosition" ;
17+ import {
18+ computeResizeSnap ,
19+ deriveActiveEdges ,
20+ type SnapGuide ,
21+ type SnapRect ,
22+ } from "../../../lib/snapGuides" ;
23+
24+ /** Pack a Konva clientRect into the SnapRect shape used by snap helpers. */
25+ function toSnapRect ( id : string , rect : { x : number ; y : number ; width : number ; height : number } ) : SnapRect {
26+ return { id, x : rect . x , y : rect . y , width : rect . width , height : rect . height } ;
27+ }
28+
29+ /**
30+ * Snapshot every object's stage-space bbox at transform start. Other objects
31+ * can't move during a resize, so we cache once and avoid per-tick Konva queries.
32+ */
33+ function captureOtherRects (
34+ stage : Konva . Stage ,
35+ objects : LabelObject [ ] ,
36+ excludeId : string ,
37+ ) : SnapRect [ ] {
38+ const result : SnapRect [ ] = [ ] ;
39+ for ( const o of objects ) {
40+ if ( o . id === excludeId ) continue ;
41+ const n = stage . findOne < Konva . Node > ( `#${ o . id } ` ) ;
42+ if ( ! n ) continue ;
43+ const cr = n . getClientRect ( { skipShadow : true , skipStroke : true , relativeTo : stage } ) ;
44+ result . push ( toSnapRect ( o . id , cr ) ) ;
45+ }
46+ return result ;
47+ }
48+
49+ /**
50+ * Phase 1 of resize: snap height to whole rowHeight increments for stacked-2D
51+ * barcodes (or to whole dots otherwise), and pin the bottom edge if the resize
52+ * originates from the top anchor.
53+ */
54+ function applyHeightSnap (
55+ oldBox : BoundingBox ,
56+ newBox : BoundingBox ,
57+ dotPx : number ,
58+ anchor : { nodeHeight : number ; rowHeight : number } | null ,
59+ ) : BoundingBox {
60+ const stepPx =
61+ anchor && anchor . rowHeight > 0 && anchor . nodeHeight > 0
62+ ? anchor . nodeHeight / anchor . rowHeight
63+ : dotPx ;
64+ const snappedH = snapBoxHeight ( newBox . height , stepPx ) ;
65+ return isTopAnchorResize ( oldBox , newBox , dotPx * 0.5 )
66+ ? pinBottomEdge ( oldBox , newBox , snappedH )
67+ : { ...newBox , height : snappedH } ;
68+ }
69+
70+ /**
71+ * Phase 2 of resize: snap moving edges to other objects' / label edges
72+ * (object-snap, mirrors drag-time snap). Pure delegation to `computeResizeSnap`
73+ * with input shape conversion.
74+ */
75+ function applyResizeObjectSnap (
76+ bbox : BoundingBox ,
77+ startBbox : SnapRect ,
78+ others : SnapRect [ ] ,
79+ labelRect : SnapRect ,
80+ ) : { bbox : BoundingBox ; guides : SnapGuide [ ] } {
81+ const draggedRect = toSnapRect ( startBbox . id , bbox ) ;
82+ const activeEdges = deriveActiveEdges ( startBbox , draggedRect ) ;
83+ const result = computeResizeSnap ( draggedRect , others , activeEdges , undefined , labelRect , labelRect ) ;
84+ return {
85+ bbox : { x : result . x , y : result . y , width : result . width , height : result . height , rotation : bbox . rotation } ,
86+ guides : result . guides ,
87+ } ;
88+ }
1789
1890interface Options {
1991 transformerRef : React . RefObject < Konva . Transformer | null > ;
@@ -26,6 +98,12 @@ interface Options {
2698 labelOffsetY : number ;
2799 snap : ( dots : number ) => number ;
28100 updateObject : ( id : string , changes : ObjectChanges ) => void ;
101+ /** Label rect in stage-screen space, used as a snap target. */
102+ labelRect : SnapRect ;
103+ /** True when grid-snap is OFF — object-snap during resize mirrors drag. */
104+ objectSnapEnabled : boolean ;
105+ /** Pushes resize-time snap guides into the canvas's shared guide state. */
106+ setGuides : ( guides : SnapGuide [ ] ) => void ;
29107}
30108
31109export interface TransformerState {
@@ -48,10 +126,20 @@ export function useKonvaTransformer({
48126 labelOffsetY,
49127 snap,
50128 updateObject,
129+ labelRect,
130+ objectSnapEnabled,
131+ setGuides,
51132} : Options ) : TransformerState {
52133 // Captures node height and rowHeight at drag start so boundBoxFunc uses a
53134 // fixed step size throughout the entire drag session.
54135 const transformAnchorRef = useRef < { nodeHeight : number ; rowHeight : number } | null > ( null ) ;
136+ // Captures the bbox at transform start so deriveActiveEdges can detect which
137+ // edges are moving relative to the start state (oldBox in boundBoxFunc is the
138+ // previous frame, which would always look "everything moved").
139+ const transformStartBboxRef = useRef < SnapRect | null > ( null ) ;
140+ // Snapshot of the other objects' bboxes at transform start. Other objects
141+ // can't move during a resize, so we avoid re-querying Konva on every tick.
142+ const othersSnapshotRef = useRef < SnapRect [ ] > ( [ ] ) ;
55143
56144 // Stable key of selected object types — avoids re-running on every drag-move
57145 // position update (which changes objects but not the types of selected objects).
@@ -91,46 +179,59 @@ export function useKonvaTransformer({
91179 : BARCODE_1D_TYPES . has ( objects . find ( ( o ) => o . id === selectedIds [ 0 ] ) ?. type ?? "" )
92180 ? [ "top-center" , "bottom-center" ]
93181 : undefined ;
182+ const isFreeResize = enabledAnchors === undefined ;
183+
184+ /** Reset all transform-time state. Idempotent; safe to call from any exit path. */
185+ function cleanupTransformState ( ) {
186+ transformAnchorRef . current = null ;
187+ transformStartBboxRef . current = null ;
188+ othersSnapshotRef . current = [ ] ;
189+ setGuides ( [ ] ) ;
190+ }
94191
95192 const onTransformStart = ( ) => {
96193 const singleId = selectedIds [ 0 ] ;
97194 if ( ! singleId || ! stageRef . current ) return ;
98195 const node = stageRef . current . findOne < Konva . Node > ( `#${ singleId } ` ) ;
99196 if ( ! node ) return ;
100197 const obj = objects . find ( ( o ) => o . id === singleId ) ;
101- if ( obj && STACKED_2D_TYPES . has ( obj . type ) ) {
102- transformAnchorRef . current = {
103- nodeHeight : node . height ( ) ,
104- rowHeight : ( obj . props as { rowHeight : number } ) . rowHeight ,
105- } ;
106- } else {
107- transformAnchorRef . current = null ;
108- }
198+ transformAnchorRef . current = obj && STACKED_2D_TYPES . has ( obj . type )
199+ ? { nodeHeight : node . height ( ) , rowHeight : ( obj . props as { rowHeight : number } ) . rowHeight }
200+ : null ;
201+ const startRect = node . getClientRect ( { skipShadow : true , skipStroke : true , relativeTo : stageRef . current } ) ;
202+ transformStartBboxRef . current = toSnapRect ( singleId , startRect ) ;
203+ othersSnapshotRef . current = captureOtherRects ( stageRef . current , objects , singleId ) ;
109204 } ;
110205
111206 const boundBoxFunc = ( oldBox : BoundingBox , newBox : BoundingBox ) : BoundingBox => {
112207 if ( newBox . width < 10 || newBox . height < 10 ) return oldBox ;
113208 const dotPx = scale / dpmm ;
114- // For stacked 2D barcodes, snap to whole rowHeight increments. stepPx is
115- // derived from the node height captured at drag start (not oldBox.height,
116- // which mutates each call and would drift).
117- const anchor = transformAnchorRef . current ;
118- const stepPx =
119- anchor && anchor . rowHeight > 0 && anchor . nodeHeight > 0
120- ? anchor . nodeHeight / anchor . rowHeight
121- : dotPx ;
122- const snappedH = snapBoxHeight ( newBox . height , stepPx ) ;
123- if ( isTopAnchorResize ( oldBox , newBox , dotPx * 0.5 ) ) {
124- return pinBottomEdge ( oldBox , newBox , snappedH ) ;
209+ let bbox = applyHeightSnap ( oldBox , newBox , dotPx , transformAnchorRef . current ) ;
210+
211+ // Object-snap during resize (mirrors drag-time snap). Only fires when
212+ // grid-snap is off and the user is doing a free corner-resize — the 1D /
213+ // stacked-2D anchor restrictions have their own height math above and
214+ // would conflict with edge-driven snapping.
215+ const startBbox = transformStartBboxRef . current ;
216+ if ( objectSnapEnabled && isFreeResize && startBbox ) {
217+ const snapped = applyResizeObjectSnap ( bbox , startBbox , othersSnapshotRef . current , labelRect ) ;
218+ setGuides ( snapped . guides ) ;
219+ bbox = snapped . bbox ;
125220 }
126- return { ... newBox , height : snappedH } ;
221+ return bbox ;
127222 } ;
128223
129224 const onTransformEnd = ( ) => {
130- if ( selectedIds . length !== 1 || ! selectedIds [ 0 ] || ! stageRef . current ) return ;
225+ if ( selectedIds . length !== 1 || ! selectedIds [ 0 ] || ! stageRef . current ) {
226+ cleanupTransformState ( ) ;
227+ return ;
228+ }
131229 const singleId = selectedIds [ 0 ] ;
132230 const node = stageRef . current . findOne < Konva . Node > ( `#${ singleId } ` ) ;
133- if ( ! node ) return ;
231+ if ( ! node ) {
232+ cleanupTransformState ( ) ;
233+ return ;
234+ }
134235 const sx = node . scaleX ( ) ;
135236 const sy = node . scaleY ( ) ;
136237 const nodeWidth = node . width ( ) ;
@@ -139,7 +240,7 @@ export function useKonvaTransformer({
139240 node . scaleY ( 1 ) ;
140241 const obj = currentObjects ( useLabelStore . getState ( ) ) . find ( ( o ) => o . id === singleId ) ;
141242 if ( ! obj ) {
142- transformAnchorRef . current = null ;
243+ cleanupTransformState ( ) ;
143244 return ;
144245 }
145246 const isCenterAnchored = ObjectRegistry [ obj . type ] ?. nodeOrigin === "center" ;
@@ -175,7 +276,7 @@ export function useKonvaTransformer({
175276 } ) ;
176277 updateObject ( singleId , { ...pos , props : propChanges } ) ;
177278 }
178- transformAnchorRef . current = null ;
279+ cleanupTransformState ( ) ;
179280 } ;
180281
181282 return {
0 commit comments