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>
241 lines
7 KiB
TypeScript
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>
|
|
);
|
|
}
|