207 lines
6.1 KiB
TypeScript
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
|
|
}
|