1
0
Fork 0
polaris-task-force/src/components/frontend/intelligence/LexicalRichTextToolbar.tsx
Z8MB1E 982c33f4f3 feat(missions): lexical rich text editing, side-op narrative fields, briefing updates
Editor, toolbar, and button components with the lib they share, consumed by the mission briefing section and side-op dialogs.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-14 15:37:12 -04:00

241 lines
7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
mergeRegister,
SELECTION_CHANGE_COMMAND,
type LexicalEditor,
type LexicalNode,
type RangeSelection,
} from "lexical";
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
$isQuoteNode,
} from "@lexical/rich-text";
import { $setBlocksType } from "@lexical/selection";
import {
$isListNode,
INSERT_CHECK_LIST_COMMAND,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
type ListType,
} from "@lexical/list";
import {
Bold,
Code2,
Italic,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Strikethrough,
Underline,
type LucideIcon,
} from "lucide-react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/extension";
import { Separator } from "@/components/ui/separator";
import { LexicalRichTextToolbarButton } from "./LexicalRichTextToolbarButton";
type BlockType = "paragraph" | "h2" | "h3" | "h4" | "quote";
interface ToolbarState {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
code: boolean;
blockType: BlockType;
listType: ListType | null;
}
const DEFAULT_TOOLBAR_STATE: ToolbarState = {
blockType: "paragraph",
bold: false,
code: false,
italic: false,
listType: null,
strikethrough: false,
underline: false,
};
function getBlockType(selection: RangeSelection): BlockType {
const topLevelElement = selection.anchor.getNode().getTopLevelElement();
if ($isHeadingNode(topLevelElement)) {
const tag = topLevelElement.getTag();
if (tag === "h2" || tag === "h3" || tag === "h4") return tag;
}
if ($isQuoteNode(topLevelElement)) return "quote";
return "paragraph";
}
function getListType(selection: RangeSelection): ListType | null {
let node: LexicalNode | null = selection.anchor.getNode();
while (node) {
if ($isListNode(node)) return node.getListType();
node = node.getParent();
}
return null;
}
function readToolbarState(editor: LexicalEditor): ToolbarState {
let state = DEFAULT_TOOLBAR_STATE;
editor.getEditorState().read(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) return;
state = {
blockType: getBlockType(selection),
bold: selection.hasFormat("bold"),
code: selection.hasFormat("code"),
italic: selection.hasFormat("italic"),
listType: getListType(selection),
strikethrough: selection.hasFormat("strikethrough"),
underline: selection.hasFormat("underline"),
};
});
return state;
}
export function LexicalRichTextToolbar({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext();
const [state, setState] = useState(DEFAULT_TOOLBAR_STATE);
useEffect(() => {
const update = () => setState(readToolbarState(editor));
update();
return mergeRegister(
editor.registerUpdateListener(update),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
update();
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor]);
function formatText(format: "bold" | "italic" | "underline" | "strikethrough" | "code") {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
}
function setBlockType(type: BlockType) {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) return;
const nextType = state.blockType === type && type !== "paragraph" ? "paragraph" : type;
if (nextType === "paragraph") {
$setBlocksType(selection, () => $createParagraphNode());
} else if (nextType === "quote") {
$setBlocksType(selection, () => $createQuoteNode());
} else {
$setBlocksType(selection, () => $createHeadingNode(nextType));
}
});
}
const inlineButtons: {
label: string;
icon: LucideIcon;
active: boolean;
onClick: () => void;
}[] = [
{ label: "Bold", icon: Bold, active: state.bold, onClick: () => formatText("bold") },
{ label: "Italic", icon: Italic, active: state.italic, onClick: () => formatText("italic") },
{
label: "Underline",
icon: Underline,
active: state.underline,
onClick: () => formatText("underline"),
},
{
label: "Strikethrough",
icon: Strikethrough,
active: state.strikethrough,
onClick: () => formatText("strikethrough"),
},
{ label: "Inline code", icon: Code2, active: state.code, onClick: () => formatText("code") },
];
const blockButtons: {
label: string;
icon: LucideIcon;
active: boolean;
pressed?: boolean;
onClick: () => void;
}[] = [
{
label: "Bulleted list",
icon: List,
active: state.listType === "bullet",
onClick: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
},
{
label: "Numbered list",
icon: ListOrdered,
active: state.listType === "number",
onClick: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
},
{
label: "Checklist",
icon: ListChecks,
active: state.listType === "check",
onClick: () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
},
{
label: "Blockquote",
icon: Quote,
active: state.blockType === "quote",
onClick: () => setBlockType("quote"),
},
{
label: "Horizontal rule",
icon: Minus,
active: false,
pressed: false,
onClick: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
},
];
return (
<div
className="flex min-w-0 flex-wrap items-center gap-1 rounded-t-md border-b bg-muted/20 p-1"
role="toolbar"
aria-label="Rich text formatting toolbar"
>
<div className="flex flex-wrap items-center gap-1" aria-label="Text formatting">
{inlineButtons.map((button) => (
<LexicalRichTextToolbarButton key={button.label} {...button} disabled={disabled} />
))}
</div>
<Separator orientation="vertical" className="mx-1 hidden h-6 sm:block" />
<div className="flex flex-wrap items-center gap-1" aria-label="Block style">
{(["paragraph", "h2", "h3", "h4"] as const).map((type) => (
<LexicalRichTextToolbarButton
key={type}
label={type === "paragraph" ? "Text" : type.toUpperCase()}
active={state.blockType === type}
disabled={disabled}
onClick={() => setBlockType(type)}
>
{type === "paragraph" ? "Text" : type.toUpperCase()}
</LexicalRichTextToolbarButton>
))}
</div>
<Separator orientation="vertical" className="mx-1 hidden h-6 sm:block" />
<div className="flex flex-wrap items-center gap-1" aria-label="Lists and blocks">
{blockButtons.map((button) => (
<LexicalRichTextToolbarButton key={button.label} {...button} disabled={disabled} />
))}
</div>
</div>
);
}