@@ -275,6 +275,13 @@ export function useKonvaTransformer({
275275 changed : boolean ;
276276 } | null > ( null ) ;
277277
278+ // Box/ellipse reflow per tick so stroke inset and corners stay correct under
279+ // raw scale; snapshot + dirty flag collapse the writes into one undo entry.
280+ const shapeReflowRef = useRef < {
281+ snapshot : { x : number ; y : number ; width : number ; height : number } ;
282+ changed : boolean ;
283+ } | null > ( null ) ;
284+
278285 // Block resize mode resolved once at drag start (panel mode + Alt). Reused at
279286 // commit so toggling Alt mid-drag can't make start and end disagree.
280287 const blockResizeModeRef = useRef < "frame" | "glyph" > ( "frame" ) ;
@@ -301,7 +308,9 @@ export function useKonvaTransformer({
301308 // idempotent set, so the no-active-drag case is a harmless no-op.
302309 useEffect ( ( ) => {
303310 return ( ) => {
304- if ( liveReflowRef . current ) useLabelStore . temporal . getState ( ) . resume ( ) ;
311+ if ( liveReflowRef . current || shapeReflowRef . current ) {
312+ useLabelStore . temporal . getState ( ) . resume ( ) ;
313+ }
305314 } ;
306315 } , [ ] ) ;
307316
@@ -333,7 +342,7 @@ export function useKonvaTransformer({
333342 // During a live reflow the per-tick store update re-runs this effect;
334343 // re-attaching (.nodes()) mid-drag aborts the gesture, so skip it. The
335344 // reflow handler keeps the transformer in sync via forceUpdate().
336- if ( liveReflowRef . current ) return ;
345+ if ( liveReflowRef . current || shapeReflowRef . current ) return ;
337346 if ( selectedIds . length === 0 ) {
338347 transformerRef . current . nodes ( [ ] ) ;
339348 return ;
@@ -430,6 +439,7 @@ export function useKonvaTransformer({
430439 uniformStartRectRef . current = null ;
431440 activeEdgesRef . current = null ;
432441 liveReflowRef . current = null ;
442+ shapeReflowRef . current = null ;
433443 glyphBlockStartRectRef . current = null ;
434444 blockResizeModeRef . current = "frame" ;
435445 setGuides ( [ ] ) ;
@@ -596,15 +606,24 @@ export function useKonvaTransformer({
596606 } ) ;
597607 }
598608 }
609+ // Free-resize shapes reflow live (see onTransform): pause undo so the
610+ // per-tick writes collapse into one entry on release.
611+ if ( obj && ! isGroup ( obj ) && ( obj . type === "box" || obj . type === "ellipse" ) ) {
612+ const sp = obj . props as { width : number ; height : number } ;
613+ shapeReflowRef . current = {
614+ snapshot : { x : obj . x , y : obj . y , width : sp . width , height : sp . height } ,
615+ changed : false ,
616+ } ;
617+ useLabelStore . temporal . getState ( ) . pause ( ) ;
618+ }
599619 } ;
600620
601621 // Per-tick live reflow for a frame-mode ^FB block: convert the group scale
602622 // into blockWidth/blockLines, re-wrap via a synchronous store update, reset
603623 // the scale, re-pin the anchored edge, and re-baseline the handles. Keeps
604624 // glyphs constant and justify correct because the content actually re-renders.
605625 const onTransform = ( ) => {
606- const lr = liveReflowRef . current ;
607- if ( ! lr || selectedIds . length !== 1 || ! stageRef . current ) return ;
626+ if ( selectedIds . length !== 1 || ! stageRef . current ) return ;
608627 const id = selectedIds [ 0 ] ;
609628 if ( ! id ) return ;
610629 const node = stageRef . current . findOne < Konva . Node > ( `#${ id } ` ) ;
@@ -614,51 +633,82 @@ export function useKonvaTransformer({
614633 if ( Math . abs ( sx - 1 ) < 1e-3 && Math . abs ( sy - 1 ) < 1e-3 ) return ;
615634 const cur = findObjectById ( getCurrentObjects ( ) , id ) ;
616635 if ( ! cur || isGroup ( cur ) ) return ;
617- const cp = cur . props as { blockWidth ?: number ; blockLines ?: number ; blockHeight ?: number } ;
618- const common = {
619- scaleX : sx ,
620- scaleY : sy ,
621- rotation : lr . rotation ,
622- blockWidthDots : cp . blockWidth ?? 1 ,
623- activeLeft : lr . edges ?. left ?? false ,
624- activeTop : lr . edges ?. top ?? false ,
625- leftX : lr . leftX ,
626- topY : lr . topY ,
627- rightX : lr . rightX ,
628- bottomY : lr . bottomY ,
629- scale,
630- dpmm,
631- objectsOffsetX,
632- labelOffsetY,
633- } ;
634- const geo =
635- lr . mode === "tb"
636- ? ( ( ) => {
637- const g = tbReflowGeometry ( { ...common , blockHeightDots : cp . blockHeight ?? lr . fontHeight } ) ;
638- return { ...g , props : { blockWidth : g . blockWidthDots , blockHeight : g . blockHeightDots } } ;
639- } ) ( )
640- : ( ( ) => {
641- const g = blockReflowGeometry ( {
642- ...common ,
643- blockLines : cp . blockLines ?? 1 ,
644- blockLineSpacing : lr . lineSpacing ,
645- fontHeight : lr . fontHeight ,
646- } ) ;
647- return { ...g , props : { blockWidth : g . blockWidthDots , blockLines : g . blockLines } } ;
648- } ) ( ) ;
649- flushSync ( ( ) => {
650- updateObject ( id , {
651- x : geo . modelXDots ,
652- y : geo . modelYDots ,
653- props : geo . props ,
636+ const lr = liveReflowRef . current ;
637+ if ( lr ) {
638+ const cp = cur . props as { blockWidth ?: number ; blockLines ?: number ; blockHeight ?: number } ;
639+ const common = {
640+ scaleX : sx ,
641+ scaleY : sy ,
642+ rotation : lr . rotation ,
643+ blockWidthDots : cp . blockWidth ?? 1 ,
644+ activeLeft : lr . edges ?. left ?? false ,
645+ activeTop : lr . edges ?. top ?? false ,
646+ leftX : lr . leftX ,
647+ topY : lr . topY ,
648+ rightX : lr . rightX ,
649+ bottomY : lr . bottomY ,
650+ scale,
651+ dpmm,
652+ objectsOffsetX,
653+ labelOffsetY,
654+ } ;
655+ const geo =
656+ lr . mode === "tb"
657+ ? ( ( ) => {
658+ const g = tbReflowGeometry ( { ...common , blockHeightDots : cp . blockHeight ?? lr . fontHeight } ) ;
659+ return { ...g , props : { blockWidth : g . blockWidthDots , blockHeight : g . blockHeightDots } } ;
660+ } ) ( )
661+ : ( ( ) => {
662+ const g = blockReflowGeometry ( {
663+ ...common ,
664+ blockLines : cp . blockLines ?? 1 ,
665+ blockLineSpacing : lr . lineSpacing ,
666+ fontHeight : lr . fontHeight ,
667+ } ) ;
668+ return { ...g , props : { blockWidth : g . blockWidthDots , blockLines : g . blockLines } } ;
669+ } ) ( ) ;
670+ flushSync ( ( ) => {
671+ updateObject ( id , {
672+ x : geo . modelXDots ,
673+ y : geo . modelYDots ,
674+ props : geo . props ,
675+ } ) ;
654676 } ) ;
655- } ) ;
656- lr . changed = true ;
657- node . scaleX ( 1 ) ;
658- node . scaleY ( 1 ) ;
659- node . x ( geo . targetXPx ) ;
660- node . y ( geo . targetYPx ) ;
661- transformerRef . current ?. forceUpdate ( ) ;
677+ lr . changed = true ;
678+ node . scaleX ( 1 ) ;
679+ node . scaleY ( 1 ) ;
680+ node . x ( geo . targetXPx ) ;
681+ node . y ( geo . targetYPx ) ;
682+ transformerRef . current ?. forceUpdate ( ) ;
683+ return ;
684+ }
685+
686+ // Box/ellipse: bake the group scale into width/height each tick for correct
687+ // stroke inset; dims stay float until release to avoid per-tick rounding drift.
688+ const sr = shapeReflowRef . current ;
689+ if ( sr ) {
690+ const sp = cur . props as { width : number ; height : number ; lockAspect ?: boolean } ;
691+ // boundBoxFunc is skipped during reflow, so a locked circle enforces its
692+ // own uniform scale here, matching the ellipse commit's min-axis collapse.
693+ const [ scaleW , scaleH ] = sp . lockAspect
694+ ? [ Math . min ( sx , sy ) , Math . min ( sx , sy ) ]
695+ : [ sx , sy ] ;
696+ const newW = Math . max ( 1 , sp . width * scaleW ) ;
697+ const newH = Math . max ( 1 , sp . height * scaleH ) ;
698+ // Konva already pinned the anchored edge via node.x/y; commit that, then
699+ // re-pin from the same dots so node and store agree after the re-render.
700+ const xDots = pxToDots ( node . x ( ) - objectsOffsetX , scale , dpmm ) ;
701+ const yDots = pxToDots ( node . y ( ) - labelOffsetY , scale , dpmm ) ;
702+ flushSync ( ( ) => {
703+ updateObject ( id , { x : xDots , y : yDots , props : { width : newW , height : newH } } ) ;
704+ } ) ;
705+ sr . changed = true ;
706+ node . scaleX ( 1 ) ;
707+ node . scaleY ( 1 ) ;
708+ node . x ( objectsOffsetX + dotsToPx ( xDots , scale , dpmm ) ) ;
709+ node . y ( labelOffsetY + dotsToPx ( yDots , scale , dpmm ) ) ;
710+ transformerRef . current ?. forceUpdate ( ) ;
711+ }
662712 } ;
663713
664714 const boundBoxFunc = (
@@ -668,10 +718,16 @@ export function useKonvaTransformer({
668718 if ( newBox . width < MIN_RESIZE_BOX_PX || newBox . height < MIN_RESIZE_BOX_PX ) {
669719 return oldBox ;
670720 }
671- // A frame-mode block reflow re-pins itself from the drag-start edges in
672- // onTransform; running the snap / inactive-edge pin here too would draw
673- // snap guides that don't match where the block actually lands.
721+ // The text-block reflow re-pins from its own captured edges, so the snap /
722+ // inactive-edge pin below would fight it; skip entirely.
674723 if ( liveReflowRef . current ) return newBox ;
724+ // Locked-circle reflow needs forceSquareBox so Konva keeps sx==sy; node and
725+ // reflow only agree when the bbox stays square. Snap/pin below is free-axis only.
726+ if ( shapeReflowRef . current && isUniformScale ) {
727+ return forceSquareBox ( oldBox , newBox ) ;
728+ }
729+ // Free-resize shapes fall through: object-snap + inactive-edge pin still
730+ // apply (reflow pins from the same node position), keeping edge snapping.
675731 if ( isUniformScale ) newBox = forceSquareBox ( oldBox , newBox ) ;
676732 // When the canvas view is rotated, Konva's bbox semantics in this
677733 // callback no longer match an axis-aware snap / pin model; visual
@@ -755,34 +811,77 @@ export function useKonvaTransformer({
755811 lrNode ?. scaleY ( 1 ) ;
756812 const cur = id ? findObjectById ( getCurrentObjects ( ) , id ) : undefined ;
757813 const temporal = useLabelStore . temporal . getState ( ) ;
758- if ( lr . changed && id && cur && ! isGroup ( cur ) ) {
759- const cp = cur . props as { blockWidth ?: number ; blockLines ?: number ; blockHeight ?: number } ;
760- // ^TB collapses width + clip height; ^FB width + line count.
761- const secondAxis = lr . mode === "tb"
762- ? { blockHeight : cp . blockHeight }
763- : { blockLines : cp . blockLines } ;
764- const snapAxis = lr . mode === "tb"
765- ? { blockHeight : lr . snapshot . blockHeight }
766- : { blockLines : lr . snapshot . blockLines } ;
767- const final = {
768- x : cur . x ,
769- y : cur . y ,
770- props : { blockWidth : cp . blockWidth , ...secondAxis } ,
771- } ;
772- updateObject ( id , {
773- x : lr . snapshot . x ,
774- y : lr . snapshot . y ,
775- props : {
776- blockWidth : lr . snapshot . blockWidth ,
777- ...snapAxis ,
778- } ,
779- } ) ;
814+ // try/finally so a throw can't leave undo paused (resume is idempotent).
815+ try {
816+ if ( lr . changed && id && cur && ! isGroup ( cur ) ) {
817+ const cp = cur . props as { blockWidth ?: number ; blockLines ?: number ; blockHeight ?: number } ;
818+ // ^TB collapses width + clip height; ^FB width + line count.
819+ const secondAxis = lr . mode === "tb"
820+ ? { blockHeight : cp . blockHeight }
821+ : { blockLines : cp . blockLines } ;
822+ const snapAxis = lr . mode === "tb"
823+ ? { blockHeight : lr . snapshot . blockHeight }
824+ : { blockLines : lr . snapshot . blockLines } ;
825+ const final = {
826+ x : cur . x ,
827+ y : cur . y ,
828+ props : { blockWidth : cp . blockWidth , ...secondAxis } ,
829+ } ;
830+ updateObject ( id , {
831+ x : lr . snapshot . x ,
832+ y : lr . snapshot . y ,
833+ props : {
834+ blockWidth : lr . snapshot . blockWidth ,
835+ ...snapAxis ,
836+ } ,
837+ } ) ;
838+ temporal . resume ( ) ;
839+ updateObject ( id , final ) ;
840+ }
841+ } finally {
780842 temporal . resume ( ) ;
781- updateObject ( id , final ) ;
782- } else {
843+ cleanupTransformState ( ) ;
844+ }
845+ return ;
846+ }
847+ // Shape live reflow: same collapse-to-one-entry as the text block. Dims were
848+ // kept float during the drag; round + snap them once here for the commit.
849+ const sr = shapeReflowRef . current ;
850+ if ( sr ) {
851+ const id = selectedIds [ 0 ] ;
852+ const srNode = id ? stageRef . current ?. findOne < Konva . Node > ( `#${ id } ` ) : null ;
853+ srNode ?. scaleX ( 1 ) ;
854+ srNode ?. scaleY ( 1 ) ;
855+ const cur = id ? findObjectById ( getCurrentObjects ( ) , id ) : undefined ;
856+ const temporal = useLabelStore . temporal . getState ( ) ;
857+ // try/finally so a throw can't leave undo paused (resume is idempotent).
858+ try {
859+ if ( sr . changed && id && cur && ! isGroup ( cur ) ) {
860+ const sp = cur . props as { width : number ; height : number } ;
861+ // Same snap/round/clamp contract as commitWidthHeightTransform, applied
862+ // to the already-baked float dims.
863+ const width = Math . max ( 1 , snap ( Math . round ( sp . width ) ) ) ;
864+ const height = Math . max ( 1 , snap ( Math . round ( sp . height ) ) ) ;
865+ // Pin the anchored edge: when dragging left/top, derive x/y from the
866+ // snapped size so the opposite edge doesn't walk by the snap delta.
867+ const edges = activeEdgesRef . current ;
868+ const final = {
869+ x : Math . round ( edges ?. left ? cur . x + sp . width - width : cur . x ) ,
870+ y : Math . round ( edges ?. top ? cur . y + sp . height - height : cur . y ) ,
871+ props : { width, height } ,
872+ } ;
873+ updateObject ( id , {
874+ x : sr . snapshot . x ,
875+ y : sr . snapshot . y ,
876+ props : { width : sr . snapshot . width , height : sr . snapshot . height } ,
877+ } ) ;
878+ temporal . resume ( ) ;
879+ updateObject ( id , final ) ;
880+ }
881+ } finally {
783882 temporal . resume ( ) ;
883+ cleanupTransformState ( ) ;
784884 }
785- cleanupTransformState ( ) ;
786885 return ;
787886 }
788887 if ( selectedIds . length !== 1 || ! selectedIds [ 0 ] || ! stageRef . current ) {
0 commit comments