import type { TipTapJSON } from "@/lib/drive/richtext-import" import { DOCS_GRAPHIC_DEFAULTS, imageAttrsToGraphic, parseGraphicAttrs, type DocsGraphicWrap, type DocsGraphicPlacement, } from "./docs-graphic-types.ts" const LEGACY_IMAGE_KEYS = [ "src", "alt", "title", "width", "height", "placement", "wrap", "floatSide", "x", "y", "rotationDeg", "zIndex", "cropX", "cropY", "cropWidth", "cropHeight", "cropShape", "lockAspectRatio", "imageFit", "imageFitAnchorH", "imageFitAnchorV", "assetId", "opacity", "shadow", ] as const const DOCX_WRAP_MAP: Record = { inline: "inline", square: "square", tight: "tight", through: "through", topAndBottom: "top-bottom", topbottom: "top-bottom", behind: "behind", infront: "in-front", inFront: "in-front", } const DOCX_PLACEMENT_MAP: Record = { inline: "inline", block: "block", absolute: "absolute", anchored: "absolute", } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value) } function paragraphIsSingleImage(node: TipTapJSON): boolean { if (node.type !== "paragraph" || !Array.isArray(node.content) || node.content.length !== 1) { return false } const child = node.content[0] as TipTapJSON return child.type === "image" || child.type === "docsInlineGraphic" } function upgradeImageNode(raw: TipTapJSON, asBlock: boolean): TipTapJSON { const attrs = isRecord(raw.attrs) ? raw.attrs : {} const graphic = imageAttrsToGraphic(attrs) if (asBlock) { return { type: "docsGraphic", attrs: { ...graphic, placement: graphic.placement === "inline" ? "block" : graphic.placement, wrap: graphic.wrap === "inline" ? "square" : graphic.wrap, }, } } return { type: "docsInlineGraphic", attrs: { ...graphic, placement: "inline", wrap: graphic.wrap === "square" ? "inline" : graphic.wrap, }, } } function upgradeGraphicNode(raw: TipTapJSON): TipTapJSON { const attrs = isRecord(raw.attrs) ? parseGraphicAttrs(raw.attrs) : DOCS_GRAPHIC_DEFAULTS if (raw.type === "docsGraphic") { return { type: "docsGraphic", attrs } } if (raw.type === "docsInlineGraphic") { return { type: "docsInlineGraphic", attrs } } if (raw.type === "image") { return upgradeImageNode(raw, false) } return raw } function mapImportedGraphicAttrs(attrs: Record): Record { const wrapRaw = attrs.wrap ?? attrs.textWrap ?? attrs.layout const placementRaw = attrs.placement ?? attrs.position ?? attrs.layoutMode const wrap = typeof wrapRaw === "string" ? (DOCX_WRAP_MAP[wrapRaw] ?? wrapRaw) : undefined const placement = typeof placementRaw === "string" ? (DOCX_PLACEMENT_MAP[placementRaw] ?? placementRaw) : undefined return { ...attrs, ...(wrap ? { wrap } : {}), ...(placement ? { placement } : {}), floatSide: attrs.floatSide ?? attrs.align ?? attrs.horizontalAlign, } } /** Upgrade legacy `image` nodes and normalize graphic attrs after DOCX import. */ export function normalizeImportedGraphics(content: TipTapJSON): TipTapJSON { const walk = (node: unknown): unknown => { if (!node || typeof node !== "object") return node if (Array.isArray(node)) return node.map(walk) const record = node as TipTapJSON if (record.type === "image" && isRecord(record.attrs)) { const mapped = mapImportedGraphicAttrs(record.attrs) const placement = mapped.placement as string | undefined const wrap = mapped.wrap as string | undefined const asBlock = wrap === "square" || wrap === "tight" || wrap === "through" || wrap === "top-bottom" || wrap === "behind" || wrap === "in-front" || placement === "block" || placement === "absolute" return upgradeImageNode({ ...record, attrs: mapped }, asBlock) } if ( (record.type === "docsGraphic" || record.type === "docsInlineGraphic") && isRecord(record.attrs) ) { record.attrs = parseGraphicAttrs(mapImportedGraphicAttrs(record.attrs)) } if (record.type === "paragraph" && paragraphIsSingleImage(record)) { const child = (record.content as TipTapJSON[])[0] if (child.type === "image") { const upgraded = upgradeImageNode( { ...child, attrs: mapImportedGraphicAttrs((child.attrs as Record) ?? {}) }, true ) return upgraded } } if (Array.isArray(record.content)) { const nextContent = record.content.map(walk).filter(Boolean) if (record.type === "paragraph") { return { ...record, content: nextContent } } return { ...record, content: nextContent } } if (record.type === "image" || record.type === "docsGraphic" || record.type === "docsInlineGraphic") { return upgradeGraphicNode(record) } return record } const normalized = walk(content) if (!normalized || typeof normalized !== "object") { return { type: "doc", content: [{ type: "paragraph" }] } } return normalized as TipTapJSON } /** Keep legacy inline images compatible with @tiptap/extension-image when needed. */ export function normalizeLegacyImageAttrs(content: TipTapJSON): TipTapJSON { const walk = (node: unknown): unknown => { if (!node || typeof node !== "object") return node if (Array.isArray(node)) return node.map(walk) const record = node as TipTapJSON if (record.type === "image" && isRecord(record.attrs)) { const attrs: Record = {} for (const key of LEGACY_IMAGE_KEYS) { const value = record.attrs[key] if (value != null && value !== "") attrs[key] = value } if (typeof attrs.src !== "string" || !attrs.src) return null return { ...record, attrs } } if (Array.isArray(record.content)) { return { ...record, content: record.content.map(walk).filter(Boolean) } } return record } const normalized = walk(content) if (!normalized || typeof normalized !== "object") { return { type: "doc", content: [{ type: "paragraph" }] } } return normalized as TipTapJSON }