Skip to content

Commit f13c29d

Browse files
authored
Merge pull request #193 from u8array/fix/unit-input-manual-entry
fix(canvas): shape live-resize, rotated-view action bar, manual unit …
2 parents 5e42ebf + 86dcc4a commit f13c29d

3 files changed

Lines changed: 247 additions & 92 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: 175 additions & 76 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
@@ -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

Comments
 (0)