Document editor
Write and format text with links and lists.
Built with
- Button
- DirectionalIcon
- RichTextEditor
- RTEBubbleMenu
- RTELinkPopover
- RTETool
- RTEToolbar
- RTEToolbarDivider
- RTEToolSelect
New announcement
Select text to format it, or press Ctrl+K to add a link.
Message
Open enrolment starts on 1 November
This year you can choose between two health plans and add family members until 30 November. Read the benefits guide before you pick a plan.
- Compare the Standard and Plus plans
- Add your partner or children
- Submit before 30 November
Sent to all 128 employees by email and in the app.
262 / 5,000document-editor.tsx
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
LuBold,
LuItalic,
LuLink,
LuList,
LuListOrdered,
LuRedo2,
LuRemoveFormatting,
LuStrikethrough,
LuUnderline,
LuUndo2,
} from "react-icons/lu";
import {
Button,
DirectionalIcon,
RichTextEditor,
RTEBubbleMenu,
RTELinkPopover,
RTETool,
RTEToolbar,
RTEToolbarDivider,
RTEToolSelect,
} from "@nexera-ui/react";
const BLOCKS = [
{ id: "p", label: "Paragraph" },
{ id: "h2", label: "Heading 2" },
{ id: "h3", label: "Heading 3" },
];
const MARKS = [
"bold",
"italic",
"underline",
"strikeThrough",
"insertUnorderedList",
"insertOrderedList",
] as const;
type Mark = (typeof MARKS)[number];
const INITIAL_HTML =
'<h2 class="m-0 text-heading-h3">Open enrolment starts on 1 November</h2><p class="m-0 mt-3">This year you can choose between two health plans and add family members until 30 November. Read the benefits guide before you pick a plan.</p><ul class="m-0 mt-3 list-disc ps-6"><li>Compare the Standard and Plus plans</li><li>Add your partner or children</li><li>Submit before 30 November</li></ul>';
/**
* Demo only: the browser's editing commands stand in for an editor engine. In production, wire an engine
* (Tiptap, Lexical, ProseMirror) to the same tools.
*/
function exec(command: string, value?: string) {
try {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- demo only: no non-deprecated browser API formats contenteditable text; production wires an editor engine
document.execCommand(command, false, value);
} catch {
// Not available (for example in tests): the editor still accepts plain typing.
}
}
function query(command: string) {
try {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- demo only: no non-deprecated browser API formats contenteditable text; production wires an editor engine
return document.queryCommandState(command);
} catch {
return false;
}
}
function queryBlock() {
try {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- demo only: no non-deprecated browser API formats contenteditable text; production wires an editor engine
const value = document.queryCommandValue("formatBlock").toLowerCase();
return value === "h2" || value === "h3" ? value : "p";
} catch {
return "p";
}
}
/** Document editor: write and format an announcement with a toolbar, a selection bubble menu and a link popover. */
export default function DocumentEditor() {
const contentRef = useRef<HTMLDivElement>(null);
const linkToolRef = useRef<HTMLButtonElement>(null);
const savedRange = useRef<Range | null>(null);
const [marks, setMarks] = useState<ReadonlySet<string>>(new Set());
const [block, setBlock] = useState<string | number>("p");
const [length, setLength] = useState(INITIAL_HTML.replace(/<[^>]+>/g, "").length);
const [invalid, setInvalid] = useState(false);
const [published, setPublished] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const [bubbleOpen, setBubbleOpen] = useState(false);
const refresh = useCallback(() => {
if (!contentRef.current) return;
setMarks(new Set(MARKS.filter((mark) => query(mark))));
setBlock(queryBlock());
setLength(contentRef.current.textContent.length);
}, []);
useEffect(() => {
const onSelectionChange = () => {
const selection = document.getSelection();
if (
!selection ||
selection.rangeCount === 0 ||
!contentRef.current?.contains(selection.anchorNode)
)
return;
savedRange.current = selection.getRangeAt(0).cloneRange();
setBubbleOpen(!selection.isCollapsed);
refresh();
};
document.addEventListener("selectionchange", onSelectionChange);
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
};
}, [refresh]);
/** Restores the editor selection (focus may be on a tool), runs the command and refreshes the tools. */
const run = (command: string, value?: string) => {
contentRef.current?.focus();
const selection = document.getSelection();
if (selection && savedRange.current) {
selection.removeAllRanges();
selection.addRange(savedRange.current);
}
exec(command, value);
refresh();
};
const toggle = (command: Mark, name: string, icon: ReactNode, keys?: string) => (
<RTETool
aria-label={name}
icon={icon}
isSelected={marks.has(command)}
onChange={() => {
run(command);
}}
preventFocusOnPress
{...(keys ? { shortcut: `Ctrl+${keys}`, "aria-keyshortcuts": `Control+${keys}` } : {})}
/>
);
const toolbar = (
<RTEToolbar aria-label="Formatting">
<RTETool
kind="action"
aria-label="Undo"
icon={
<DirectionalIcon>
<LuUndo2 />
</DirectionalIcon>
}
shortcut="Ctrl+Z"
preventFocusOnPress
onPress={() => {
run("undo");
}}
/>
<RTETool
kind="action"
aria-label="Redo"
icon={
<DirectionalIcon>
<LuRedo2 />
</DirectionalIcon>
}
shortcut="Ctrl+Y"
preventFocusOnPress
onPress={() => {
run("redo");
}}
/>
<RTEToolbarDivider />
<RTEToolSelect
aria-label="Block type"
options={BLOCKS}
value={block}
onChange={(key) => {
if (key !== null) run("formatBlock", `<${String(key)}>`);
}}
/>
<RTEToolbarDivider />
{toggle("bold", "Bold", <LuBold />, "B")}
{toggle("italic", "Italic", <LuItalic />, "I")}
{toggle("underline", "Underline", <LuUnderline />, "U")}
{toggle("strikeThrough", "Strikethrough", <LuStrikethrough />)}
<RTEToolbarDivider />
{toggle("insertUnorderedList", "Bullet list", <LuList />)}
{toggle("insertOrderedList", "Numbered list", <LuListOrdered />)}
<RTEToolbarDivider />
<RTETool
ref={linkToolRef}
kind="action"
aria-label="Link"
icon={<LuLink />}
shortcut="Ctrl+K"
aria-keyshortcuts="Control+K"
onPress={() => {
setLinkOpen(true);
}}
/>
<RTETool
kind="action"
aria-label="Clear formatting"
icon={<LuRemoveFormatting />}
className="ms-auto"
preventFocusOnPress
onPress={() => {
run("removeFormat");
}}
/>
</RTEToolbar>
);
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<div className="mx-auto grid w-full max-w-3xl gap-4">
<header className="grid gap-1">
<h2 className="text-heading-h3 text-primary">New announcement</h2>
<p className="text-body-default text-secondary">
Select text to format it, or press Ctrl+K to add a link.
</p>
</header>
<RichTextEditor
label="Message"
description="Sent to all 128 employees by email and in the app."
errorMessage="Add a message before publishing."
isInvalid={invalid}
counter={`${length.toLocaleString("en-US")} / 5,000`}
placeholder="Write your announcement."
content={length === 0 ? "empty" : "filled"}
toolbar={toolbar}
>
{({ contentProps, isDisabled }) => (
<div
ref={contentRef}
{...contentProps}
role="textbox"
tabIndex={isDisabled ? -1 : 0}
contentEditable={!isDisabled}
suppressContentEditableWarning
dangerouslySetInnerHTML={{ __html: INITIAL_HTML }}
onInput={() => {
setInvalid(false);
setPublished(false);
refresh();
}}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setLinkOpen(true);
}
}}
/>
)}
</RichTextEditor>
<RTELinkPopover
triggerRef={linkToolRef}
isOpen={linkOpen}
onOpenChange={setLinkOpen}
onSubmit={({ href }) => {
run("createLink", href);
}}
/>
<RTEBubbleMenu
aria-label="Text formatting"
triggerRef={contentRef}
getTargetRect={() => savedRange.current?.getBoundingClientRect() ?? null}
isOpen={bubbleOpen}
onOpenChange={setBubbleOpen}
>
{toggle("bold", "Bold", <LuBold />, "B")}
{toggle("italic", "Italic", <LuItalic />, "I")}
{toggle("underline", "Underline", <LuUnderline />, "U")}
</RTEBubbleMenu>
<div className="flex flex-wrap items-center gap-3">
<Button
onPress={() => {
if ((contentRef.current?.textContent ?? "").trim() === "") {
setInvalid(true);
contentRef.current?.focus();
} else setPublished(true);
}}
>
Publish
</Button>
<Button variant="ghost">Save draft</Button>
<output className="text-body-small text-secondary">
{published ? "Published to all employees." : ""}
</output>
</div>
</div>
</div>
);
}
More messaging and collaboration blocks
- ChatOne conversation with bubbles, reactions and a composer.
- InboxConversation list beside the open chat.
- Helpdesk assistantAsk HR or support a question and get answers.
- Comment threadDiscuss a record with replies and reactions.
- Notification centreUnread notifications in a popover.
- AnnouncementA banner for news that affects everyone.
- Day separators and system notesShow who joined and where each day starts.