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

295 lines
8.7 KiB
TypeScript

import type { Editor } from "@tiptap/core"
import type { ResizeHandle } from "./docs-graphic-layout.ts"
import { moveInFlowGraphic } from "./docs-graphic-flow-move.ts"
import { usesPageLayer } from "./docs-graphic-position.ts"
import {
clearDocsGraphicSnapGuides,
setDocsGraphicSnapGuides,
} from "./docs-graphic-snap-bridge.ts"
import {
collectOtherPageGraphicRects,
readPageSnapContextFromStack,
snapMoveRect,
snapResizeRect,
} from "./docs-graphic-snap.ts"
import {
parseGraphicAttrs,
type DocsGraphicAttrs,
type DocsGraphicFloatSide,
} from "./docs-graphic-types.ts"
export const GRAPHIC_KEYBOARD_MIN_SIZE = 24
export type GraphicKeyboardAction =
| { type: "move"; dx: number; dy: number }
| { type: "resize"; dw: number; dh: number }
| { type: "rotate"; deltaDeg: number }
| { type: "layer"; direction: "forward" | "backward" }
export function readActiveGraphicAttrs(editor: Editor): DocsGraphicAttrs | null {
const name = editor.isActive("docsInlineGraphic")
? "docsInlineGraphic"
: editor.isActive("docsGraphic")
? "docsGraphic"
: null
if (!name) return null
return parseGraphicAttrs(editor.getAttributes(name) as Record<string, unknown>)
}
export function isGraphicNodeActive(editor: Editor): boolean {
return readActiveGraphicAttrs(editor) != null
}
const FLOAT_SIDE_ORDER: DocsGraphicFloatSide[] = ["left", "center", "right"]
/** Graphics anchored in the text flow (mode « Avec le texte »). */
export function isInFlowGraphic(attrs: DocsGraphicAttrs): boolean {
return attrs.positionMode === "move-with-text" && !usesPageLayer(attrs)
}
export function cycleFloatSide(
current: DocsGraphicFloatSide,
direction: "left" | "right"
): DocsGraphicFloatSide {
const index = FLOAT_SIDE_ORDER.indexOf(current)
const step = direction === "right" ? 1 : -1
return FLOAT_SIDE_ORDER[(index + step + FLOAT_SIDE_ORDER.length) % FLOAT_SIDE_ORDER.length]!
}
function clampSize(value: number): number {
return Math.max(GRAPHIC_KEYBOARD_MIN_SIZE, Math.round(value))
}
let keyboardSnapGuideTimer: ReturnType<typeof setTimeout> | null = null
function keyboardResizeHandle(dw: number, dh: number): ResizeHandle {
if (dw < 0 && dh < 0) return "nw"
if (dw > 0 && dh < 0) return "ne"
if (dw < 0 && dh > 0) return "sw"
if (dw > 0 && dh > 0) return "se"
if (dw < 0) return "w"
if (dw > 0) return "e"
if (dh < 0) return "n"
return "s"
}
function readPageLayerSnapContext(editor: Editor, attrs: DocsGraphicAttrs) {
if (typeof document === "undefined" || !usesPageLayer(attrs)) return null
const stack = document.querySelector("[data-docs-page-stack]") as HTMLElement | null
const pos = editor.state.selection.from
const otherRects = collectOtherPageGraphicRects(
editor.state.doc,
attrs.pageIndex,
pos
)
return readPageSnapContextFromStack(stack, otherRects)
}
function flashKeyboardSnapGuides(
pageIndex: number,
pageWidth: number,
pageHeight: number,
guides: ReturnType<typeof snapMoveRect>["guides"]
) {
if (guides.length === 0) {
clearDocsGraphicSnapGuides()
return
}
setDocsGraphicSnapGuides({ pageIndex, pageWidth, pageHeight, guides })
if (keyboardSnapGuideTimer) clearTimeout(keyboardSnapGuideTimer)
keyboardSnapGuideTimer = setTimeout(() => {
clearDocsGraphicSnapGuides()
keyboardSnapGuideTimer = null
}, 650)
}
function applyPageLayerSnap(
attrs: DocsGraphicAttrs,
patch: Partial<DocsGraphicAttrs>,
editor: Editor,
action: Extract<GraphicKeyboardAction, { type: "move" | "resize" }>
): Partial<DocsGraphicAttrs> {
const ctx = readPageLayerSnapContext(editor, attrs)
if (!ctx) return patch
const rect = {
x: patch.pageX ?? attrs.pageX,
y: patch.pageY ?? attrs.pageY,
width: patch.width ?? attrs.width,
height: patch.height ?? attrs.height,
}
const snapped =
action.type === "resize"
? snapResizeRect(keyboardResizeHandle(action.dw, action.dh), rect, ctx)
: snapMoveRect(rect, ctx)
flashKeyboardSnapGuides(attrs.pageIndex, ctx.pageWidth, ctx.pageHeight, snapped.guides)
return {
...patch,
pageX: snapped.rect.x,
pageY: snapped.rect.y,
width: snapped.rect.width,
height: snapped.rect.height,
}
}
export function applyGraphicKeyboardAction(
attrs: DocsGraphicAttrs,
action: GraphicKeyboardAction
): Partial<DocsGraphicAttrs> | null {
const onPage = usesPageLayer(attrs)
if (action.type === "move") {
if (onPage) {
return {
pageX: Math.round(attrs.pageX + action.dx),
pageY: Math.round(attrs.pageY + action.dy),
}
}
if (attrs.placement === "absolute") {
return {
x: Math.round(attrs.x + action.dx),
y: Math.round(attrs.y + action.dy),
}
}
// In-flow graphics use document move / float-side cycling (see runGraphicKeyboardAction).
return null
}
if (action.type === "rotate") {
return { rotationDeg: attrs.rotationDeg + action.deltaDeg }
}
if (action.type === "resize") {
let width = attrs.width
let height = attrs.height
let shiftX = 0
let shiftY = 0
if (action.dw !== 0) {
const nextW = clampSize(width + action.dw)
if (action.dw < 0) shiftX = width - nextW
width = nextW
}
if (action.dh !== 0) {
const nextH = clampSize(height + action.dh)
if (action.dh < 0) shiftY = height - nextH
height = nextH
}
const patch: Partial<DocsGraphicAttrs> = { width, height }
if (onPage) {
if (shiftX !== 0) patch.pageX = Math.round(attrs.pageX + shiftX)
if (shiftY !== 0) patch.pageY = Math.round(attrs.pageY + shiftY)
} else if (shiftX !== 0 || shiftY !== 0) {
if (shiftX !== 0) patch.x = Math.round(attrs.x + shiftX)
if (shiftY !== 0) patch.y = Math.round(attrs.y + shiftY)
}
return patch
}
return null
}
export function graphicKeyboardActionFromKey(event: KeyboardEvent): GraphicKeyboardAction | null {
const step = event.shiftKey ? 10 : 1
const resizeStep = event.shiftKey ? 20 : 5
const mod = event.metaKey || event.ctrlKey
if (mod && !event.altKey) {
if (event.key === "]" || event.key === "}") {
return { type: "layer", direction: "forward" }
}
if (event.key === "[" || event.key === "{") {
return { type: "layer", direction: "backward" }
}
}
if (event.altKey && !mod) {
switch (event.key) {
case "ArrowLeft":
return { type: "resize", dw: -resizeStep, dh: 0 }
case "ArrowRight":
return { type: "resize", dw: resizeStep, dh: 0 }
case "ArrowUp":
return { type: "resize", dw: 0, dh: -resizeStep }
case "ArrowDown":
return { type: "resize", dw: 0, dh: resizeStep }
default:
return null
}
}
if (!event.altKey && !mod) {
switch (event.key) {
case "ArrowLeft":
return { type: "move", dx: -step, dy: 0 }
case "ArrowRight":
return { type: "move", dx: step, dy: 0 }
case "ArrowUp":
return { type: "move", dx: 0, dy: -step }
case "ArrowDown":
return { type: "move", dx: 0, dy: step }
case "[":
return { type: "rotate", deltaDeg: event.shiftKey ? -15 : -5 }
case "]":
return { type: "rotate", deltaDeg: event.shiftKey ? 15 : 5 }
default:
return null
}
}
return null
}
export function runGraphicKeyboardAction(editor: Editor, event: KeyboardEvent): boolean {
if (typeof document !== "undefined" && document.querySelector(".docs-graphic--cropping")) {
return false
}
const attrs = readActiveGraphicAttrs(editor)
if (!attrs) return false
const action = graphicKeyboardActionFromKey(event)
if (!action) return false
if (action.type === "layer") {
const ok =
action.direction === "forward"
? editor.chain().focus().bringDocsGraphicForward().run()
: editor.chain().focus().sendDocsGraphicBackward().run()
if (ok) event.preventDefault()
return ok
}
if (isInFlowGraphic(attrs) && action.type === "move") {
if (action.dy !== 0) {
moveInFlowGraphic(editor, action.dy < 0 ? "up" : "down")
// Always consume ↑↓ so the caret does not leave the selected graphic.
event.preventDefault()
return true
}
if (action.dx !== 0) {
const nextSide = cycleFloatSide(attrs.floatSide, action.dx > 0 ? "right" : "left")
const ok = editor.chain().focus().setDocsGraphicFloatSide(nextSide).run()
if (ok) event.preventDefault()
return ok
}
}
const patch = applyGraphicKeyboardAction(attrs, action)
if (!patch) return false
const snappedPatch =
usesPageLayer(attrs) && (action.type === "move" || action.type === "resize")
? applyPageLayerSnap(attrs, patch, editor, action)
: patch
const ok = editor.chain().focus().updateDocsGraphic(snappedPatch).run()
if (ok) event.preventDefault()
return ok
}