ultisuite-client/lib/drive/use-docs-paragraph-styles.ts
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

211 lines
6.3 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { Editor } from "@tiptap/react"
import { apiClient } from "@/lib/api/client"
import {
notifyParagraphStyleCatalog,
setActiveParagraphStyleCatalog,
} from "@/lib/drive/docs-paragraph-style-bridge"
import { buildParagraphStylesCss } from "@/lib/drive/docs-paragraph-styles-css"
import type { DocParagraphStyleDefinition, DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import {
createCustomParagraphStyle,
defaultDocumentParagraphStyles,
emptyUserParagraphStyles,
mergeParagraphStyleCatalogs,
} from "@/lib/drive/docs-paragraph-styles"
import type { DocParagraphStylesState } from "@/lib/drive/docs-styles-types"
const STYLES_DEBOUNCE_MS = 600
function parseCatalog(raw: unknown): DocParagraphStylesCatalog | null {
if (!raw || typeof raw !== "object") return null
const definitions = (raw as DocParagraphStylesCatalog).definitions
if (!definitions || typeof definitions !== "object") return null
return { definitions }
}
export function useDocsParagraphStyles({
editor,
initialDocumentStyles,
editable,
canonicalPath,
saveUrl,
}: {
editor: Editor | null
initialDocumentStyles?: DocParagraphStylesCatalog | null
editable?: boolean
canonicalPath?: string
saveUrl?: string
}) {
const [documentStyles, setDocumentStyles] = useState<DocParagraphStylesCatalog>(
() => initialDocumentStyles ?? defaultDocumentParagraphStyles()
)
const [userStyles, setUserStyles] = useState<DocParagraphStylesCatalog>(emptyUserParagraphStyles)
const documentStylesRef = useRef(documentStyles)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
documentStylesRef.current = documentStyles
}, [documentStyles])
useEffect(() => {
if (initialDocumentStyles) setDocumentStyles(initialDocumentStyles)
}, [initialDocumentStyles])
useEffect(() => {
let cancelled = false
void apiClient
.get<DocParagraphStylesCatalog>("/richtext/user-paragraph-styles")
.then((catalog) => {
if (cancelled) return
setUserStyles(parseCatalog(catalog) ?? emptyUserParagraphStyles())
})
.catch(() => {
if (!cancelled) setUserStyles(emptyUserParagraphStyles())
})
return () => {
cancelled = true
}
}, [])
const mergedCatalog = useMemo(
() => mergeParagraphStyleCatalogs(documentStyles, userStyles),
[documentStyles, userStyles]
)
useEffect(() => {
setActiveParagraphStyleCatalog(mergedCatalog)
notifyParagraphStyleCatalog(mergedCatalog)
}, [mergedCatalog])
useEffect(() => {
const css = buildParagraphStylesCss(mergedCatalog)
const id = "docs-paragraph-styles-dynamic"
let el = document.getElementById(id) as HTMLStyleElement | null
if (!el) {
el = document.createElement("style")
el.id = id
document.head.appendChild(el)
}
el.textContent = css
}, [mergedCatalog])
const flushDocumentStylesSave = useCallback(async () => {
if (!editable || !canonicalPath) return
const payload = { path: canonicalPath, paragraphStyles: documentStylesRef.current }
if (saveUrl) {
const res = await fetch(saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("paragraph styles save failed")
return
}
await apiClient.put("/richtext/save", payload)
}, [canonicalPath, editable, saveUrl])
const scheduleDocumentStylesSave = useCallback(() => {
if (!editable) return
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
void flushDocumentStylesSave().catch(() => undefined)
}, STYLES_DEBOUNCE_MS)
}, [editable, flushDocumentStylesSave])
const saveUserStyles = useCallback(async (catalog: DocParagraphStylesCatalog) => {
setUserStyles(catalog)
await apiClient.put("/richtext/user-paragraph-styles", catalog)
}, [])
const updateDocumentStyle = useCallback(
(styleId: string, definition: DocParagraphStyleDefinition) => {
setDocumentStyles((prev) => ({
definitions: {
...prev.definitions,
[styleId]: { ...definition, id: styleId, scope: "document" },
},
}))
scheduleDocumentStylesSave()
},
[scheduleDocumentStylesSave]
)
const updateUserStyle = useCallback(
(styleId: string, definition: DocParagraphStyleDefinition) => {
setUserStyles((prev) => {
const next = {
definitions: {
...prev.definitions,
[styleId]: { ...definition, id: styleId, scope: "user" as const },
},
}
void saveUserStyles(next)
return next
})
},
[saveUserStyles]
)
const createUserStyle = useCallback(
(input: { name: string; basedOn?: string }) => {
const template = mergedCatalog.definitions[input.basedOn ?? "normal"]
const created = createCustomParagraphStyle({ ...input, template })
setUserStyles((prev) => {
const next = {
definitions: { ...prev.definitions, [created.id]: created },
}
void saveUserStyles(next)
return next
})
return created
},
[mergedCatalog.definitions, saveUserStyles]
)
useEffect(() => {
if (!editor) return
const onUpdate = ({
styleId,
definition,
}: {
styleId: string
definition: DocParagraphStyleDefinition
}) => {
if (definition.scope === "user") updateUserStyle(styleId, definition)
else updateDocumentStyle(styleId, definition)
}
editor.on("docsParagraphStyleUpdate", onUpdate)
return () => {
editor.off("docsParagraphStyleUpdate", onUpdate)
}
}, [editor, updateDocumentStyle, updateUserStyle])
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current)
},
[]
)
const state: DocParagraphStylesState = useMemo(
() => ({ documentStyles, userStyles, mergedCatalog }),
[documentStyles, mergedCatalog, userStyles]
)
return {
state,
updateDocumentStyle,
updateUserStyle,
createUserStyle,
applyStyle: (styleId: string) => {
editor?.commands.applyDocsParagraphStyle(styleId)
},
updateStyleFromSelection: (styleId: string) => {
editor?.commands.updateDocsParagraphStyleFromSelection(styleId)
},
}
}