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>
This commit is contained in:
parent
7ad9320c02
commit
982c33f4f3
9 changed files with 595 additions and 79 deletions
|
|
@ -27,7 +27,6 @@ import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogi
|
|||
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
||||
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
||||
import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls";
|
||||
import { lexicalToText } from "@/lib/intelligence/briefingText";
|
||||
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
||||
import {
|
||||
type AttendanceResponse,
|
||||
|
|
@ -457,7 +456,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
: (mission.classification.campaign ?? null),
|
||||
briefing: (mission.briefing ?? []).map((entry) => ({
|
||||
sectionName: entry.sectionName,
|
||||
details: lexicalToText(entry.details),
|
||||
details: entry.details,
|
||||
})),
|
||||
missionObjectives: (mission.missionObjectives ?? []).map((obj) => ({
|
||||
title: obj.title,
|
||||
|
|
@ -467,7 +466,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
redacted: obj.redacted ?? false,
|
||||
})),
|
||||
civilianPresence: mission.civilianPresence ?? "None",
|
||||
radioPlan: lexicalToText(mission.commsAndDocs?.radioPlan ?? null),
|
||||
radioPlan: mission.commsAndDocs?.radioPlan ?? null,
|
||||
insertion: mission.logisticsAndSupport?.insertion ?? [],
|
||||
extraction: mission.logisticsAndSupport?.extraction ?? [],
|
||||
assetsAvailable: (mission.logisticsAndSupport?.assetsAvailable ?? []).map(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { hasPermission } from "@/utils/access-control/hasPermission";
|
|||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { textToLexical } from "@/lib/intelligence/briefingText";
|
||||
import {
|
||||
nextDayKey,
|
||||
parseDateKey,
|
||||
|
|
@ -266,7 +265,7 @@ export interface FridaySideOpInput {
|
|||
noCampaign?: boolean;
|
||||
customMapName?: string | null;
|
||||
requiredModsId?: number | null;
|
||||
briefing?: { sectionName: string; details: string }[] | null;
|
||||
briefing?: { sectionName: string; details?: Record<string, unknown> | null }[] | null;
|
||||
missionObjectives?: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
|
|
@ -279,7 +278,7 @@ export interface FridaySideOpInput {
|
|||
faction: number;
|
||||
threatLevel: "ally" | "friendly" | "neutral" | "hostile";
|
||||
}[] | null;
|
||||
radioPlan?: string | null;
|
||||
radioPlan?: Record<string, unknown> | null;
|
||||
insertion?: InfilExfilMethod[] | null;
|
||||
extraction?: InfilExfilMethod[] | null;
|
||||
assetsAvailable?: {
|
||||
|
|
@ -291,11 +290,11 @@ export interface FridaySideOpInput {
|
|||
}
|
||||
|
||||
type SideOpNarrativeData = {
|
||||
briefing?: { sectionName: string; details: ReturnType<typeof textToLexical> }[];
|
||||
briefing?: { sectionName: string; details: MissionLexicalState }[];
|
||||
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
||||
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
||||
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
||||
commsAndDocs?: { radioPlan?: ReturnType<typeof textToLexical> };
|
||||
commsAndDocs?: { radioPlan?: MissionLexicalState | null };
|
||||
logisticsAndSupport?: {
|
||||
insertion?: InfilExfilMethod[] | null;
|
||||
extraction?: InfilExfilMethod[] | null;
|
||||
|
|
@ -303,6 +302,38 @@ type SideOpNarrativeData = {
|
|||
};
|
||||
};
|
||||
|
||||
type MissionLexicalState = NonNullable<NonNullable<Mission["briefing"]>[number]["details"]>;
|
||||
|
||||
type NarrativeDataResult = { success: true; data: SideOpNarrativeData } | { success: false; error: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isLexicalValue(value: unknown): value is MissionLexicalState | null | undefined {
|
||||
return value === undefined || value === null || (isRecord(value) && isRecord(value.root));
|
||||
}
|
||||
|
||||
const EMPTY_LEXICAL_STATE: MissionLexicalState = {
|
||||
root: {
|
||||
type: "root",
|
||||
children: [],
|
||||
direction: null,
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeLexicalValue(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
): MissionLexicalState {
|
||||
if (value === null || value === undefined || !isLexicalValue(value)) {
|
||||
return EMPTY_LEXICAL_STATE;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function narrativeData(input: {
|
||||
briefing?: FridaySideOpInput["briefing"];
|
||||
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
||||
|
|
@ -312,10 +343,18 @@ function narrativeData(input: {
|
|||
insertion?: FridaySideOpInput["insertion"];
|
||||
extraction?: FridaySideOpInput["extraction"];
|
||||
assetsAvailable?: FridaySideOpInput["assetsAvailable"];
|
||||
}): SideOpNarrativeData {
|
||||
}): NarrativeDataResult {
|
||||
const radioPlan = input.radioPlan;
|
||||
if (!isLexicalValue(radioPlan)) {
|
||||
return { success: false, error: "Invalid radio plan format." };
|
||||
}
|
||||
if (input.briefing?.some((entry) => !isLexicalValue(entry.details))) {
|
||||
return { success: false, error: "Invalid briefing details format." };
|
||||
}
|
||||
|
||||
const commsAndDocs: SideOpNarrativeData["commsAndDocs"] = {};
|
||||
if (input.radioPlan !== undefined) {
|
||||
commsAndDocs.radioPlan = textToLexical(input.radioPlan ?? "");
|
||||
if (radioPlan !== undefined) {
|
||||
commsAndDocs.radioPlan = radioPlan;
|
||||
}
|
||||
const logisticsAndSupport: SideOpNarrativeData["logisticsAndSupport"] = {};
|
||||
if (input.insertion !== undefined) {
|
||||
|
|
@ -328,19 +367,24 @@ function narrativeData(input: {
|
|||
logisticsAndSupport.assetsAvailable = input.assetsAvailable;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...(input.briefing !== undefined
|
||||
? {
|
||||
briefing: (input.briefing ?? []).map((entry) => ({
|
||||
sectionName: entry.sectionName,
|
||||
details: textToLexical(entry.details),
|
||||
details: normalizeLexicalValue(entry.details),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(input.missionObjectives !== undefined ? { missionObjectives: input.missionObjectives } : {}),
|
||||
...(input.missionObjectives !== undefined
|
||||
? { missionObjectives: input.missionObjectives }
|
||||
: {}),
|
||||
...(input.civilianPresence !== undefined ? { civilianPresence: input.civilianPresence } : {}),
|
||||
...(input.factionsInvolved !== undefined ? { factionsInvolved: input.factionsInvolved } : {}),
|
||||
...(Object.keys(commsAndDocs).length > 0 ? { commsAndDocs } : {}),
|
||||
...(Object.keys(logisticsAndSupport).length > 0 ? { logisticsAndSupport } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -474,6 +518,9 @@ export async function createFridaySideOp(
|
|||
return { success: false, error: "Please choose a map." };
|
||||
}
|
||||
|
||||
const narrative = narrativeData(input);
|
||||
if (!narrative.success) return narrative;
|
||||
|
||||
const mission = await payload.create({
|
||||
collection: "missions",
|
||||
overrideAccess: true,
|
||||
|
|
@ -508,7 +555,7 @@ export async function createFridaySideOp(
|
|||
? { gameSetup: { requiredMods: input.requiredModsId } }
|
||||
: {}),
|
||||
},
|
||||
...narrativeData(input),
|
||||
...narrative.data,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -547,11 +594,11 @@ export interface UpdateFridaySideOpInput {
|
|||
noCampaign?: boolean;
|
||||
customMapName?: string | null;
|
||||
requiredModsId?: number | null;
|
||||
briefing?: { sectionName: string; details: string }[] | null;
|
||||
briefing?: { sectionName: string; details?: Record<string, unknown> | null }[] | null;
|
||||
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
||||
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
||||
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
||||
radioPlan?: string | null;
|
||||
radioPlan?: Record<string, unknown> | null;
|
||||
insertion?: InfilExfilMethod[] | null;
|
||||
extraction?: InfilExfilMethod[] | null;
|
||||
assetsAvailable?: FridaySideOpInput["assetsAvailable"];
|
||||
|
|
@ -677,7 +724,9 @@ export async function updateFridaySideOp(
|
|||
if (input.requiredModsId !== undefined) {
|
||||
data.gameDetails = { gameSetup: { requiredMods: input.requiredModsId } };
|
||||
}
|
||||
Object.assign(data, narrativeData(input));
|
||||
const narrative = narrativeData(input);
|
||||
if (!narrative.success) return narrative;
|
||||
Object.assign(data, narrative.data);
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: "missions",
|
||||
|
|
|
|||
|
|
@ -37,16 +37,31 @@ import {
|
|||
SideOpNarrativeFields,
|
||||
type SideOpNarrative,
|
||||
} from "@/components/frontend/intelligence/SideOpNarrativeFields";
|
||||
import type { LexicalJson } from "./LexicalRichTextEditor";
|
||||
|
||||
const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const;
|
||||
const DEFAULT_BRIEFING_SECTIONS = ["Situation", "Mission", "Execution", "Signals"];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasLexicalText(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.text === "string" && value.text.trim() !== "") return true;
|
||||
return Array.isArray(value.children) && value.children.some(hasLexicalText);
|
||||
}
|
||||
|
||||
function isEmptyLexical(value: LexicalJson | null): boolean {
|
||||
return value === null || !hasLexicalText(value);
|
||||
}
|
||||
|
||||
function defaultNarrative(): SideOpNarrative {
|
||||
return {
|
||||
briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: "" })),
|
||||
briefing: DEFAULT_BRIEFING_SECTIONS.map((sectionName) => ({ sectionName, details: null })),
|
||||
missionObjectives: [],
|
||||
civilianPresence: "None",
|
||||
radioPlan: "",
|
||||
radioPlan: null,
|
||||
insertion: [],
|
||||
extraction: [],
|
||||
assetsAvailable: [],
|
||||
|
|
@ -73,7 +88,7 @@ export interface SideOpDialogInitial {
|
|||
maxPlayers: number;
|
||||
campaignId: number | null;
|
||||
requiredModsId?: number | null;
|
||||
briefing?: { sectionName: string; details: string }[];
|
||||
briefing?: { sectionName: string; details: LexicalJson | null }[];
|
||||
missionObjectives?: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -82,7 +97,7 @@ export interface SideOpDialogInitial {
|
|||
redacted: boolean;
|
||||
}[];
|
||||
civilianPresence?: string;
|
||||
radioPlan?: string;
|
||||
radioPlan?: LexicalJson | null;
|
||||
insertion?: string[];
|
||||
extraction?: string[];
|
||||
assetsAvailable?: {
|
||||
|
|
@ -172,7 +187,7 @@ export function CreateSideOpDialog({
|
|||
briefing: initial.briefing ?? [],
|
||||
missionObjectives: initial.missionObjectives ?? [],
|
||||
civilianPresence: initial.civilianPresence ?? "None",
|
||||
radioPlan: initial.radioPlan ?? "",
|
||||
radioPlan: initial.radioPlan ?? null,
|
||||
insertion: initial.insertion ?? [],
|
||||
extraction: initial.extraction ?? [],
|
||||
assetsAvailable: initial.assetsAvailable ?? [],
|
||||
|
|
@ -190,7 +205,7 @@ export function CreateSideOpDialog({
|
|||
}
|
||||
return {
|
||||
briefing: narrative.briefing
|
||||
.filter((e) => e.sectionName.trim() !== "" || e.details.trim() !== "")
|
||||
.filter((e) => e.sectionName.trim() !== "" || !isEmptyLexical(e.details))
|
||||
.map((e) => ({ sectionName: e.sectionName.trim() || "Untitled", details: e.details })),
|
||||
missionObjectives: narrative.missionObjectives
|
||||
.filter((o) => o.title.trim() !== "")
|
||||
|
|
|
|||
206
src/components/frontend/intelligence/LexicalRichTextEditor.tsx
Normal file
206
src/components/frontend/intelligence/LexicalRichTextEditor.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { $createParagraphNode, $getRoot, type EditorThemeClasses } from "lexical";
|
||||
import { HorizontalRuleNode } from "@lexical/extension";
|
||||
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
||||
import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin";
|
||||
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
|
||||
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
||||
import { ListItemNode, ListNode } from "@lexical/list";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LexicalRichTextToolbar } from "./LexicalRichTextToolbar";
|
||||
|
||||
export type LexicalJson = Record<string, unknown>;
|
||||
|
||||
const EXTERNAL_STATE_TAG = "polaris-external-state";
|
||||
|
||||
const EDITOR_NODES = [
|
||||
HeadingNode,
|
||||
QuoteNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
HorizontalRuleNode,
|
||||
LinkNode,
|
||||
AutoLinkNode,
|
||||
] as const;
|
||||
|
||||
const EDITOR_THEME: EditorThemeClasses = {
|
||||
heading: {
|
||||
h2: "text-lg font-semibold",
|
||||
h3: "text-base font-semibold",
|
||||
h4: "text-sm font-semibold",
|
||||
},
|
||||
hr: "my-3 border-border",
|
||||
link: "text-primary underline underline-offset-2",
|
||||
list: {
|
||||
checklist: "pl-6",
|
||||
listitem: "pl-1",
|
||||
listitemChecked: "text-muted-foreground line-through",
|
||||
listitemUnchecked: "",
|
||||
nested: { list: "pl-6", listitem: "pl-1" },
|
||||
ol: "list-decimal pl-6",
|
||||
ul: "list-disc pl-6",
|
||||
},
|
||||
quote: "border-l-2 border-border pl-3 italic text-muted-foreground",
|
||||
text: {
|
||||
bold: "font-semibold",
|
||||
code: "rounded-sm bg-muted px-1 font-mono text-xs",
|
||||
italic: "italic",
|
||||
strikethrough: "line-through",
|
||||
underline: "underline",
|
||||
underlineStrikethrough: "underline line-through",
|
||||
},
|
||||
};
|
||||
|
||||
function serializeValue(value: LexicalJson | null): string | null {
|
||||
return value === null ? null : (JSON.stringify(value) ?? null);
|
||||
}
|
||||
|
||||
function EditablePlugin({ disabled }: { disabled: boolean }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
editor.setEditable(!disabled);
|
||||
}, [disabled, editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ExternalStatePlugin({
|
||||
lastEmittedJsonRef,
|
||||
value,
|
||||
}: {
|
||||
lastEmittedJsonRef: { current: string | null };
|
||||
value: LexicalJson | null;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const previousValueRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousValueRef.current === value) return;
|
||||
previousValueRef.current = value;
|
||||
|
||||
const serializedValue = serializeValue(value);
|
||||
if (serializedValue !== null && serializedValue === lastEmittedJsonRef.current) {
|
||||
lastEmittedJsonRef.current = null;
|
||||
return;
|
||||
}
|
||||
lastEmittedJsonRef.current = null;
|
||||
|
||||
if (serializedValue === null) {
|
||||
editor.update(
|
||||
() => {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
root.append($createParagraphNode());
|
||||
},
|
||||
{ tag: EXTERNAL_STATE_TAG },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
editor.setEditorState(editor.parseEditorState(serializedValue), { tag: EXTERNAL_STATE_TAG });
|
||||
}, [editor, lastEmittedJsonRef, value]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SerializeOnChangePlugin({
|
||||
lastEmittedJsonRef,
|
||||
onChange,
|
||||
}: {
|
||||
lastEmittedJsonRef: { current: string | null };
|
||||
onChange: (json: LexicalJson) => void;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, editorState, tags }) => {
|
||||
if (tags.has(EXTERNAL_STATE_TAG) || (dirtyElements.size === 0 && dirtyLeaves.size === 0)) {
|
||||
return;
|
||||
}
|
||||
const json: LexicalJson = { ...editorState.toJSON() };
|
||||
lastEmittedJsonRef.current = JSON.stringify(json);
|
||||
onChangeRef.current(json);
|
||||
}),
|
||||
[editor, lastEmittedJsonRef],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LexicalRichTextEditorProps {
|
||||
value: LexicalJson | null;
|
||||
onChange: (json: LexicalJson) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LexicalRichTextEditor({
|
||||
disabled = false,
|
||||
onChange,
|
||||
placeholder = "Write the operation details...",
|
||||
value,
|
||||
}: LexicalRichTextEditorProps) {
|
||||
const lastEmittedJsonRef = useRef<string | null>(null);
|
||||
const [initialConfig] = useState(() => ({
|
||||
namespace: "PolarisSideOpNarrative",
|
||||
nodes: EDITOR_NODES,
|
||||
onError: (error: Error) => console.error("Lexical editor error.", error),
|
||||
editorState: serializeValue(value),
|
||||
theme: EDITOR_THEME,
|
||||
editable: !disabled,
|
||||
}));
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border border-input bg-background transition-[box-shadow] focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<LexicalRichTextToolbar disabled={disabled} />
|
||||
<div className="relative min-h-[120px]">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
className="min-h-[120px] px-3 py-2 text-sm outline-none"
|
||||
aria-label="Rich text editor"
|
||||
aria-placeholder={placeholder}
|
||||
placeholder={<span className="sr-only">{placeholder}</span>}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<div className="pointer-events-none absolute left-3 top-2 text-sm text-muted-foreground">
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<CheckListPlugin />
|
||||
<HorizontalRulePlugin />
|
||||
<EditablePlugin disabled={disabled} />
|
||||
<ExternalStatePlugin value={value} lastEmittedJsonRef={lastEmittedJsonRef} />
|
||||
<SerializeOnChangePlugin onChange={onChange} lastEmittedJsonRef={lastEmittedJsonRef} />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
241
src/components/frontend/intelligence/LexicalRichTextToolbar.tsx
Normal file
241
src/components/frontend/intelligence/LexicalRichTextToolbar.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LexicalRichTextToolbarButton({
|
||||
active = false,
|
||||
children,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
pressed = active,
|
||||
}: {
|
||||
active?: boolean;
|
||||
children?: ReactNode;
|
||||
disabled: boolean;
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
pressed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "secondary" : "outline"}
|
||||
className={Icon ? "size-8 p-0" : "px-2.5"}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{Icon ? <Icon aria-hidden="true" /> : children}
|
||||
{Icon && <span className="sr-only">{label}</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ export function MissionBriefingSection({ briefing }: MissionBriefingSectionProps
|
|||
<ItemHeader>
|
||||
<ItemTitle>{entry.sectionName}</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
|
||||
<ItemContent className="flex flex-col justify-start items-start lexical-content">
|
||||
<RichText data={entry.details} />
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LexicalRichTextEditor, type LexicalJson } from "./LexicalRichTextEditor";
|
||||
|
||||
const CIVILIAN_PRESENCE = ["None", "Low", "Medium", "High"] as const;
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ const INFIL_EXFIL_METHODS = [
|
|||
] as const;
|
||||
|
||||
export interface SideOpNarrative {
|
||||
briefing: { sectionName: string; details: string }[];
|
||||
briefing: { sectionName: string; details: LexicalJson | null }[];
|
||||
missionObjectives: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -35,7 +36,7 @@ export interface SideOpNarrative {
|
|||
redacted: boolean;
|
||||
}[];
|
||||
civilianPresence: string;
|
||||
radioPlan: string;
|
||||
radioPlan: LexicalJson | null;
|
||||
insertion: string[];
|
||||
extraction: string[];
|
||||
assetsAvailable: {
|
||||
|
|
@ -65,8 +66,8 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
|
|||
return (
|
||||
<div className="grid gap-5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plain text only. Each line becomes a paragraph. Rich formatting written in the admin panel
|
||||
is flattened if you save here.
|
||||
Use the formatting toolbar for briefing and radio details. Existing rich text is preserved
|
||||
when you save.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -93,15 +94,14 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
|
|||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
<LexicalRichTextEditor
|
||||
value={entry.details}
|
||||
onChange={(e) => {
|
||||
onChange={(details) => {
|
||||
const next = [...value.briefing];
|
||||
next[i] = { ...entry, details: e.target.value };
|
||||
next[i] = { ...entry, details };
|
||||
setBriefing(next);
|
||||
}}
|
||||
placeholder="Briefing text for this section"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -111,9 +111,7 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() =>
|
||||
setBriefing([...value.briefing, { sectionName: "", details: "" }])
|
||||
}
|
||||
onClick={() => setBriefing([...value.briefing, { sectionName: "", details: null }])}
|
||||
>
|
||||
<Plus className="size-4" /> Add briefing entry
|
||||
</Button>
|
||||
|
|
@ -232,11 +230,10 @@ export function SideOpNarrativeFields({ value, onChange }: SideOpNarrativeFields
|
|||
|
||||
<div className="grid gap-2">
|
||||
<Label>Radio SOP</Label>
|
||||
<Textarea
|
||||
<LexicalRichTextEditor
|
||||
value={value.radioPlan}
|
||||
onChange={(e) => onChange({ ...value, radioPlan: e.target.value })}
|
||||
onChange={(radioPlan) => onChange({ ...value, radioPlan })}
|
||||
placeholder="How radios will be used: nets, contingency plans, radio silence, etc."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -52,33 +52,3 @@ export function lexicalToText(details: unknown): string {
|
|||
.filter((line, i, all) => line.trim() !== "" || (i > 0 && i < all.length - 1))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function textToLexical(text: string): SerializedLexicalState {
|
||||
const lines = text.split("\n");
|
||||
return {
|
||||
root: {
|
||||
type: "root",
|
||||
children: (lines.length > 0 ? lines : [""]).map((line) => ({
|
||||
type: "paragraph",
|
||||
children:
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
text: line,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: null,
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
})),
|
||||
direction: "ltr",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue