import { mergeAttributes, Node, type CommandProps } from "@tiptap/core" import { NodeSelection } from "@tiptap/pm/state" import { ReactNodeViewRenderer } from "@tiptap/react" import { DocsGraphicNodeView } from "@/components/drive/richtext/docs-graphic-node-view" import { bboxToPageCoords, usesPageLayer } from "@/lib/drive/docs-graphic-position" import { buildGradientCss, DOCS_GRAPHIC_DEFAULTS, type DocsGraphicAttrs, type DocsGraphicFloatSide, type DocsGraphicPlacement, type DocsGraphicPositionMode, type DocsGraphicType, type DocsGraphicWrap, type DocsShapeType, parseGraphicAttrs, } from "@/lib/drive/docs-graphic-types" declare module "@tiptap/core" { interface Commands { docsGraphic: { insertDocsGraphic: (attrs: Partial) => ReturnType updateDocsGraphic: (attrs: Partial) => ReturnType setDocsGraphicWrap: (wrap: DocsGraphicWrap) => ReturnType setDocsGraphicPlacement: (placement: DocsGraphicPlacement) => ReturnType setDocsGraphicFloatSide: (floatSide: DocsGraphicFloatSide) => ReturnType setDocsGraphicPositionMode: (mode: DocsGraphicPositionMode) => ReturnType setDocsGraphicWrapMargin: (mm: number) => ReturnType setDocsGraphicAnchor: (anchorPos: number) => ReturnType bringDocsGraphicForward: () => ReturnType sendDocsGraphicBackward: () => ReturnType } } } const graphicAttributes = { graphicType: { default: DOCS_GRAPHIC_DEFAULTS.graphicType }, src: { default: null as string | null }, alt: { default: "" }, shapeType: { default: DOCS_GRAPHIC_DEFAULTS.shapeType }, fill: { default: DOCS_GRAPHIC_DEFAULTS.fill }, stroke: { default: DOCS_GRAPHIC_DEFAULTS.stroke }, strokeWidth: { default: DOCS_GRAPHIC_DEFAULTS.strokeWidth }, gradientCss: { default: "" }, gradientType: { default: DOCS_GRAPHIC_DEFAULTS.gradientType }, gradientAngle: { default: DOCS_GRAPHIC_DEFAULTS.gradientAngle }, gradientColor1: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor1 }, gradientColor2: { default: DOCS_GRAPHIC_DEFAULTS.gradientColor2 }, width: { default: DOCS_GRAPHIC_DEFAULTS.width }, height: { default: DOCS_GRAPHIC_DEFAULTS.height }, placement: { default: DOCS_GRAPHIC_DEFAULTS.placement }, wrap: { default: DOCS_GRAPHIC_DEFAULTS.wrap }, floatSide: { default: DOCS_GRAPHIC_DEFAULTS.floatSide }, x: { default: 0 }, y: { default: 0 }, positionMode: { default: DOCS_GRAPHIC_DEFAULTS.positionMode }, anchorPos: { default: -1 }, pageIndex: { default: 0 }, pageX: { default: 0 }, pageY: { default: 0 }, wrapMarginMm: { default: DOCS_GRAPHIC_DEFAULTS.wrapMarginMm }, rotationDeg: { default: 0 }, zIndex: { default: 0 }, cropX: { default: 0 }, cropY: { default: 0 }, cropWidth: { default: 1 }, cropHeight: { default: 1 }, cropShape: { default: "rect" }, lockAspectRatio: { default: true }, imageFit: { default: "contain" }, imageFitAnchorH: { default: 0.5 }, imageFitAnchorV: { default: 0.5 }, assetId: { default: null as string | null }, opacity: { default: 1 }, shadow: { default: "" }, brightness: { default: 0 }, contrast: { default: 0 }, recolor: { default: "" }, altTitle: { default: "" }, drawScene: { default: null as string | null, rendered: false, parseHTML: (element) => element.getAttribute("data-draw-scene"), renderHTML: (attributes) => attributes.drawScene ? { "data-draw-scene": attributes.drawScene } : {}, }, drawDriveFileId: { default: null as string | null, rendered: false, parseHTML: (element) => element.getAttribute("data-draw-drive-file-id"), renderHTML: (attributes) => attributes.drawDriveFileId ? { "data-draw-drive-file-id": attributes.drawDriveFileId } : {}, }, } function mergeGraphicAttrs(partial: Partial): DocsGraphicAttrs { const merged = parseGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, ...partial }) if (merged.graphicType === "gradient" && !partial.gradientCss) { merged.gradientCss = buildGradientCss( merged.gradientAngle, merged.gradientColor1, merged.gradientColor2, merged.gradientType ) } return merged } function graphicNodeName(attrs: Partial): "docsGraphic" | "docsInlineGraphic" { const merged = mergeGraphicAttrs(attrs) return merged.placement === "inline" || merged.wrap === "inline" ? "docsInlineGraphic" : "docsGraphic" } function activeGraphicName(editor: { isActive: (name: string) => boolean }): string | null { if (editor.isActive("docsInlineGraphic")) return "docsInlineGraphic" if (editor.isActive("docsGraphic")) return "docsGraphic" return null } type GraphicCommandProps = CommandProps function selectedGraphicNode( state: import("@tiptap/pm/state").EditorState ): import("@tiptap/pm/model").Node | null { const selection = state.selection if (!(selection instanceof NodeSelection)) return null const node = selection.node if (node.type.name !== "docsGraphic" && node.type.name !== "docsInlineGraphic") return null return node } function isGraphicNodeName(name: string): name is "docsGraphic" | "docsInlineGraphic" { return name === "docsGraphic" || name === "docsInlineGraphic" } function restoreGraphicNodeSelection( tr: import("@tiptap/pm/state").Transaction, preferredPos: number ): void { const candidates = [preferredPos, preferredPos + 1, preferredPos - 1] for (const tryPos of candidates) { if (tryPos < 0 || tryPos > tr.doc.content.size) continue const nodeAt = tr.doc.nodeAt(tryPos) if (nodeAt && isGraphicNodeName(nodeAt.type.name)) { tr.setSelection(NodeSelection.create(tr.doc, tryPos)) return } } let best: { pos: number; dist: number } | null = null tr.doc.descendants((node, pos) => { if (!isGraphicNodeName(node.type.name)) return const dist = Math.abs(pos - preferredPos) if (!best || dist < best.dist) best = { pos, dist } }) if (best) tr.setSelection(NodeSelection.create(tr.doc, best.pos)) } function replaceBlockGraphicWithInline( tr: import("@tiptap/pm/state").Transaction, state: import("@tiptap/pm/state").EditorState, pos: number, node: import("@tiptap/pm/model").Node, merged: DocsGraphicAttrs, inlineType: import("@tiptap/pm/model").NodeType ): number { const paragraphType = state.schema.nodes.paragraph if (!paragraphType) return pos const inlineNode = inlineType.create(merged) const paragraphNode = paragraphType.create(null, inlineNode) tr.replaceWith(pos, pos + node.nodeSize, paragraphNode) return pos + 1 } function replaceInlineGraphicWithBlock( tr: import("@tiptap/pm/state").Transaction, state: import("@tiptap/pm/state").EditorState, pos: number, node: import("@tiptap/pm/model").Node, merged: DocsGraphicAttrs, blockType: import("@tiptap/pm/model").NodeType ): number { const $pos = state.doc.resolve(pos) const parent = $pos.parent const blockNode = blockType.create(merged) if (parent.type.name === "paragraph" && parent.childCount === 1) { const paragraphPos = $pos.before($pos.depth) tr.replaceWith(paragraphPos, $pos.after($pos.depth), blockNode) return paragraphPos } const paragraphPos = $pos.before($pos.depth) tr.insert(paragraphPos, blockNode) const mappedPos = tr.mapping.map(pos) tr.delete(mappedPos, mappedPos + node.nodeSize) return paragraphPos } function replaceGraphicWithAttrs( { state, tr, dispatch }: GraphicCommandProps, patch: Partial ): boolean { const { selection } = state const node = selectedGraphicNode(state) if (!node) return false const currentName = node.type.name as "docsGraphic" | "docsInlineGraphic" const merged = mergeGraphicAttrs({ ...(node.attrs as Record), ...patch }) const targetName = graphicNodeName(merged) const pos = selection.from let selectionPos = pos if (targetName === currentName) { tr.setNodeMarkup(pos, undefined, merged) } else { const targetType = state.schema.nodes[targetName] if (!targetType) return false if (currentName === "docsGraphic" && targetName === "docsInlineGraphic") { selectionPos = replaceBlockGraphicWithInline(tr, state, pos, node, merged, targetType) } else if (currentName === "docsInlineGraphic" && targetName === "docsGraphic") { selectionPos = replaceInlineGraphicWithBlock(tr, state, pos, node, merged, targetType) } else { tr.replaceWith(pos, pos + node.nodeSize, targetType.create(merged)) selectionPos = tr.mapping.map(pos, -1) } } restoreGraphicNodeSelection(tr, selectionPos) if (dispatch) dispatch(tr) return true } function updateGraphicAttrs(ctx: GraphicCommandProps, patch: Partial) { if (selectedGraphicNode(ctx.state)) { return replaceGraphicWithAttrs(ctx, patch) } const name = activeGraphicName(ctx.editor) if (!name) return false return ctx.chain().updateAttributes(name, patch).run() } /** Read current on-screen page coords of the selected graphic (DOM measure). */ function measureSelectedGraphicPageCoords(): Partial | null { if (typeof document === "undefined") return null const el = document.querySelector( ".docs-graphic--selected, .ProseMirror-selectednode .docs-graphic" ) as HTMLElement | null const stack = document.querySelector("[data-docs-page-stack]") as HTMLElement | null if (!el || !stack) return null const scale = Number.parseFloat(stack.dataset.docsPageScale ?? "1") || 1 const pageHeight = Number.parseFloat(stack.dataset.docsPageHeight ?? "0") || 0 if (pageHeight <= 0) return null const coords = bboxToPageCoords( el.getBoundingClientRect(), stack.getBoundingClientRect(), scale, pageHeight ) return { pageIndex: coords.pageIndex, pageX: coords.pageX, pageY: coords.pageY } } /** Patch parts for promoting the selected graphic to the fixed page layer. */ function fixedOnPagePatch(ctx: GraphicCommandProps): Partial { const patch: Partial = { positionMode: "fixed-on-page", placement: "absolute", } const node = selectedGraphicNode(ctx.state) const alreadyOnLayer = node && usesPageLayer(node.attrs as Record) if (!alreadyOnLayer) { // Keep the graphic where it visually sits when leaving the text flow. Object.assign(patch, measureSelectedGraphicPageCoords()) } return patch } /** Patch parts for returning the selected graphic to the text flow. */ function moveWithTextPatch(currentWrap: unknown): Partial { const wrap: DocsGraphicWrap = currentWrap === "behind" || currentWrap === "in-front" ? "square" : ((currentWrap as DocsGraphicWrap) ?? "square") return { positionMode: "move-with-text", placement: wrap === "inline" ? "inline" : "block", wrap, } } function graphicCommands() { return { insertDocsGraphic: (partial: Partial) => ({ chain, editor }: GraphicCommandProps) => { const anchorPos = partial.anchorPos ?? editor.state.selection.from const attrs = mergeGraphicAttrs({ ...partial, anchorPos }) const type = graphicNodeName(attrs) const ok = chain().focus().insertContent({ type, attrs }).run() if (!ok) return false let graphicPos: number | null = null let bestDist = Infinity editor.state.doc.descendants((node, pos) => { if (!isGraphicNodeName(node.type.name)) return const dist = Math.abs(pos - anchorPos) if (dist < bestDist) { bestDist = dist graphicPos = pos } }) if (graphicPos != null) { editor.commands.setNodeSelection(graphicPos) } return true }, updateDocsGraphic: (partial: Partial) => (ctx: GraphicCommandProps) => updateGraphicAttrs(ctx, partial), setDocsGraphicWrap: (wrap: DocsGraphicWrap) => (ctx: GraphicCommandProps) => { let patch: Partial if (wrap === "behind" || wrap === "in-front") { patch = { ...fixedOnPagePatch(ctx), wrap } } else { // Choosing a text-wrap mode puts the graphic back into the flow. patch = { wrap, positionMode: "move-with-text", placement: wrap === "inline" ? "inline" : "block", } } return replaceGraphicWithAttrs(ctx, patch) }, setDocsGraphicPlacement: (placement: DocsGraphicPlacement) => (ctx: GraphicCommandProps) => { if (placement === "absolute") { return replaceGraphicWithAttrs(ctx, fixedOnPagePatch(ctx)) } const currentWrap = selectedGraphicNode(ctx.state)?.attrs?.wrap const patch = moveWithTextPatch(currentWrap) patch.placement = placement if (placement === "inline") patch.wrap = "inline" return replaceGraphicWithAttrs(ctx, patch) }, setDocsGraphicFloatSide: (floatSide: DocsGraphicFloatSide) => (ctx: GraphicCommandProps) => updateGraphicAttrs(ctx, { floatSide }), setDocsGraphicPositionMode: (positionMode: DocsGraphicPositionMode) => (ctx: GraphicCommandProps) => { if (positionMode === "fixed-on-page") { return replaceGraphicWithAttrs(ctx, fixedOnPagePatch(ctx)) } const currentWrap = selectedGraphicNode(ctx.state)?.attrs?.wrap return replaceGraphicWithAttrs(ctx, moveWithTextPatch(currentWrap)) }, setDocsGraphicWrapMargin: (wrapMarginMm: number) => (ctx: GraphicCommandProps) => updateGraphicAttrs(ctx, { wrapMarginMm: Math.max(0, wrapMarginMm) }), setDocsGraphicAnchor: (anchorPos: number) => (ctx: GraphicCommandProps) => updateGraphicAttrs(ctx, { anchorPos }), bringDocsGraphicForward: () => (ctx: GraphicCommandProps) => { const name = activeGraphicName(ctx.editor) if (!name) return false const z = Number(ctx.editor.getAttributes(name).zIndex ?? 0) return ctx.chain().updateAttributes(name, { zIndex: z + 1 }).run() }, sendDocsGraphicBackward: () => (ctx: GraphicCommandProps) => { const name = activeGraphicName(ctx.editor) if (!name) return false const z = Number(ctx.editor.getAttributes(name).zIndex ?? 0) return ctx.chain().updateAttributes(name, { zIndex: Math.max(0, z - 1) }).run() }, } } export const DocsGraphic = Node.create({ name: "docsGraphic", group: "block", atom: true, draggable: true, selectable: true, addAttributes() { return graphicAttributes }, parseHTML() { return [{ tag: 'div[data-type="docs-graphic"]' }] }, renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { "data-type": "docs-graphic" })] }, addNodeView() { return ReactNodeViewRenderer(DocsGraphicNodeView) }, addCommands() { return graphicCommands() }, }) export const DocsInlineGraphic = Node.create({ name: "docsInlineGraphic", group: "inline", inline: true, atom: true, draggable: true, selectable: true, addAttributes() { return graphicAttributes }, parseHTML() { return [{ tag: 'span[data-type="docs-inline-graphic"]' }] }, renderHTML({ HTMLAttributes }) { return ["span", mergeAttributes(HTMLAttributes, { "data-type": "docs-inline-graphic" })] }, addNodeView() { return ReactNodeViewRenderer(DocsGraphicNodeView) }, }) export function buildInsertGraphicAttrs( graphicType: DocsGraphicType, partial: Partial = {} ): DocsGraphicAttrs { return mergeGraphicAttrs({ ...DOCS_GRAPHIC_DEFAULTS, graphicType, ...partial }) } /** Build image attrs with a frame sized to the source aspect ratio. */ export async function buildImageInsertGraphicAttrs( partial: Partial & { src: string } ): Promise { const { computeImageInsertFrameSize, readImageNaturalSize } = await import( "../docs-graphic-image-size.ts" ) try { const natural = await readImageNaturalSize(partial.src) const { width, height } = computeImageInsertFrameSize(natural.width, natural.height) return buildInsertGraphicAttrs("image", { ...partial, width, height, lockAspectRatio: true, }) } catch { return buildInsertGraphicAttrs("image", partial) } } export { mergeGraphicAttrs, graphicNodeName }