ultisuite-client/lib/drive/docs-graphic-snap.ts
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

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,
}
}