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