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

101 lines
3.4 KiB
TypeScript

import { Extension, type Editor } from "@tiptap/core"
import { Plugin, PluginKey } from "@tiptap/pm/state"
import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
import { parseGraphicAttrs, type DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
export const docsGraphicAnchorSyncKey = new PluginKey("docsGraphicAnchorSync")
function isGraphicNode(node: ProseMirrorNode): boolean {
return node.type.name === "docsGraphic" || node.type.name === "docsInlineGraphic"
}
function syncMoveWithTextGraphic(
node: ProseMirrorNode,
mappedAnchorPos: number
): Partial<DocsGraphicAttrs> | null {
const attrs = parseGraphicAttrs(node.attrs as Record<string, unknown>)
if (attrs.positionMode !== "move-with-text") return null
if (attrs.anchorPos < 0) return { anchorPos: mappedAnchorPos }
if (mappedAnchorPos < 0) {
return {
positionMode: "fixed-on-page",
placement: "absolute",
anchorPos: -1,
}
}
if (mappedAnchorPos !== attrs.anchorPos) {
return { anchorPos: mappedAnchorPos }
}
return null
}
/** Select a graphic under the pointer (Alt+click), including behind-text overlays. */
function selectGraphicAtPointer(editor: Editor, event: MouseEvent): boolean {
if (!event.altKey) return false
const stack = document.elementsFromPoint(event.clientX, event.clientY)
for (const el of stack) {
if (!(el instanceof HTMLElement)) continue
const graphic = el.classList.contains("docs-graphic")
? el
: (el.closest(".docs-graphic") as HTMLElement | null)
const host = graphic?.hasAttribute("data-graphic-pos")
? graphic
: (graphic?.closest("[data-graphic-pos]") as HTMLElement | null)
if (!host) continue
const posRaw = host.getAttribute("data-graphic-pos")
const pos = posRaw ? Number(posRaw) : NaN
if (!Number.isFinite(pos)) continue
editor.chain().focus().setNodeSelection(pos).run()
event.preventDefault()
event.stopPropagation()
return true
}
return false
}
/** Keeps move-with-text anchor positions mapped through document edits. */
export const DocsGraphicAnchorSync = Extension.create({
name: "docsGraphicAnchorSync",
addProseMirrorPlugins() {
const editor = this.editor
return [
new Plugin({
key: docsGraphicAnchorSyncKey,
props: {
handleDOMEvents: {
mousedown(_view, event) {
return selectGraphicAtPointer(editor, event)
},
},
},
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some((tr) => tr.docChanged)) return null
let tr = newState.tr
let changed = false
newState.doc.descendants((node, pos) => {
if (!isGraphicNode(node)) return
const attrs = parseGraphicAttrs(node.attrs as Record<string, unknown>)
if (attrs.positionMode !== "move-with-text" || attrs.anchorPos < 0) return
const mapped = transactions.reduce((anchor, transaction) => {
if (!transaction.docChanged) return anchor
return transaction.mapping.map(anchor)
}, attrs.anchorPos)
const patch = syncMoveWithTextGraphic(node, mapped)
if (patch) {
tr = tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...patch })
changed = true
}
})
return changed ? tr : null
},
}),
]
},
})