@@ -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
@@ -785,6 +841,43 @@ export function useKonvaTransformer({
785841 cleanupTransformState ( ) ;
786842 return ;
787843 }
844+ // Shape live reflow: same collapse-to-one-entry as the text block. Dims were
845+ // kept float during the drag; round + snap them once here for the commit.
846+ const sr = shapeReflowRef . current ;
847+ if ( sr ) {
848+ const id = selectedIds [ 0 ] ;
849+ const srNode = id ? stageRef . current ?. findOne < Konva . Node > ( `#${ id } ` ) : null ;
850+ srNode ?. scaleX ( 1 ) ;
851+ srNode ?. scaleY ( 1 ) ;
852+ const cur = id ? findObjectById ( getCurrentObjects ( ) , id ) : undefined ;
853+ const temporal = useLabelStore . temporal . getState ( ) ;
854+ if ( sr . changed && id && cur && ! isGroup ( cur ) ) {
855+ const sp = cur . props as { width : number ; height : number } ;
856+ // Same snap/round/clamp contract as commitWidthHeightTransform, applied
857+ // to the already-baked float dims.
858+ const width = Math . max ( 1 , snap ( Math . round ( sp . width ) ) ) ;
859+ const height = Math . max ( 1 , snap ( Math . round ( sp . height ) ) ) ;
860+ // Pin the anchored edge: when dragging left/top, derive x/y from the
861+ // snapped size so the opposite edge doesn't walk by the snap delta.
862+ const edges = activeEdgesRef . current ;
863+ const final = {
864+ x : Math . round ( edges ?. left ? cur . x + sp . width - width : cur . x ) ,
865+ y : Math . round ( edges ?. top ? cur . y + sp . height - height : cur . y ) ,
866+ props : { width, height } ,
867+ } ;
868+ updateObject ( id , {
869+ x : sr . snapshot . x ,
870+ y : sr . snapshot . y ,
871+ props : { width : sr . snapshot . width , height : sr . snapshot . height } ,
872+ } ) ;
873+ temporal . resume ( ) ;
874+ updateObject ( id , final ) ;
875+ } else {
876+ temporal . resume ( ) ;
877+ }
878+ cleanupTransformState ( ) ;
879+ return ;
880+ }
788881 if ( selectedIds . length !== 1 || ! selectedIds [ 0 ] || ! stageRef . current ) {
789882 cleanupTransformState ( ) ;
790883 return ;
0 commit comments