smart guide
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user