136 lines
3.8 KiB
TypeScript
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>
|
|
)
|
|
}
|