295 lines
8.7 KiB
TypeScript
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
|
|
}
|