ultisuite-client/lib/drive/docs-graphic-import.ts
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

207 lines
6.1 KiB
TypeScript

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",
"assetId",
"opacity",
"shadow",
] as const
const DOCX_WRAP_MAP: Record<string, DocsGraphicWrap> = {
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<string, DocsGraphicPlacement> = {
inline: "inline",
block: "block",
absolute: "absolute",
anchored: "absolute",
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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![0] as TipTapJSON
if (child.type === "image") {
const upgraded = upgradeImageNode(
{ ...child, attrs: mapImportedGraphicAttrs((child.attrs as Record<string, unknown>) ?? {}) },
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<string, unknown> = {}
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
}