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 | null { const attrs = parseGraphicAttrs(node.attrs as Record) 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) 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 }, }), ] }, })