diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 8e41e85..3e8b522 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -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(null); const layerRef = useRef(null); + const smartGuidesLayerRef = useRef(null); const activeGuideIdsRef = useRef([]); const stageWrapRef = useRef(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) => { @@ -448,6 +462,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { onGuideSelect={setSelectedGuideId} onGuideDragEnd={handleGuideDragEnd} /> + diff --git a/src/pages/editor/components/canvas/useSnap.ts b/src/pages/editor/components/canvas/useSnap.ts index 1e1f871..bc7564a 100644 --- a/src/pages/editor/components/canvas/useSnap.ts +++ b/src/pages/editor/components/canvas/useSnap.ts @@ -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(); +};