import type { Editor } from "@tiptap/core" import type { Node as PMNode } from "@tiptap/pm/model" import { NodeSelection, type EditorState, type ResolvedPos, type Transaction } from "@tiptap/pm/state" import { parseGraphicAttrs } from "./docs-graphic-types.ts" function isGraphicNode(node: PMNode): boolean { return node.type.name === "docsGraphic" || node.type.name === "docsInlineGraphic" } /** Document position immediately before child `childIndex` inside `$pos.parent`. */ export function childPosAtIndex($pos: ResolvedPos, childIndex: number): number { const parent = $pos.parent let pos = $pos.start($pos.depth) for (let i = 0; i < childIndex; i++) pos += parent.child(i).nodeSize return pos } function removeEmptyParagraph(tr: Transaction, paragraphPos: number): void { const mappedPos = tr.mapping.map(paragraphPos) const paragraph = tr.doc.nodeAt(mappedPos) if (paragraph?.type.name === "paragraph" && paragraph.content.size === 0) { tr.delete(mappedPos, mappedPos + paragraph.nodeSize) } } function inlineToBlockNode(state: EditorState, node: PMNode): PMNode | null { const blockType = state.schema.nodes.docsGraphic if (!blockType) return null const attrs = parseGraphicAttrs(node.attrs as Record) const wrap = attrs.wrap === "inline" ? "square" : attrs.wrap return blockType.create({ ...node.attrs, placement: "block", wrap }) } function findSelectedGraphic( state: EditorState ): { pos: number; node: PMNode } | null { const { selection } = state if (!(selection instanceof NodeSelection)) return null const node = selection.node if (!isGraphicNode(node)) return null return { pos: selection.from, node } } function moveBlockGraphic( state: EditorState, pos: number, node: PMNode, direction: "up" | "down", dispatch?: (tr: Transaction) => void ): boolean { const $pos = state.doc.resolve(pos) const index = $pos.index($pos.depth) const parent = $pos.parent if (direction === "up") { if (index === 0) return false const insertPos = childPosAtIndex($pos, index - 1) const tr = state.tr tr.delete(pos, pos + node.nodeSize) const mappedInsert = tr.mapping.map(insertPos) tr.insert(mappedInsert, node) tr.setSelection(NodeSelection.create(tr.doc, mappedInsert)) dispatch?.(tr) return true } if (index >= parent.childCount - 1) return false const insertPos = childPosAtIndex($pos, index + 2) const tr = state.tr tr.delete(pos, pos + node.nodeSize) const mappedInsert = tr.mapping.map(insertPos) tr.insert(mappedInsert, node) tr.setSelection(NodeSelection.create(tr.doc, mappedInsert)) dispatch?.(tr) return true } function moveInlineGraphic( state: EditorState, pos: number, node: PMNode, direction: "up" | "down", dispatch?: (tr: Transaction) => void ): boolean { const blockNode = inlineToBlockNode(state, node) if (!blockNode) return false const $pos = state.doc.resolve(pos) const paragraphPos = $pos.before($pos.depth) const $paragraph = state.doc.resolve(paragraphPos) if (direction === "up" && $paragraph.index($paragraph.depth) === 0) return false const paragraphEnd = $pos.after($pos.depth) if (direction === "down" && paragraphEnd >= state.doc.content.size) return false const tr = state.tr tr.delete(pos, pos + node.nodeSize) removeEmptyParagraph(tr, paragraphPos) const insertPos = direction === "up" ? tr.mapping.map(paragraphPos) : tr.mapping.map(paragraphEnd) tr.insert(insertPos, blockNode) tr.setSelection(NodeSelection.create(tr.doc, insertPos)) dispatch?.(tr) return true } /** Move a move-with-text graphic up/down in the document flow. */ export function moveInFlowGraphic(editor: Editor, direction: "up" | "down"): boolean { const { state } = editor const selected = findSelectedGraphic(state) if (!selected) return false const { pos, node } = selected if (node.type.name === "docsGraphic") { return moveBlockGraphic(state, pos, node, direction, editor.view.dispatch.bind(editor.view)) } return moveInlineGraphic(state, pos, node, direction, editor.view.dispatch.bind(editor.view)) }