ultisuite-client/components/drive/richtext/docs-region-editor.tsx
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

136 lines
3.8 KiB
TypeScript

"use client"
import { useEffect, useRef } from "react"
import { useEditor, EditorContent, type Editor } from "@tiptap/react"
import {
buildRegionEditorExtensions,
RICHTEXT_REGION_EDITOR_CLASS,
} from "@/lib/drive/richtext-extensions"
import { cn } from "@/lib/utils"
function regionEditorProseStyle(maxHeightPx?: number, minHeightPx?: number): string | undefined {
const parts: string[] = []
if (minHeightPx != null) parts.push(`min-height:${minHeightPx}px`)
if (maxHeightPx != null) {
parts.push(`max-height:${maxHeightPx}px`, "overflow-y:auto")
}
return parts.length > 0 ? parts.join(";") : undefined
}
export function DocsRegionEditor({
content,
editable,
placeholder,
className,
maxHeightPx,
minHeightPx,
onUpdate,
onBlur,
onEditorReady,
onContentHeightChange,
autoFocus,
}: {
content: Record<string, unknown> | undefined
editable: boolean
placeholder?: string
className?: string
/** When set, content scrolls inside. Omit to grow with content. */
maxHeightPx?: number
/** Minimum editable band height (header/footer margin zone). */
minHeightPx?: number
onUpdate?: (json: Record<string, unknown>) => void
onBlur?: () => void
onEditorReady?: (editor: Editor | null) => void
onContentHeightChange?: (height: number) => void
autoFocus?: boolean
}) {
const syncingRef = useRef(false)
const rootRef = useRef<HTMLDivElement>(null)
const proseStyle = regionEditorProseStyle(maxHeightPx, minHeightPx)
const editor = useEditor({
immediatelyRender: false,
editable,
extensions: buildRegionEditorExtensions(placeholder),
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
editorProps: {
attributes: {
class: RICHTEXT_REGION_EDITOR_CLASS,
...(proseStyle ? { style: proseStyle } : {}),
},
},
onUpdate: ({ editor: ed }) => {
if (syncingRef.current) return
onUpdate?.(ed.getJSON() as Record<string, unknown>)
},
onBlur: () => onBlur?.(),
})
useEffect(() => {
onEditorReady?.(editor)
return () => onEditorReady?.(null)
}, [editor, onEditorReady])
useEffect(() => {
if (!editor || !content) return
syncingRef.current = true
editor.commands.setContent(content, { emitUpdate: false })
syncingRef.current = false
}, [content, editor])
useEffect(() => {
editor?.setEditable(editable)
if (editable && autoFocus) {
requestAnimationFrame(() => editor?.commands.focus("end"))
}
}, [autoFocus, editable, editor])
useEffect(() => {
if (!editor) return
const style = regionEditorProseStyle(maxHeightPx, minHeightPx)
editor.setOptions({
editorProps: {
attributes: {
class: RICHTEXT_REGION_EDITOR_CLASS,
...(style ? { style } : {}),
},
},
})
}, [editor, maxHeightPx, minHeightPx])
useEffect(() => {
const root = rootRef.current
if (!root || !onContentHeightChange) return
const prose = root.querySelector(".ProseMirror") as HTMLElement | null
if (!prose) return
const report = () => {
requestAnimationFrame(() => onContentHeightChange(prose.offsetHeight))
}
report()
const ro = new ResizeObserver(report)
ro.observe(prose)
return () => ro.disconnect()
}, [editor, onContentHeightChange])
if (!editor) return null
return (
<div
ref={rootRef}
className={cn("docs-region-editor-root", maxHeightPx == null && "docs-region-editor-root--grow")}
style={minHeightPx != null ? { minHeight: minHeightPx } : undefined}
>
<EditorContent
editor={editor}
className={cn("docs-region-editor", maxHeightPx == null ? "docs-region-editor--grow" : "h-full", className)}
style={
maxHeightPx != null
? { height: maxHeightPx, maxHeight: maxHeightPx }
: undefined
}
/>
</div>
)
}