import type { Node as PMNode } from "@tiptap/pm/model" import type { ResizeHandle } from "./docs-graphic-layout.ts" import { usesPageLayer } from "./docs-graphic-position.ts" import { parseGraphicAttrs } from "./docs-graphic-types.ts" export const DOCS_GRAPHIC_SNAP_THRESHOLD_PX = 6 export type SnapRect = { x: number y: number width: number height: number } export type SnapGuideLine = { axis: "x" | "y" position: number } export type PageSnapContext = { pageWidth: number pageHeight: number margins: { top: number; right: number; bottom: number; left: number } otherRects: SnapRect[] } export type DocsGraphicSnapGuideState = { pageIndex: number pageWidth: number pageHeight: number guides: SnapGuideLine[] } function rectXEdges(rect: SnapRect): [number, number, number] { return [rect.x, rect.x + rect.width / 2, rect.x + rect.width] } function rectYEdges(rect: SnapRect): [number, number, number] { return [rect.y, rect.y + rect.height / 2, rect.y + rect.height] } export function buildPageSnapLines(ctx: PageSnapContext): { x: number[]; y: number[] } { const { pageWidth, pageHeight, margins, otherRects } = ctx const x = new Set([ 0, margins.left, pageWidth / 2, pageWidth - margins.right, pageWidth, ]) const y = new Set([ 0, margins.top, pageHeight / 2, pageHeight - margins.bottom, pageHeight, ]) for (const rect of otherRects) { for (const edge of rectXEdges(rect)) x.add(edge) for (const edge of rectYEdges(rect)) y.add(edge) } return { x: [...x], y: [...y] } } function bestAxisSnap( edges: number[], lines: number[], threshold: number ): { delta: number; guide: number | null } { let bestDist = threshold + 1 let bestDelta = 0 let bestGuide: number | null = null for (const edge of edges) { for (const line of lines) { const delta = line - edge const dist = Math.abs(delta) if (dist <= threshold && dist < bestDist) { bestDist = dist bestDelta = delta bestGuide = line } } } return { delta: bestDist <= threshold ? bestDelta : 0, guide: bestGuide } } export function snapMoveRect( rect: SnapRect, ctx: PageSnapContext, threshold = DOCS_GRAPHIC_SNAP_THRESHOLD_PX ): { rect: SnapRect; guides: SnapGuideLine[] } { const lines = buildPageSnapLines(ctx) const guides: SnapGuideLine[] = [] const snapX = bestAxisSnap(rectXEdges(rect), lines.x, threshold) const snapY = bestAxisSnap(rectYEdges(rect), lines.y, threshold) if (snapX.guide != null) guides.push({ axis: "x", position: snapX.guide }) if (snapY.guide != null) guides.push({ axis: "y", position: snapY.guide }) return { rect: { ...rect, x: Math.round(rect.x + snapX.delta), y: Math.round(rect.y + snapY.delta), }, guides, } } function movingEdgesForHandle(handle: ResizeHandle): { x: Array<"start" | "center" | "end"> y: Array<"start" | "center" | "end"> } { const x: Array<"start" | "center" | "end"> = [] const y: Array<"start" | "center" | "end"> = [] if (handle.includes("w")) x.push("start") if (handle.includes("e")) x.push("end") if (handle.includes("n")) y.push("start") if (handle.includes("s")) y.push("end") if (x.length === 0 && y.length === 0) { x.push("end") y.push("end") } return { x, y } } function edgeValue(rect: SnapRect, axis: "x" | "y", kind: "start" | "center" | "end"): number { if (axis === "x") { if (kind === "start") return rect.x if (kind === "center") return rect.x + rect.width / 2 return rect.x + rect.width } if (kind === "start") return rect.y if (kind === "center") return rect.y + rect.height / 2 return rect.y + rect.height } function setEdgeValue( rect: SnapRect, axis: "x" | "y", kind: "start" | "center" | "end", value: number ): SnapRect { const next = { ...rect } if (axis === "x") { if (kind === "start") { const right = rect.x + rect.width next.x = value next.width = Math.max(1, right - value) } else if (kind === "end") { next.width = Math.max(1, value - rect.x) } else { const half = rect.width / 2 next.x = value - half } } else { if (kind === "start") { const bottom = rect.y + rect.height next.y = value next.height = Math.max(1, bottom - value) } else if (kind === "end") { next.height = Math.max(1, value - rect.y) } else { const half = rect.height / 2 next.y = value - half } } return { x: Math.round(next.x), y: Math.round(next.y), width: Math.round(next.width), height: Math.round(next.height), } } function snapEdge( rect: SnapRect, axis: "x" | "y", kind: "start" | "center" | "end", lines: number[], threshold: number ): { rect: SnapRect; guide: number | null } { const current = edgeValue(rect, axis, kind) let bestDist = threshold + 1 let bestLine: number | null = null for (const line of lines) { const dist = Math.abs(line - current) if (dist <= threshold && dist < bestDist) { bestDist = dist bestLine = line } } if (bestLine == null) return { rect, guide: null } return { rect: setEdgeValue(rect, axis, kind, bestLine), guide: bestLine } } export function snapResizeRect( handle: ResizeHandle, rect: SnapRect, ctx: PageSnapContext, threshold = DOCS_GRAPHIC_SNAP_THRESHOLD_PX ): { rect: SnapRect; guides: SnapGuideLine[] } { const lines = buildPageSnapLines(ctx) const moving = movingEdgesForHandle(handle) let next = { ...rect } const guides: SnapGuideLine[] = [] for (const kind of moving.x) { const snapped = snapEdge(next, "x", kind, lines.x, threshold) next = snapped.rect if (snapped.guide != null) guides.push({ axis: "x", position: snapped.guide }) } for (const kind of moving.y) { const snapped = snapEdge(next, "y", kind, lines.y, threshold) next = snapped.rect if (snapped.guide != null) guides.push({ axis: "y", position: snapped.guide }) } return { rect: next, guides: dedupeGuides(guides) } } function dedupeGuides(guides: SnapGuideLine[]): SnapGuideLine[] { const seen = new Set() const out: SnapGuideLine[] = [] for (const guide of guides) { const key = `${guide.axis}:${guide.position}` if (seen.has(key)) continue seen.add(key) out.push(guide) } return out } export function collectOtherPageGraphicRects( doc: PMNode, pageIndex: number, excludePos: number | null ): SnapRect[] { const rects: SnapRect[] = [] doc.descendants((node, pos) => { if (node.type.name !== "docsGraphic") return const attrs = parseGraphicAttrs(node.attrs as Record) if (!usesPageLayer(attrs)) return if (attrs.pageIndex !== pageIndex) return if (excludePos != null && pos === excludePos) return rects.push({ x: attrs.pageX, y: attrs.pageY, width: attrs.width, height: attrs.height, }) }) return rects } export function readPageSnapContextFromStack( stack: HTMLElement | null, otherRects: SnapRect[] ): PageSnapContext | null { if (!stack) return null const pageWidth = Number.parseFloat(stack.dataset.docsPageWidth ?? "0") const pageHeight = Number.parseFloat(stack.dataset.docsPageHeight ?? "0") if (pageWidth <= 0 || pageHeight <= 0) return null return { pageWidth, pageHeight, margins: { top: Number.parseFloat(stack.dataset.docsPageMarginTop ?? "0") || 0, right: Number.parseFloat(stack.dataset.docsPageMarginRight ?? "0") || 0, bottom: Number.parseFloat(stack.dataset.docsPageMarginBottom ?? "0") || 0, left: Number.parseFloat(stack.dataset.docsPageMarginLeft ?? "0") || 0, }, otherRects, } }