Skip to content

Commit eb77ac2

Browse files
authored
Merge pull request #72 from u8array/fix/smart-align-on-resize
feat(line): smart-align snap on thickness side-handle drag
2 parents f94f20f + 6e2989d commit eb77ac2

1 file changed

Lines changed: 99 additions & 5 deletions

File tree

‎src/components/Canvas/LineObject.tsx‎

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import type { LabelObject } from "../../types/Group";
55
import { dotsToPx, pxToDots } from "../../lib/coordinates";
66
import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain";
77
import { useColorScheme } from "../../lib/useColorScheme";
8-
import { computePointSnap, type SnapRect } from "../../lib/snapGuides";
8+
import {
9+
computePointSnap,
10+
computeResizeSnap,
11+
type SnapRect,
12+
} from "../../lib/snapGuides";
913
import { diagonalPolygonPoints } from "../../lib/shapeGeometry";
1014
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
1115

@@ -223,6 +227,90 @@ export function LineObject({
223227
setGuides?.([]);
224228
}
225229

230+
/**
231+
* Smart-align snap for the thickness side-handle. Treats the band as a
232+
* resize bbox with only the extrusion edge active (`bottom` for horizontal
233+
* lines, `right` for vertical) and delegates to `computeResizeSnap`, the
234+
* same path the Transformer uses for free resizes. Returns the snapped
235+
* thickness in dots. No-ops on diagonals (band axis isn't stage-aligned),
236+
* rotated views (AABB conversion would mislabel the active edge), or
237+
* shift-bypass.
238+
*/
239+
function snapThickness(
240+
rawT: number,
241+
shift: boolean,
242+
parent: Konva.Node | null,
243+
): number {
244+
const absRot = parent ? Math.abs(parent.getAbsoluteRotation() % 360) : 0;
245+
const rotated = absRot > 0.1 && absRot < 359.9;
246+
if (
247+
!isAxisAligned ||
248+
shift ||
249+
rotated ||
250+
!getOthersSnapshot ||
251+
!labelRect ||
252+
!setGuides ||
253+
!parent
254+
) {
255+
setGuides?.([]);
256+
return rawT;
257+
}
258+
if (othersSnapshotRef.current === null) {
259+
othersSnapshotRef.current = getOthersSnapshot(obj.id);
260+
}
261+
const transform = parent.getAbsoluteTransform();
262+
const thicknessPx = Math.max(dotsToPx(rawT, scale, dpmm), 1);
263+
// Band bbox in label-local coords, then mapped to stage so the others
264+
// snapshot (already in stage frame) can be compared apples-to-apples.
265+
const localBand = isHorizontal
266+
? {
267+
x: Math.min(dispX1, dispX2),
268+
y: lineCenterY,
269+
width: visualLenPx,
270+
height: thicknessPx,
271+
}
272+
: {
273+
x: lineCenterX,
274+
y: Math.min(dispY1, dispY2),
275+
width: thicknessPx,
276+
height: visualLenPx,
277+
};
278+
const tl = transform.point({ x: localBand.x, y: localBand.y });
279+
const br = transform.point({
280+
x: localBand.x + localBand.width,
281+
y: localBand.y + localBand.height,
282+
});
283+
const stageBand: SnapRect = {
284+
id: obj.id,
285+
x: Math.min(tl.x, br.x),
286+
y: Math.min(tl.y, br.y),
287+
width: Math.abs(br.x - tl.x),
288+
height: Math.abs(br.y - tl.y),
289+
};
290+
const activeEdges = isHorizontal
291+
? { top: false, bottom: true, left: false, right: false }
292+
: { top: false, bottom: false, left: false, right: true };
293+
const result = computeResizeSnap(
294+
stageBand,
295+
othersSnapshotRef.current,
296+
activeEdges,
297+
undefined,
298+
labelRect,
299+
labelRect,
300+
);
301+
const snappedExtPx = isHorizontal ? result.height : result.width;
302+
const cappedT = Math.min(
303+
p.length,
304+
Math.max(1, Math.round(pxToDots(snappedExtPx, scale, dpmm))),
305+
);
306+
// If the t ≤ length cap forced the band shorter than the snap target,
307+
// the guide would point past the visible band edge — clear it so the
308+
// hint matches what the user sees commit.
309+
const cappedExtPx = dotsToPx(cappedT, scale, dpmm);
310+
setGuides(Math.abs(cappedExtPx - snappedExtPx) < 0.5 ? result.guides : []);
311+
return cappedT;
312+
}
313+
226314
/**
227315
* Full endpoint-drag pipeline: axis constraint → object snap → final
228316
* geometry derivation. Returns the same shape as `project` so call
@@ -575,14 +663,19 @@ export function LineObject({
575663
// promotion regime where thickness exceeds length and
576664
// Labelary would print `t × t` rather than the band the
577665
// user is dragging.
578-
const newT = Math.min(
666+
const rawT = Math.min(
579667
p.length,
580668
Math.max(1, Math.round(pxToDots(extPx, scale, dpmm))),
581669
);
670+
const newT = snapThickness(
671+
rawT,
672+
e.evt.shiftKey,
673+
e.target.getParent(),
674+
);
582675
setLiveThicknessDots(newT);
583-
// Pin the Rect to the (possibly-clamped) anchor so
584-
// dragging past the minimum doesn't decouple the handle
585-
// from the band edge.
676+
// Pin the Rect to the (possibly-clamped, possibly-snapped)
677+
// anchor so dragging past the minimum doesn't decouple the
678+
// handle from the band edge.
586679
const newStroke = Math.max(dotsToPx(newT, scale, dpmm), 1);
587680
e.target.position({
588681
x:
@@ -598,6 +691,7 @@ export function LineObject({
598691
onDragEnd={() => {
599692
const committed = liveThicknessDots;
600693
setLiveThicknessDots(null);
694+
clearSnap();
601695
if (committed !== null && committed !== p.thickness) {
602696
onChange({ props: { thickness: committed } });
603697
}

0 commit comments

Comments
 (0)