Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | 10x 10x 10x 33x 120x 33x 33x 33x 120x 120x 120x 120x 29x 29x 29x 29x 29x 120x | import { useEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mui/material";
import {
ContentState,
EditorState,
RawDraftContentState,
convertFromRaw,
} from "draft-js";
import HFRichInput from "@shared/HFInputs/HFRichInput/HFRichInput";
// Legal documents are long-form; lift the editor's default short-input cap.
const DOCUMENT_MAX_LENGTH = 200000;
// Gap between the sticky banner and the floating toolbar (matches the mockup).
const TOOLBAR_TOP_GAP = 20;
const isRawDraftContent = (value: unknown): value is RawDraftContentState =>
!!value &&
typeof value === "object" &&
Array.isArray((value as RawDraftContentState).blocks);
interface DocumentEditorProps {
content: unknown;
onChange: (state: EditorState) => void;
/** Height of the sticky page banner; the toolbar floats just beneath it. */
stickyTop?: number;
}
export default function DocumentEditor({
content,
onChange,
stickyTop = 0,
}: DocumentEditorProps) {
const initialContent = useMemo<ContentState>(() => {
try {
Eif (isRawDraftContent(content)) {
return convertFromRaw(content);
}
} catch {
// fall through to an empty document
}
return ContentState.createFromText("");
}, [content]);
// The toolbar is sticky; raise a shadow only once it actually sticks (i.e.
// the user has scrolled the content underneath it), per the mockup.
const sentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
const toolbarOffset = stickyTop + TOOLBAR_TOP_GAP;
useEffect(() => {
const el = sentinelRef.current;
Iif (!el || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
([entry]) => setIsStuck(!entry.isIntersecting),
{ rootMargin: `-${toolbarOffset}px 0px 0px 0px`, threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [toolbarOffset]);
return (
<Box>
{/* Marks where the toolbar sits unstuck; once it scrolls past the sticky
offset the toolbar gains a shadow. */}
<Box ref={sentinelRef} sx={{ height: 0 }} />
<Box
sx={{
"& .toolbarClassName": {
position: "sticky",
top: `${toolbarOffset}px`,
zIndex: 10,
// Center the toolbar items on a common baseline — without this the
// shorter icon buttons sit higher than the taller "Paragraph"
// dropdown (they aren't vertically centered against each other).
display: "flex",
alignItems: "center",
width: "fit-content",
maxWidth: "100%",
marginLeft: "auto",
marginRight: "auto",
marginTop: `${TOOLBAR_TOP_GAP}px`,
marginBottom: "20px",
boxShadow: isStuck ? "0 4px 16px rgba(0, 0, 0, 0.12)" : "none",
transition: "box-shadow 0.2s ease",
},
// The document variant omits the form-input box entirely (border,
// 172px height, padding) — only the page-specific minimum height is
// declared here, so nothing races HFRichInput's Container styles in
// the cascade. An empty draft still presents a page-like surface.
"& .editorClassName": {
minHeight: "60vh",
},
}}
>
<HFRichInput
value={initialContent}
onChange={onChange}
options={[
"headings",
"paragraph-small",
"alignments",
"ordered-items",
// Fee-schedule tables (Merchant Agreement) — see TableBlock.tsx
"table",
// Keep a pasted Word/Google Docs agreement's headings, emphasis,
// alignment, lists and links instead of flattening them to plain
// text — see pastedContent.ts.
"rich-paste",
]}
maxLength={DOCUMENT_MAX_LENGTH}
label={null}
showCharCount={false}
variant="document"
/>
</Box>
</Box>
);
}
|