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 { useMaskGroupState } from "./canvas/useMaskGroupState";
import ObjectsLayer from "./canvas/ObjectsLayer"; import ObjectsLayer from "./canvas/ObjectsLayer";
import GuidesLayer from "./canvas/GuidesLayer"; 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 CellEditor from "@/components/CellEditor";
import ZoomControls from "./ZoomControls"; import ZoomControls from "./ZoomControls";
import EditorCanvasToolbar from "./EditorCanvasToolbar"; import EditorCanvasToolbar from "./EditorCanvasToolbar";
@@ -26,6 +31,7 @@ type EditorCanvasProps = {
const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const stageRef = useRef<Konva.Stage>(null); const stageRef = useRef<Konva.Stage>(null);
const layerRef = useRef<Konva.Layer>(null); const layerRef = useRef<Konva.Layer>(null);
const smartGuidesLayerRef = useRef<Konva.Layer>(null);
const activeGuideIdsRef = useRef<string[]>([]); const activeGuideIdsRef = useRef<string[]>([]);
const stageWrapRef = useRef<HTMLDivElement>(null); const stageWrapRef = useRef<HTMLDivElement>(null);
@@ -302,7 +308,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const node = e.target; const node = e.target;
if (!node || node.name() === "guide-line") return; 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) { if (node.x() !== snapped.position.x || node.y() !== snapped.position.y) {
node.position(snapped.position); node.position(snapped.position);
@@ -318,10 +324,18 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
y: snapped.position.y, y: snapped.position.y,
}); });
} }
drawSmartGuides(
smartGuidesLayerRef.current,
snapped.smartGuides,
stageSize.width,
stageSize.height,
);
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
updateActiveGuides([]); updateActiveGuides([]);
clearSmartGuides(smartGuidesLayerRef.current);
}; };
const handleStageMouseDownWithGuideReset = (e: Konva.KonvaEventObject<MouseEvent>) => { const handleStageMouseDownWithGuideReset = (e: Konva.KonvaEventObject<MouseEvent>) => {
@@ -448,6 +462,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
onGuideSelect={setSelectedGuideId} onGuideSelect={setSelectedGuideId}
onGuideDragEnd={handleGuideDragEnd} onGuideDragEnd={handleGuideDragEnd}
/> />
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
</Stage> </Stage>
</div> </div>
</div> </div>
+304 -38
View File
@@ -6,87 +6,353 @@ export type Guide = {
position: number; position: number;
}; };
const SNAP_THRESHOLD = 10; const SNAP_THRESHOLD_PX = 5;
export type SnappedPositionResult = { export type SnappedPositionResult = {
position: { x: number; y: number }; position: { x: number; y: number };
activeGuideIds: string[]; activeGuideIds: string[];
smartGuides: SmartGuideLine[];
};
export type SmartGuideLine = {
orientation: "vertical" | "horizontal";
position: number;
}; };
type AxisSnapCandidate = { type AxisSnapCandidate = {
guideId: string; guideId?: string;
delta: number; delta: number;
distance: number; distance: number;
priority: number;
guides: SmartGuideLine[];
}; };
const findBestAxisSnap = ( type RectBounds = {
guideInfos: Array<{ id: string; position: number }>, 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[], anchors: number[],
): AxisSnapCandidate | null => { targets: Array<{
let best: AxisSnapCandidate | null = null; position: number;
guideId?: string;
guideInfos.forEach((guide) => { priority: number;
}>,
threshold: number,
) => {
const matches: AxisSnapCandidate[] = [];
targets.forEach((target) => {
anchors.forEach((anchor) => { anchors.forEach((anchor) => {
const distance = Math.abs(anchor - guide.position); const distance = Math.abs(anchor - target.position);
if (distance > SNAP_THRESHOLD) { if (distance > threshold) return;
return; matches.push({
} guideId: target.guideId,
delta: target.position - anchor,
if (!best || distance < best.distance) { distance,
best = { priority: target.priority,
guideId: guide.id, guides: [axisLine(axis, target.position)],
delta: guide.position - anchor, });
distance,
};
}
}); });
}); });
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; return best;
}; };
export const getSnappedPosition = ( export const getSnappedPosition = (
node: Konva.Node, node: Konva.Node,
guides: Guide[], guides: Guide[],
scale: number,
): SnappedPositionResult => { ): SnappedPositionResult => {
const layer = node.getLayer(); const layer = node.getLayer();
const rect = node.getClientRect({ const rect = node.getClientRect({
skipShadow: true, skipShadow: true,
...(layer ? { relativeTo: layer } : {}), ...(layer ? { relativeTo: layer } : {}),
}); });
const threshold = SNAP_THRESHOLD_PX / Math.max(scale, 0.01);
const moving = toRectBounds(rect);
let x = node.x(); let x = node.x();
let y = node.y(); let y = node.y();
const activeGuideIds: string[] = []; const activeGuideIds: string[] = [];
const smartGuides: SmartGuideLine[] = [];
const verticalGuides = guides const manualVerticalTargets = guides
.filter((guide) => guide.orientation === "vertical") .filter((guide) => guide.orientation === "vertical")
.map((guide) => ({ id: guide.id, position: guide.position })); .map((guide) => ({ position: guide.position, guideId: guide.id }));
const horizontalGuides = guides const manualHorizontalTargets = guides
.filter((guide) => guide.orientation === "horizontal") .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, [ const stage = node.getStage();
rect.x, const stageWidth = stage ? stage.width() / stage.scaleX() : 0;
rect.x + rect.width / 2, const stageHeight = stage ? stage.height() / stage.scaleY() : 0;
rect.x + rect.width,
]); const otherRects: RectBounds[] = [];
if (verticalSnap) { if (layer) {
x += verticalSnap.delta; layer
activeGuideIds.push(verticalSnap.guideId); .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, [ const verticalCandidates: AxisSnapCandidate[] = [
rect.y, ...collectAlignmentCandidates(
rect.y + rect.height / 2, "x",
rect.y + rect.height, [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) { if (horizontalSnap) {
y += horizontalSnap.delta; y += horizontalSnap.delta;
activeGuideIds.push(horizontalSnap.guideId); if (horizontalSnap.guideId) {
activeGuideIds.push(horizontalSnap.guideId);
}
smartGuides.push(...horizontalSnap.guides);
} }
return { return {
position: { x, y }, position: { x, y },
activeGuideIds, 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();
};