Skip to content

Commit 5b0ea89

Browse files
committed
fix(canvas): shape live-resize, rotated-view action bar, manual unit input
Box/ellipse resize reflows live (anchored-edge pin + object-snap), the action bar tracks the rotated selection at rest and on-drag, and unit-number inputs accept manual entry.
1 parent 5e42ebf commit 5b0ea89

3 files changed

Lines changed: 216 additions & 67 deletions

File tree

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
115115
// Action-bar chrome refs live here so the drag onDelta can translate the bar
116116
// live (its own beforeDraw lags for controller-moved siblings).
117117
const actionBarRef = useRef<Konva.Group>(null);
118+
// The view-rotation group; the action bar lives outside it (stage coords), so
119+
// its rest bounds must be mapped through this group's transform when rotated.
120+
const rotationGroupRef = useRef<Konva.Group>(null);
118121
const lockedFrameRef = useRef<Konva.Group>(null);
119122
const dragActiveRef = useRef(false);
120123
const transformActiveRef = useRef(false);
@@ -451,6 +454,28 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
451454
}
452455
// Action-bar bounds: live client-rects during a transformer resize (model
453456
// commits only at the end), else optical model bounds (matches the drag center).
457+
// Map group-local bounds into stage coords (identity when unrotated). Single
458+
// source for the rest position (getBarBounds) and on-drag position (onDelta).
459+
const barBoundsToStage = (b: BarBounds): BarBounds => {
460+
const rg = rotationGroupRef.current;
461+
if (!rg || viewRotation === 0) return b;
462+
const tf = rg.getAbsoluteTransform();
463+
const pts = [
464+
tf.point({ x: b.minX, y: b.minY }),
465+
tf.point({ x: b.maxX, y: b.minY }),
466+
tf.point({ x: b.minX, y: b.maxY }),
467+
tf.point({ x: b.maxX, y: b.maxY }),
468+
];
469+
const xs = pts.map((p) => p.x);
470+
const ys = pts.map((p) => p.y);
471+
return {
472+
minX: Math.min(...xs),
473+
minY: Math.min(...ys),
474+
maxX: Math.max(...xs),
475+
maxY: Math.max(...ys),
476+
};
477+
};
478+
454479
const getBarBounds = (): BarBounds | null => {
455480
const stage = stageRef.current;
456481
if (transformActiveRef.current && stage) {
@@ -467,7 +492,9 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
467492
return minX === Infinity ? null : { minX, minY, maxX, maxY };
468493
}
469494
const u = toFramePx(selectionUnionDots(getCurrentObjects(), visibleSelIds, frameCtx));
470-
return u ? { minX: u.x, minY: u.y, maxX: u.x + u.width, maxY: u.y + u.height } : null;
495+
return u
496+
? barBoundsToStage({ minX: u.x, minY: u.y, maxX: u.x + u.width, maxY: u.y + u.height })
497+
: null;
471498
};
472499
useLayoutEffect(() => {
473500
const r = selectionFrameRef.current;
@@ -520,7 +547,14 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
520547
}
521548
bar.position(
522549
actionBarPosition(
523-
{ minX: live.x, minY: live.y, maxX: live.x + live.width, maxY: live.y + live.height },
550+
// `live` is group-local (the drag delta is in model space); map it to
551+
// stage coords so the bar tracks the rotated selection on-drag too.
552+
barBoundsToStage({
553+
minX: live.x,
554+
minY: live.y,
555+
maxX: live.x + live.width,
556+
maxY: live.y + live.height,
557+
}),
524558
barHalfRef.current.w,
525559
barHalfRef.current.h,
526560
stage.width(),
@@ -1037,6 +1071,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
10371071
<Layer>
10381072
{/* Pivot at labelCenter; node.x()/y() stays group-local during drags. */}
10391073
<Group
1074+
ref={rotationGroupRef}
10401075
x={labelCenterX}
10411076
y={labelCenterY}
10421077
rotation={viewRotation}

‎src/components/Canvas/hooks/useKonvaTransformer.ts‎

Lines changed: 144 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)