smart guide

This commit is contained in:
hamid zarghami
2026-05-09 14:07:22 +03:30
parent 20118afeac
commit 9ead4b799b
2 changed files with 321 additions and 40 deletions
+17 -2
View File
@@ -12,7 +12,12 @@ import { useObjectHandlers } from "./canvas/useObjectHandlers";
import { useMaskGroupState } from "./canvas/useMaskGroupState";
import ObjectsLayer from "./canvas/ObjectsLayer";
import GuidesLayer from "./canvas/GuidesLayer";
import { getSnappedPosition, type Guide } from "./canvas/useSnap";
import {
clearSmartGuides,
drawSmartGuides,
getSnappedPosition,
type Guide,
} from "./canvas/useSnap";
import CellEditor from "@/components/CellEditor";
import ZoomControls from "./ZoomControls";
import EditorCanvasToolbar from "./EditorCanvasToolbar";
@@ -26,6 +31,7 @@ type EditorCanvasProps = {
const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const stageRef = useRef<Konva.Stage>(null);
const layerRef = useRef<Konva.Layer>(null);
const smartGuidesLayerRef = useRef<Konva.Layer>(null);
const activeGuideIdsRef = useRef<string[]>([]);
const stageWrapRef = useRef<HTMLDivElement>(null);
@@ -302,7 +308,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const node = e.target;
if (!node || node.name() === "guide-line") return;
const snapped = getSnappedPosition(node, guides);
const snapped = getSnappedPosition(node, guides, finalScale);
if (node.x() !== snapped.position.x || node.y() !== snapped.position.y) {
node.position(snapped.position);
@@ -318,10 +324,18 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
y: snapped.position.y,
});
}
drawSmartGuides(
smartGuidesLayerRef.current,
snapped.smartGuides,
stageSize.width,
stageSize.height,
);
};
const handleDragEnd = () => {
updateActiveGuides([]);
clearSmartGuides(smartGuidesLayerRef.current);
};
const handleStageMouseDownWithGuideReset = (e: Konva.KonvaEventObject<MouseEvent>) => {
@@ -448,6 +462,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
onGuideSelect={setSelectedGuideId}
onGuideDragEnd={handleGuideDragEnd}
/>
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
</Stage>
</div>
</div>
+304 -38
View File
@@ -6,87 +6,353 @@ export type Guide = {
position: number;
};
const SNAP_THRESHOLD = 10;
const SNAP_THRESHOLD_PX = 5;
export type SnappedPositionResult = {
position: { x: number; y: number };
activeGuideIds: string[];
smartGuides: SmartGuideLine[];
};
export type SmartGuideLine = {
orientation: "vertical" | "horizontal";
position: number;
};
type AxisSnapCandidate = {
guideId: string;
guideId?: string;
delta: number;
distance: number;
priority: number;
guides: SmartGuideLine[];
};
const findBestAxisSnap = (
guideInfos: Array<{ id: string; position: number }>,
type RectBounds = {
left: number;
right: number;
top: number;
bottom: number;
centerX: number;
centerY: number;
width: number;
height: number;
};
type Axis = "x" | "y";
const toRectBounds = (rect: Konva.IRect): RectBounds => {
const left = rect.x;
const top = rect.y;
const right = rect.x + rect.width;
const bottom = rect.y + rect.height;
return {
left,
right,
top,
bottom,
centerX: left + rect.width / 2,
centerY: top + rect.height / 2,
width: rect.width,
height: rect.height,
};
};
const axisLine = (axis: Axis, position: number): SmartGuideLine => ({
orientation: axis === "x" ? "vertical" : "horizontal",
position,
});
const isBetterCandidate = (
next: AxisSnapCandidate,
current: AxisSnapCandidate | null,
) => {
if (!current) return true;
if (next.distance < current.distance) return true;
if (next.distance > current.distance) return false;
return next.priority < current.priority;
};
const collectAlignmentCandidates = (
axis: Axis,
anchors: number[],
): AxisSnapCandidate | null => {
let best: AxisSnapCandidate | null = null;
guideInfos.forEach((guide) => {
targets: Array<{
position: number;
guideId?: string;
priority: number;
}>,
threshold: number,
) => {
const matches: AxisSnapCandidate[] = [];
targets.forEach((target) => {
anchors.forEach((anchor) => {
const distance = Math.abs(anchor - guide.position);
if (distance > SNAP_THRESHOLD) {
return;
}
if (!best || distance < best.distance) {
best = {
guideId: guide.id,
delta: guide.position - anchor,
distance,
};
}
const distance = Math.abs(anchor - target.position);
if (distance > threshold) return;
matches.push({
guideId: target.guideId,
delta: target.position - anchor,
distance,
priority: target.priority,
guides: [axisLine(axis, target.position)],
});
});
});
return matches;
};
const overlapsOnCrossAxis = (axis: Axis, a: RectBounds, b: RectBounds) => {
if (axis === "x") {
return !(a.bottom <= b.top || a.top >= b.bottom);
}
return !(a.right <= b.left || a.left >= b.right);
};
const collectEqualSpacingCandidates = (
axis: Axis,
moving: RectBounds,
others: RectBounds[],
threshold: number,
) => {
const matches: AxisSnapCandidate[] = [];
if (others.length < 2) return matches;
const sorted = [...others].sort((a, b) =>
axis === "x" ? a.left - b.left : a.top - b.top,
);
for (let i = 0; i < sorted.length - 1; i += 1) {
const first = sorted[i];
const second = sorted[i + 1];
if (
!overlapsOnCrossAxis(axis, moving, first) ||
!overlapsOnCrossAxis(axis, moving, second)
) {
continue;
}
const gap =
axis === "x" ? second.left - first.right : second.top - first.bottom;
if (gap <= 0) continue;
if (axis === "x") {
const targetLeft = first.right + gap;
const leftDistance = Math.abs(moving.left - targetLeft);
if (leftDistance <= threshold) {
matches.push({
delta: targetLeft - moving.left,
distance: leftDistance,
priority: 3,
guides: [
axisLine("x", targetLeft),
axisLine("x", targetLeft + moving.width),
],
});
}
const targetRight = second.left - gap;
const rightDistance = Math.abs(moving.right - targetRight);
if (rightDistance <= threshold) {
matches.push({
delta: targetRight - moving.right,
distance: rightDistance,
priority: 3,
guides: [
axisLine("x", targetRight - moving.width),
axisLine("x", targetRight),
],
});
}
} else {
const targetTop = first.bottom + gap;
const topDistance = Math.abs(moving.top - targetTop);
if (topDistance <= threshold) {
matches.push({
delta: targetTop - moving.top,
distance: topDistance,
priority: 3,
guides: [
axisLine("y", targetTop),
axisLine("y", targetTop + moving.height),
],
});
}
const targetBottom = second.top - gap;
const bottomDistance = Math.abs(moving.bottom - targetBottom);
if (bottomDistance <= threshold) {
matches.push({
delta: targetBottom - moving.bottom,
distance: bottomDistance,
priority: 3,
guides: [
axisLine("y", targetBottom - moving.height),
axisLine("y", targetBottom),
],
});
}
}
}
return matches;
};
const findBestCandidate = (candidates: AxisSnapCandidate[]) => {
let best: AxisSnapCandidate | null = null;
candidates.forEach((candidate) => {
if (isBetterCandidate(candidate, best)) {
best = candidate;
}
});
return best;
};
export const getSnappedPosition = (
node: Konva.Node,
guides: Guide[],
scale: number,
): SnappedPositionResult => {
const layer = node.getLayer();
const rect = node.getClientRect({
skipShadow: true,
...(layer ? { relativeTo: layer } : {}),
});
const threshold = SNAP_THRESHOLD_PX / Math.max(scale, 0.01);
const moving = toRectBounds(rect);
let x = node.x();
let y = node.y();
const activeGuideIds: string[] = [];
const smartGuides: SmartGuideLine[] = [];
const verticalGuides = guides
const manualVerticalTargets = guides
.filter((guide) => guide.orientation === "vertical")
.map((guide) => ({ id: guide.id, position: guide.position }));
const horizontalGuides = guides
.map((guide) => ({ position: guide.position, guideId: guide.id }));
const manualHorizontalTargets = guides
.filter((guide) => guide.orientation === "horizontal")
.map((guide) => ({ id: guide.id, position: guide.position }));
.map((guide) => ({ position: guide.position, guideId: guide.id }));
const verticalSnap = findBestAxisSnap(verticalGuides, [
rect.x,
rect.x + rect.width / 2,
rect.x + rect.width,
]);
if (verticalSnap) {
x += verticalSnap.delta;
activeGuideIds.push(verticalSnap.guideId);
const stage = node.getStage();
const stageWidth = stage ? stage.width() / stage.scaleX() : 0;
const stageHeight = stage ? stage.height() / stage.scaleY() : 0;
const otherRects: RectBounds[] = [];
if (layer) {
layer
.find((candidate) => Boolean(candidate))
.forEach((candidate) => {
if (candidate === node) return;
if (!candidate.draggable()) return;
if (!candidate.isVisible()) return;
if (candidate.id() === node.id()) return;
if (candidate.name() === "guide-line") return;
const candidateRect = candidate.getClientRect({
skipShadow: true,
relativeTo: layer,
});
if (candidateRect.width <= 0 || candidateRect.height <= 0) return;
otherRects.push(toRectBounds(candidateRect));
});
}
const horizontalSnap = findBestAxisSnap(horizontalGuides, [
rect.y,
rect.y + rect.height / 2,
rect.y + rect.height,
]);
const verticalCandidates: AxisSnapCandidate[] = [
...collectAlignmentCandidates(
"x",
[moving.left, moving.centerX, moving.right],
[
...manualVerticalTargets.map((target) => ({
position: target.position,
guideId: target.guideId,
priority: 0,
})),
...otherRects.flatMap((other) => [
{ position: other.left, priority: 1 },
{ position: other.centerX, priority: 1 },
{ position: other.right, priority: 1 },
]),
{ position: stageWidth / 2, priority: 2 },
],
threshold,
),
...collectEqualSpacingCandidates("x", moving, otherRects, threshold),
];
const verticalSnap = findBestCandidate(verticalCandidates);
if (verticalSnap) {
x += verticalSnap.delta;
if (verticalSnap.guideId) {
activeGuideIds.push(verticalSnap.guideId);
}
smartGuides.push(...verticalSnap.guides);
}
const horizontalCandidates: AxisSnapCandidate[] = [
...collectAlignmentCandidates(
"y",
[moving.top, moving.centerY, moving.bottom],
[
...manualHorizontalTargets.map((target) => ({
position: target.position,
guideId: target.guideId,
priority: 0,
})),
...otherRects.flatMap((other) => [
{ position: other.top, priority: 1 },
{ position: other.centerY, priority: 1 },
{ position: other.bottom, priority: 1 },
]),
{ position: stageHeight / 2, priority: 2 },
],
threshold,
),
...collectEqualSpacingCandidates("y", moving, otherRects, threshold),
];
const horizontalSnap = findBestCandidate(horizontalCandidates);
if (horizontalSnap) {
y += horizontalSnap.delta;
activeGuideIds.push(horizontalSnap.guideId);
if (horizontalSnap.guideId) {
activeGuideIds.push(horizontalSnap.guideId);
}
smartGuides.push(...horizontalSnap.guides);
}
return {
position: { x, y },
activeGuideIds,
smartGuides,
};
};
export const drawSmartGuides = (
layer: Konva.Layer | null,
guides: SmartGuideLine[],
stageWidth: number,
stageHeight: number,
) => {
if (!layer) return;
layer.destroyChildren();
guides.forEach((guide) => {
const line = new Konva.Line({
points:
guide.orientation === "vertical"
? [guide.position, 0, guide.position, stageHeight]
: [0, guide.position, stageWidth, guide.position],
stroke: "#ef4444",
strokeWidth: 1,
dash: [4, 4],
listening: false,
perfectDrawEnabled: false,
});
layer.add(line);
});
layer.visible(guides.length > 0);
layer.batchDraw();
};
export const clearSmartGuides = (layer: Konva.Layer | null) => {
if (!layer) return;
layer.destroyChildren();
layer.visible(false);
layer.batchDraw();
};