284 lines
7.6 KiB
TypeScript
284 lines
7.6 KiB
TypeScript
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<number>([
|
|
0,
|
|
margins.left,
|
|
pageWidth / 2,
|
|
pageWidth - margins.right,
|
|
pageWidth,
|
|
])
|
|
const y = new Set<number>([
|
|
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<string>()
|
|
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<string, unknown>)
|
|
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,
|
|
}
|
|
}
|