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) } 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 | 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["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, editor: Editor, action: Extract ): Partial { 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 | 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 = { 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 }