- {error && (
-
-
-
- )}
-
-
-
+
+ {error && (
+
+
+
+ )}
+
@@ -491,7 +493,13 @@ export default function EditorPage() {
Cancel
- OK
+ Save
+
+ Leave without saving
+
diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css
index 4095fc660..c192a27be 100644
--- a/surfsense_web/app/globals.css
+++ b/surfsense_web/app/globals.css
@@ -4,6 +4,8 @@
@plugin "tailwindcss-animate";
+@plugin "tailwind-scrollbar-hide";
+
@custom-variant dark (&:is(.dark *));
@theme {
@@ -46,6 +48,8 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--syntax-bg: #f5f5f5;
+ --brand: oklch(0.623 0.214 259.815);
+ --highlight: oklch(0.852 0.199 91.936);
}
.dark {
@@ -82,6 +86,8 @@
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--syntax-bg: #1e1e1e;
+ --brand: oklch(0.707 0.165 254.624);
+ --highlight: oklch(0.852 0.199 91.936);
}
@theme inline {
@@ -123,6 +129,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
+ --color-brand: var(--brand);
+ --color-highlight: var(--highlight);
}
@layer base {
diff --git a/surfsense_web/components.json b/surfsense_web/components.json
index 6e57ca9e3..6086c498b 100644
--- a/surfsense_web/components.json
+++ b/surfsense_web/components.json
@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
+ "iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "iconLibrary": "lucide"
+ "registries": {
+ "@plate": "https://platejs.org/r/{name}.json"
+ }
}
diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx
deleted file mode 100644
index 440c63625..000000000
--- a/surfsense_web/components/BlockNoteEditor.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-"use client";
-
-import { useTheme } from "next-themes";
-import { useEffect, useMemo, useRef } from "react";
-import "@blocknote/core/fonts/inter.css";
-import "@blocknote/mantine/style.css";
-import { BlockNoteView } from "@blocknote/mantine";
-import { useCreateBlockNote } from "@blocknote/react";
-
-interface BlockNoteEditorProps {
- initialContent?: any;
- onChange?: (content: any) => void;
- useTitleBlock?: boolean; // Whether to use first block as title (Notion-style)
-}
-
-// Helper to ensure first block is a heading for title
-function ensureTitleBlock(content: any[] | undefined): any[] {
- if (!content || content.length === 0) {
- // Return empty heading block for new notes
- return [
- {
- type: "heading",
- props: { level: 1 },
- content: [],
- children: [],
- },
- ];
- }
-
- // If first block is not a heading, convert it to one
- const firstBlock = content[0];
- if (firstBlock?.type !== "heading") {
- // Extract text from first block
- let titleText = "";
- if (firstBlock?.content && Array.isArray(firstBlock.content)) {
- titleText = firstBlock.content
- .map((item: any) => {
- if (typeof item === "string") return item;
- if (item?.text) return item.text;
- return "";
- })
- .join("")
- .trim();
- }
-
- // Create heading block with extracted text
- const titleBlock = {
- type: "heading",
- props: { level: 1 },
- content: titleText
- ? [
- {
- type: "text",
- text: titleText,
- styles: {},
- },
- ]
- : [],
- children: [],
- };
-
- // Replace first block with heading, keep rest
- return [titleBlock, ...content.slice(1)];
- }
-
- return content;
-}
-
-export default function BlockNoteEditor({
- initialContent,
- onChange,
- useTitleBlock = false,
-}: BlockNoteEditorProps) {
- const { resolvedTheme } = useTheme();
-
- // Track the initial content to prevent re-initialization
- const initialContentRef = useRef
(null);
- const isInitializedRef = useRef(false);
-
- // Prepare initial content - ensure first block is a heading if useTitleBlock is true
- const preparedInitialContent = useMemo(() => {
- if (initialContentRef.current !== null) {
- return undefined; // Already initialized
- }
- if (initialContent === undefined) {
- // New note - create empty heading block
- return useTitleBlock
- ? [
- {
- type: "heading",
- props: { level: 1 },
- content: [],
- children: [],
- },
- ]
- : undefined;
- }
- // Existing note - ensure first block is heading
- return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent;
- }, [initialContent, useTitleBlock]);
-
- // Creates a new editor instance - only use initialContent on first render
- const editor = useCreateBlockNote({
- initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
- });
-
- // Store initial content on first render only
- useEffect(() => {
- if (preparedInitialContent !== undefined && initialContentRef.current === null) {
- initialContentRef.current = preparedInitialContent;
- isInitializedRef.current = true;
- } else if (preparedInitialContent === undefined && initialContentRef.current === null) {
- // Mark as initialized even when initialContent is undefined (for new notes)
- isInitializedRef.current = true;
- }
- }, [preparedInitialContent]);
-
- // Call onChange when document changes (but don't update from props)
- useEffect(() => {
- if (!onChange || !editor) return;
-
- // For new notes (no initialContent), we need to wait for editor to be ready
- // Use a small delay to ensure editor is fully initialized
- if (!isInitializedRef.current) {
- const timer = setTimeout(() => {
- isInitializedRef.current = true;
- }, 100);
- return () => clearTimeout(timer);
- }
-
- const handleChange = () => {
- onChange(editor.document);
- };
-
- // Subscribe to document changes
- const unsubscribe = editor.onChange(handleChange);
-
- // Also call onChange once with current document to capture initial state
- // This ensures we capture content even if user doesn't make changes
- if (editor.document) {
- onChange(editor.document);
- }
-
- return () => {
- unsubscribe();
- };
- }, [editor, onChange]);
-
- // Determine theme for BlockNote with custom dark mode background
- const blockNoteTheme = useMemo(() => {
- if (resolvedTheme === "dark") {
- // Custom dark theme - only override editor background, let BlockNote handle the rest
- return {
- colors: {
- editor: {
- background: "#0A0A0A", // Custom dark background
- },
- },
- };
- }
- return "light" as const;
- }, [resolvedTheme]);
-
- // Renders the editor instance
- return (
-
-
-
-
- );
-}
diff --git a/surfsense_web/components/DynamicBlockNoteEditor.tsx b/surfsense_web/components/DynamicBlockNoteEditor.tsx
deleted file mode 100644
index 60fc6b11c..000000000
--- a/surfsense_web/components/DynamicBlockNoteEditor.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-"use client";
-
-import dynamic from "next/dynamic";
-
-// Dynamically import BlockNote editor with SSR disabled
-export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false });
diff --git a/surfsense_web/components/editor/editor-save-context.tsx b/surfsense_web/components/editor/editor-save-context.tsx
new file mode 100644
index 000000000..d53a4adce
--- /dev/null
+++ b/surfsense_web/components/editor/editor-save-context.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+interface EditorSaveContextValue {
+ /** Callback to save the current editor content */
+ onSave?: () => void;
+ /** Whether there are unsaved changes */
+ hasUnsavedChanges: boolean;
+ /** Whether a save operation is in progress */
+ isSaving: boolean;
+ /** Whether the user can toggle between editing and viewing modes */
+ canToggleMode: boolean;
+}
+
+export const EditorSaveContext = createContext({
+ hasUnsavedChanges: false,
+ isSaving: false,
+ canToggleMode: false,
+});
+
+export function useEditorSave() {
+ return useContext(EditorSaveContext);
+}
diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx
new file mode 100644
index 000000000..a0c316d88
--- /dev/null
+++ b/surfsense_web/components/editor/plate-editor.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import { useEffect, useMemo, useRef } from "react";
+import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
+import type { AnyPluginConfig } from "platejs";
+import { createPlatePlugin, Key, Plate, usePlateEditor } from "platejs/react";
+import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
+
+import { type EditorPreset, presetMap } from "@/components/editor/presets";
+import { Editor, EditorContainer } from "@/components/ui/editor";
+import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
+import { EditorSaveContext } from "@/components/editor/editor-save-context";
+
+export interface PlateEditorProps {
+ /** Markdown string to load as initial content */
+ markdown?: string;
+ /** Called when the editor content changes, with serialized markdown */
+ onMarkdownChange?: (markdown: string) => void;
+ /**
+ * Force permanent read-only mode (e.g. public/shared view).
+ * When true, the editor cannot be toggled to editing mode.
+ * When false (default), the editor starts in viewing mode but
+ * the user can switch to editing via the mode toolbar button.
+ */
+ readOnly?: boolean;
+ /** Placeholder text */
+ placeholder?: string;
+ /** Editor container variant */
+ variant?: "default" | "demo" | "comment" | "select";
+ /** Editor text variant */
+ editorVariant?: "default" | "demo" | "fullWidth" | "none";
+ /** Additional className for the container */
+ className?: string;
+ /** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
+ onSave?: () => void;
+ /** Whether there are unsaved changes */
+ hasUnsavedChanges?: boolean;
+ /** Whether a save is in progress */
+ isSaving?: boolean;
+ /** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
+ defaultEditing?: boolean;
+ /**
+ * Plugin preset to use. Controls which plugin kits are loaded.
+ * - "full" – all plugins (toolbars, slash commands, DnD, etc.)
+ * - "minimal" – core formatting only (no fixed toolbar, slash commands, DnD, block selection)
+ * - "readonly" – rendering support for all rich content, no editing UI
+ * @default "full"
+ */
+ preset?: EditorPreset;
+ /**
+ * Additional plugins to append after the preset plugins.
+ * Use this to inject feature-specific plugins (e.g. approve/reject blocks)
+ * without modifying the core editor component.
+ */
+ extraPlugins?: AnyPluginConfig[];
+}
+
+export function PlateEditor({
+ markdown,
+ onMarkdownChange,
+ readOnly = false,
+ placeholder = "Type...",
+ variant = "default",
+ editorVariant = "default",
+ className,
+ onSave,
+ hasUnsavedChanges = false,
+ isSaving = false,
+ defaultEditing = false,
+ preset = "full",
+ extraPlugins = [],
+}: PlateEditorProps) {
+ const lastMarkdownRef = useRef(markdown);
+
+ // Keep a stable ref to the latest onSave callback so the plugin shortcut
+ // always calls the most recent version without re-creating the editor.
+ const onSaveRef = useRef(onSave);
+ useEffect(() => {
+ onSaveRef.current = onSave;
+ }, [onSave]);
+
+ // Stable Plate plugin for ⌘+S / Ctrl+S save shortcut.
+ // Only included when onSave is provided.
+ const SaveShortcutPlugin = useMemo(
+ () =>
+ createPlatePlugin({
+ key: "save-shortcut",
+ shortcuts: {
+ save: {
+ keys: [[Key.Mod, "s"]],
+ handler: () => {
+ onSaveRef.current?.();
+ },
+ preventDefault: true,
+ },
+ },
+ }),
+ []
+ );
+
+ // Resolve the plugin set from the chosen preset
+ const presetPlugins = presetMap[preset];
+
+ // When readOnly is forced, always start in readOnly.
+ // Otherwise, respect defaultEditing to decide initial mode.
+ // The user can still toggle between editing/viewing via ModeToolbarButton.
+ const editor = usePlateEditor({
+ readOnly: readOnly || !defaultEditing,
+ plugins: [
+ ...presetPlugins,
+ // Only register save shortcut when a save handler is provided
+ ...(onSave ? [SaveShortcutPlugin] : []),
+ // Consumer-provided extra plugins
+ ...extraPlugins,
+ MarkdownPlugin.configure({
+ options: {
+ remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
+ },
+ }),
+ ],
+ // Use markdown deserialization for initial value if provided
+ value: markdown
+ ? (editor) =>
+ editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
+ : undefined,
+ });
+
+ // Update editor content when markdown prop changes externally
+ // (e.g., version switching in report panel)
+ useEffect(() => {
+ if (markdown !== undefined && markdown !== lastMarkdownRef.current) {
+ lastMarkdownRef.current = markdown;
+ const newValue = editor
+ .getApi(MarkdownPlugin)
+ .markdown.deserialize(escapeMdxExpressions(markdown));
+ editor.tf.reset();
+ editor.tf.setValue(newValue);
+ }
+ }, [markdown, editor]);
+
+ // When not forced read-only, the user can toggle between editing/viewing.
+ const canToggleMode = !readOnly;
+
+ return (
+
+ {
+ if (onMarkdownChange) {
+ const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
+ lastMarkdownRef.current = md;
+ onMarkdownChange(md);
+ }
+ }}
+ >
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/components/editor/plugins/autoformat-kit.tsx b/surfsense_web/components/editor/plugins/autoformat-kit.tsx
new file mode 100644
index 000000000..a145fbb94
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/autoformat-kit.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import type { AutoformatRule } from "@platejs/autoformat";
+
+import {
+ autoformatArrow,
+ autoformatLegal,
+ autoformatLegalHtml,
+ autoformatMath,
+ AutoformatPlugin,
+ autoformatPunctuation,
+ autoformatSmartQuotes,
+} from "@platejs/autoformat";
+import { insertEmptyCodeBlock } from "@platejs/code-block";
+import { toggleList } from "@platejs/list";
+import { openNextToggles } from "@platejs/toggle/react";
+import { KEYS } from "platejs";
+
+const autoformatMarks: AutoformatRule[] = [
+ {
+ match: "***",
+ mode: "mark",
+ type: [KEYS.bold, KEYS.italic],
+ },
+ {
+ match: "__*",
+ mode: "mark",
+ type: [KEYS.underline, KEYS.italic],
+ },
+ {
+ match: "__**",
+ mode: "mark",
+ type: [KEYS.underline, KEYS.bold],
+ },
+ {
+ match: "___***",
+ mode: "mark",
+ type: [KEYS.underline, KEYS.bold, KEYS.italic],
+ },
+ {
+ match: "**",
+ mode: "mark",
+ type: KEYS.bold,
+ },
+ {
+ match: "__",
+ mode: "mark",
+ type: KEYS.underline,
+ },
+ {
+ match: "*",
+ mode: "mark",
+ type: KEYS.italic,
+ },
+ {
+ match: "_",
+ mode: "mark",
+ type: KEYS.italic,
+ },
+ {
+ match: "~~",
+ mode: "mark",
+ type: KEYS.strikethrough,
+ },
+ {
+ match: "^",
+ mode: "mark",
+ type: KEYS.sup,
+ },
+ {
+ match: "~",
+ mode: "mark",
+ type: KEYS.sub,
+ },
+ {
+ match: "==",
+ mode: "mark",
+ type: KEYS.highlight,
+ },
+ {
+ match: "≡",
+ mode: "mark",
+ type: KEYS.highlight,
+ },
+ {
+ match: "`",
+ mode: "mark",
+ type: KEYS.code,
+ },
+];
+
+const autoformatBlocks: AutoformatRule[] = [
+ {
+ match: "# ",
+ mode: "block",
+ type: KEYS.h1,
+ },
+ {
+ match: "## ",
+ mode: "block",
+ type: KEYS.h2,
+ },
+ {
+ match: "### ",
+ mode: "block",
+ type: KEYS.h3,
+ },
+ {
+ match: "#### ",
+ mode: "block",
+ type: KEYS.h4,
+ },
+ {
+ match: "##### ",
+ mode: "block",
+ type: KEYS.h5,
+ },
+ {
+ match: "###### ",
+ mode: "block",
+ type: KEYS.h6,
+ },
+ {
+ match: "> ",
+ mode: "block",
+ type: KEYS.blockquote,
+ },
+ {
+ match: "```",
+ mode: "block",
+ type: KEYS.codeBlock,
+ format: (editor) => {
+ insertEmptyCodeBlock(editor, {
+ defaultType: KEYS.p,
+ insertNodesOptions: { select: true },
+ });
+ },
+ },
+ {
+ match: "+ ",
+ mode: "block",
+ preFormat: openNextToggles,
+ type: KEYS.toggle,
+ },
+ {
+ match: ["---", "—-", "___ "],
+ mode: "block",
+ type: KEYS.hr,
+ format: (editor) => {
+ editor.tf.setNodes({ type: KEYS.hr });
+ editor.tf.insertNodes({
+ children: [{ text: "" }],
+ type: KEYS.p,
+ });
+ },
+ },
+];
+
+const autoformatLists: AutoformatRule[] = [
+ {
+ match: ["* ", "- "],
+ mode: "block",
+ type: "list",
+ format: (editor) => {
+ toggleList(editor, {
+ listStyleType: KEYS.ul,
+ });
+ },
+ },
+ {
+ match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
+ matchByRegex: true,
+ mode: "block",
+ type: "list",
+ format: (editor, { matchString }) => {
+ toggleList(editor, {
+ listRestartPolite: Number(matchString) || 1,
+ listStyleType: KEYS.ol,
+ });
+ },
+ },
+ {
+ match: ["[] "],
+ mode: "block",
+ type: "list",
+ format: (editor) => {
+ toggleList(editor, {
+ listStyleType: KEYS.listTodo,
+ });
+ editor.tf.setNodes({
+ checked: false,
+ listStyleType: KEYS.listTodo,
+ });
+ },
+ },
+ {
+ match: ["[x] "],
+ mode: "block",
+ type: "list",
+ format: (editor) => {
+ toggleList(editor, {
+ listStyleType: KEYS.listTodo,
+ });
+ editor.tf.setNodes({
+ checked: true,
+ listStyleType: KEYS.listTodo,
+ });
+ },
+ },
+];
+
+export const AutoformatKit = [
+ AutoformatPlugin.configure({
+ options: {
+ enableUndoOnDelete: true,
+ rules: [
+ ...autoformatBlocks,
+ ...autoformatMarks,
+ ...autoformatSmartQuotes,
+ ...autoformatPunctuation,
+ ...autoformatLegal,
+ ...autoformatLegalHtml,
+ ...autoformatArrow,
+ ...autoformatMath,
+ ...autoformatLists,
+ ].map(
+ (rule): AutoformatRule => ({
+ ...rule,
+ query: (editor) =>
+ !editor.api.some({
+ match: { type: editor.getType(KEYS.codeBlock) },
+ }),
+ })
+ ),
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx b/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
new file mode 100644
index 000000000..660648baf
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import {
+ BlockquotePlugin,
+ H1Plugin,
+ H2Plugin,
+ H3Plugin,
+ H4Plugin,
+ H5Plugin,
+ H6Plugin,
+ HorizontalRulePlugin,
+} from "@platejs/basic-nodes/react";
+import { ParagraphPlugin } from "platejs/react";
+
+import { BlockquoteElement } from "@/components/ui/blockquote-node";
+import {
+ H1Element,
+ H2Element,
+ H3Element,
+ H4Element,
+ H5Element,
+ H6Element,
+} from "@/components/ui/heading-node";
+import { HrElement } from "@/components/ui/hr-node";
+import { ParagraphElement } from "@/components/ui/paragraph-node";
+
+export const BasicBlocksKit = [
+ ParagraphPlugin.withComponent(ParagraphElement),
+ H1Plugin.configure({
+ node: {
+ component: H1Element,
+ },
+ rules: {
+ break: { empty: "reset" },
+ },
+ shortcuts: { toggle: { keys: "mod+alt+1" } },
+ }),
+ H2Plugin.configure({
+ node: {
+ component: H2Element,
+ },
+ rules: {
+ break: { empty: "reset" },
+ },
+ shortcuts: { toggle: { keys: "mod+alt+2" } },
+ }),
+ H3Plugin.configure({
+ node: {
+ component: H3Element,
+ },
+ rules: {
+ break: { empty: "reset" },
+ },
+ shortcuts: { toggle: { keys: "mod+alt+3" } },
+ }),
+ H4Plugin.configure({
+ node: {
+ component: H4Element,
+ },
+ rules: {
+ break: { empty: "reset" },
+ },
+ shortcuts: { toggle: { keys: "mod+alt+4" } },
+ }),
+ H5Plugin.configure({
+ node: {
+ component: H5Element,
+ },
+ rules: {
+ break: { empty: "reset" },
+ },
+ }),
+ H6Plugin.configure({
+ node: {
+ component: H6Element,
+ },
+ rules: {
+ break: { empty: "reset" },
+ },
+ }),
+ BlockquotePlugin.configure({
+ node: { component: BlockquoteElement },
+ shortcuts: { toggle: { keys: "mod+shift+period" } },
+ }),
+ HorizontalRulePlugin.withComponent(HrElement),
+];
diff --git a/surfsense_web/components/editor/plugins/basic-marks-kit.tsx b/surfsense_web/components/editor/plugins/basic-marks-kit.tsx
new file mode 100644
index 000000000..308fb9031
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/basic-marks-kit.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import {
+ BoldPlugin,
+ CodePlugin,
+ HighlightPlugin,
+ ItalicPlugin,
+ StrikethroughPlugin,
+ SubscriptPlugin,
+ SuperscriptPlugin,
+ UnderlinePlugin,
+} from "@platejs/basic-nodes/react";
+
+import { CodeLeaf } from "@/components/ui/code-node";
+import { HighlightLeaf } from "@/components/ui/highlight-node";
+
+export const BasicMarksKit = [
+ BoldPlugin,
+ ItalicPlugin,
+ UnderlinePlugin,
+ CodePlugin.configure({
+ node: { component: CodeLeaf },
+ shortcuts: { toggle: { keys: "mod+e" } },
+ }),
+ StrikethroughPlugin.configure({
+ shortcuts: { toggle: { keys: "mod+shift+x" } },
+ }),
+ SubscriptPlugin.configure({
+ shortcuts: { toggle: { keys: "mod+comma" } },
+ }),
+ SuperscriptPlugin.configure({
+ shortcuts: { toggle: { keys: "mod+period" } },
+ }),
+ HighlightPlugin.configure({
+ node: { component: HighlightLeaf },
+ shortcuts: { toggle: { keys: "mod+shift+h" } },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx b/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx
new file mode 100644
index 000000000..6f61868ed
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx
@@ -0,0 +1,6 @@
+"use client";
+
+import { BasicBlocksKit } from "./basic-blocks-kit";
+import { BasicMarksKit } from "./basic-marks-kit";
+
+export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];
diff --git a/surfsense_web/components/editor/plugins/callout-kit.tsx b/surfsense_web/components/editor/plugins/callout-kit.tsx
new file mode 100644
index 000000000..7c3b8b188
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/callout-kit.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { CalloutPlugin } from "@platejs/callout/react";
+
+import { CalloutElement } from "@/components/ui/callout-node";
+
+export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)];
diff --git a/surfsense_web/components/editor/plugins/code-block-kit.tsx b/surfsense_web/components/editor/plugins/code-block-kit.tsx
new file mode 100644
index 000000000..95b60c073
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/code-block-kit.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from "@platejs/code-block/react";
+import { all, createLowlight } from "lowlight";
+
+import { CodeBlockElement, CodeLineElement, CodeSyntaxLeaf } from "@/components/ui/code-block-node";
+
+const lowlight = createLowlight(all);
+
+export const CodeBlockKit = [
+ CodeBlockPlugin.configure({
+ node: { component: CodeBlockElement },
+ options: { lowlight },
+ shortcuts: { toggle: { keys: "mod+alt+8" } },
+ }),
+ CodeLinePlugin.withComponent(CodeLineElement),
+ CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
+];
diff --git a/surfsense_web/components/editor/plugins/dnd-kit.tsx b/surfsense_web/components/editor/plugins/dnd-kit.tsx
new file mode 100644
index 000000000..0d02c855e
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/dnd-kit.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { DndProvider } from "react-dnd";
+import { HTML5Backend } from "react-dnd-html5-backend";
+
+import { DndPlugin } from "@platejs/dnd";
+
+import { BlockDraggable } from "@/components/ui/block-draggable";
+
+export const DndKit = [
+ DndPlugin.configure({
+ options: {
+ enableScroller: true,
+ },
+ render: {
+ aboveNodes: BlockDraggable,
+ aboveSlate: ({ children }) => {children},
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx b/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx
new file mode 100644
index 000000000..85e0a08f2
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { createPlatePlugin } from "platejs/react";
+
+import { FixedToolbar } from "@/components/ui/fixed-toolbar";
+import { FixedToolbarButtons } from "@/components/ui/fixed-toolbar-buttons";
+
+export const FixedToolbarKit = [
+ createPlatePlugin({
+ key: "fixed-toolbar",
+ render: {
+ beforeEditable: () => (
+
+
+
+ ),
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx b/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx
new file mode 100644
index 000000000..e0a73e3c2
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { createPlatePlugin } from "platejs/react";
+
+import { FloatingToolbar } from "@/components/ui/floating-toolbar";
+import { FloatingToolbarButtons } from "@/components/ui/floating-toolbar-buttons";
+
+export const FloatingToolbarKit = [
+ createPlatePlugin({
+ key: "floating-toolbar",
+ render: {
+ afterEditable: () => (
+
+
+
+ ),
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/indent-kit.tsx b/surfsense_web/components/editor/plugins/indent-kit.tsx
new file mode 100644
index 000000000..0a86be4ad
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/indent-kit.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { IndentPlugin } from "@platejs/indent/react";
+import { KEYS } from "platejs";
+
+export const IndentKit = [
+ IndentPlugin.configure({
+ inject: {
+ targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/link-kit.tsx b/surfsense_web/components/editor/plugins/link-kit.tsx
new file mode 100644
index 000000000..62e18a60d
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/link-kit.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { LinkPlugin } from "@platejs/link/react";
+
+import { LinkElement } from "@/components/ui/link-node";
+import { LinkFloatingToolbar } from "@/components/ui/link-toolbar";
+
+export const LinkKit = [
+ LinkPlugin.configure({
+ render: {
+ node: LinkElement,
+ afterEditable: () => ,
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/list-kit.tsx b/surfsense_web/components/editor/plugins/list-kit.tsx
new file mode 100644
index 000000000..40b31bbf1
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/list-kit.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { ListPlugin } from "@platejs/list/react";
+import { KEYS } from "platejs";
+
+import { IndentKit } from "@/components/editor/plugins/indent-kit";
+import { BlockList } from "@/components/ui/block-list";
+
+export const ListKit = [
+ ...IndentKit,
+ ListPlugin.configure({
+ inject: {
+ targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
+ },
+ render: {
+ belowNodes: BlockList,
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/math-kit.tsx b/surfsense_web/components/editor/plugins/math-kit.tsx
new file mode 100644
index 000000000..9f31df374
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/math-kit.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { EquationPlugin, InlineEquationPlugin } from "@platejs/math/react";
+
+import { EquationElement, InlineEquationElement } from "@/components/ui/equation-node";
+
+export const MathKit = [
+ EquationPlugin.withComponent(EquationElement),
+ InlineEquationPlugin.withComponent(InlineEquationElement),
+];
diff --git a/surfsense_web/components/editor/plugins/selection-kit.tsx b/surfsense_web/components/editor/plugins/selection-kit.tsx
new file mode 100644
index 000000000..824426b23
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/selection-kit.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { BlockSelectionPlugin } from "@platejs/selection/react";
+
+import { BlockSelection } from "@/components/ui/block-selection";
+
+export const SelectionKit = [
+ BlockSelectionPlugin.configure({
+ render: {
+ belowRootNodes: BlockSelection as any,
+ },
+ options: {
+ isSelectable: (element) => {
+ // Exclude specific block types from selection
+ if (["code_line", "td", "th"].includes(element.type as string)) {
+ return false;
+ }
+
+ return true;
+ },
+ },
+ }),
+];
diff --git a/surfsense_web/components/editor/plugins/slash-command-kit.tsx b/surfsense_web/components/editor/plugins/slash-command-kit.tsx
new file mode 100644
index 000000000..ba07c6182
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/slash-command-kit.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { SlashInputPlugin, SlashPlugin } from "@platejs/slash-command/react";
+import { KEYS } from "platejs";
+
+import { SlashInputElement } from "@/components/ui/slash-node";
+
+export const SlashCommandKit = [
+ SlashPlugin.configure({
+ options: {
+ trigger: "/",
+ triggerPreviousCharPattern: /^\s?$/,
+ triggerQuery: (editor) =>
+ !editor.api.some({
+ match: { type: editor.getType(KEYS.codeBlock) },
+ }),
+ },
+ }),
+ SlashInputPlugin.withComponent(SlashInputElement),
+];
diff --git a/surfsense_web/components/editor/plugins/table-kit.tsx b/surfsense_web/components/editor/plugins/table-kit.tsx
new file mode 100644
index 000000000..ad111358b
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/table-kit.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import {
+ TableCellHeaderPlugin,
+ TableCellPlugin,
+ TablePlugin,
+ TableRowPlugin,
+} from "@platejs/table/react";
+
+import {
+ TableCellElement,
+ TableCellHeaderElement,
+ TableElement,
+ TableRowElement,
+} from "@/components/ui/table-node";
+
+export const TableKit = [
+ TablePlugin.withComponent(TableElement),
+ TableRowPlugin.withComponent(TableRowElement),
+ TableCellPlugin.withComponent(TableCellElement),
+ TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
+];
diff --git a/surfsense_web/components/editor/plugins/toggle-kit.tsx b/surfsense_web/components/editor/plugins/toggle-kit.tsx
new file mode 100644
index 000000000..60f71724c
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/toggle-kit.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { TogglePlugin } from "@platejs/toggle/react";
+
+import { ToggleElement } from "@/components/ui/toggle-node";
+
+export const ToggleKit = [
+ TogglePlugin.configure({
+ node: { component: ToggleElement },
+ shortcuts: { toggle: { keys: "mod+alt+9" } },
+ }),
+];
diff --git a/surfsense_web/components/editor/presets.ts b/surfsense_web/components/editor/presets.ts
new file mode 100644
index 000000000..7800f7c7d
--- /dev/null
+++ b/surfsense_web/components/editor/presets.ts
@@ -0,0 +1,79 @@
+"use client";
+
+import type { AnyPluginConfig } from "platejs";
+
+import { AutoformatKit } from "@/components/editor/plugins/autoformat-kit";
+import { BasicNodesKit } from "@/components/editor/plugins/basic-nodes-kit";
+import { CalloutKit } from "@/components/editor/plugins/callout-kit";
+import { CodeBlockKit } from "@/components/editor/plugins/code-block-kit";
+import { DndKit } from "@/components/editor/plugins/dnd-kit";
+import { FixedToolbarKit } from "@/components/editor/plugins/fixed-toolbar-kit";
+import { FloatingToolbarKit } from "@/components/editor/plugins/floating-toolbar-kit";
+import { LinkKit } from "@/components/editor/plugins/link-kit";
+import { ListKit } from "@/components/editor/plugins/list-kit";
+import { MathKit } from "@/components/editor/plugins/math-kit";
+import { SelectionKit } from "@/components/editor/plugins/selection-kit";
+import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
+import { TableKit } from "@/components/editor/plugins/table-kit";
+import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
+
+/**
+ * Full preset – every plugin kit enabled.
+ * Used by the Documents editor and Reports editor (rich editing experience).
+ */
+export const fullPreset: AnyPluginConfig[] = [
+ ...BasicNodesKit,
+ ...TableKit,
+ ...ListKit,
+ ...CodeBlockKit,
+ ...LinkKit,
+ ...CalloutKit,
+ ...ToggleKit,
+ ...MathKit,
+ ...SelectionKit,
+ ...SlashCommandKit,
+ ...FixedToolbarKit,
+ ...FloatingToolbarKit,
+ ...AutoformatKit,
+ ...DndKit,
+];
+
+/**
+ * Minimal preset – lightweight editing with core formatting only.
+ * No fixed toolbar, no slash commands, no DnD, no block selection.
+ * Ideal for inline editors like human-in-the-loop agent actions.
+ */
+export const minimalPreset: AnyPluginConfig[] = [
+ ...BasicNodesKit,
+ ...ListKit,
+ ...CodeBlockKit,
+ ...LinkKit,
+ ...FloatingToolbarKit,
+ ...AutoformatKit,
+];
+
+/**
+ * Read-only preset – rendering support for all rich content, but no editing UI.
+ * No toolbars, no autoformat, no DnD, no slash commands, no block selection.
+ * Ideal for pure display / viewer contexts.
+ */
+export const readonlyPreset: AnyPluginConfig[] = [
+ ...BasicNodesKit,
+ ...TableKit,
+ ...ListKit,
+ ...CodeBlockKit,
+ ...LinkKit,
+ ...CalloutKit,
+ ...ToggleKit,
+ ...MathKit,
+];
+
+/** All available preset names */
+export type EditorPreset = "full" | "minimal" | "readonly";
+
+/** Map from preset name to plugin array */
+export const presetMap: Record = {
+ full: fullPreset,
+ minimal: minimalPreset,
+ readonly: readonlyPreset,
+};
diff --git a/surfsense_web/components/editor/transforms.ts b/surfsense_web/components/editor/transforms.ts
new file mode 100644
index 000000000..5f74c9673
--- /dev/null
+++ b/surfsense_web/components/editor/transforms.ts
@@ -0,0 +1,160 @@
+"use client";
+
+import type { PlateEditor } from "platejs/react";
+
+import { insertCallout } from "@platejs/callout";
+import { insertCodeBlock, toggleCodeBlock } from "@platejs/code-block";
+import { triggerFloatingLink } from "@platejs/link/react";
+import { insertInlineEquation } from "@platejs/math";
+import { TablePlugin } from "@platejs/table/react";
+import { type NodeEntry, type Path, type TElement, KEYS, PathApi } from "platejs";
+
+const insertList = (editor: PlateEditor, type: string) => {
+ editor.tf.insertNodes(
+ editor.api.create.block({
+ indent: 1,
+ listStyleType: type,
+ }),
+ { select: true }
+ );
+};
+
+const insertBlockMap: Record void> = {
+ [KEYS.listTodo]: insertList,
+ [KEYS.ol]: insertList,
+ [KEYS.ul]: insertList,
+ [KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),
+ [KEYS.table]: (editor) => editor.getTransforms(TablePlugin).insert.table({}, { select: true }),
+ [KEYS.callout]: (editor) => insertCallout(editor, { select: true }),
+ [KEYS.toggle]: (editor) => {
+ editor.tf.insertNodes(editor.api.create.block({ type: KEYS.toggle }), { select: true });
+ },
+};
+
+const insertInlineMap: Record void> = {
+ [KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),
+ [KEYS.equation]: (editor) => insertInlineEquation(editor),
+};
+
+type InsertBlockOptions = {
+ upsert?: boolean;
+};
+
+export const insertBlock = (
+ editor: PlateEditor,
+ type: string,
+ options: InsertBlockOptions = {}
+) => {
+ const { upsert = false } = options;
+
+ editor.tf.withoutNormalizing(() => {
+ const block = editor.api.block();
+
+ if (!block) return;
+
+ const [currentNode, path] = block;
+ const isCurrentBlockEmpty = editor.api.isEmpty(currentNode);
+ const currentBlockType = getBlockType(currentNode);
+
+ const isSameBlockType = type === currentBlockType;
+
+ if (upsert && isCurrentBlockEmpty && isSameBlockType) {
+ return;
+ }
+
+ if (type in insertBlockMap) {
+ insertBlockMap[type](editor, type);
+ } else {
+ editor.tf.insertNodes(editor.api.create.block({ type }), {
+ at: PathApi.next(path),
+ select: true,
+ });
+ }
+
+ if (!isSameBlockType) {
+ editor.tf.removeNodes({ previousEmptyBlock: true });
+ }
+ });
+};
+
+export const insertInlineElement = (editor: PlateEditor, type: string) => {
+ if (insertInlineMap[type]) {
+ insertInlineMap[type](editor, type);
+ }
+};
+
+const setList = (editor: PlateEditor, type: string, entry: NodeEntry) => {
+ editor.tf.setNodes(
+ editor.api.create.block({
+ indent: 1,
+ listStyleType: type,
+ }),
+ {
+ at: entry[1],
+ }
+ );
+};
+
+const setBlockMap: Record<
+ string,
+ (editor: PlateEditor, type: string, entry: NodeEntry) => void
+> = {
+ [KEYS.listTodo]: setList,
+ [KEYS.ol]: setList,
+ [KEYS.ul]: setList,
+ [KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),
+ [KEYS.callout]: (editor, _type, entry) => {
+ editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] });
+ },
+ [KEYS.toggle]: (editor, _type, entry) => {
+ editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] });
+ },
+};
+
+export const setBlockType = (editor: PlateEditor, type: string, { at }: { at?: Path } = {}) => {
+ editor.tf.withoutNormalizing(() => {
+ const setEntry = (entry: NodeEntry) => {
+ const [node, path] = entry;
+
+ if (node[KEYS.listType]) {
+ editor.tf.unsetNodes([KEYS.listType, "indent"], { at: path });
+ }
+ if (type in setBlockMap) {
+ return setBlockMap[type](editor, type, entry);
+ }
+ if (node.type !== type) {
+ editor.tf.setNodes({ type }, { at: path });
+ }
+ };
+
+ if (at) {
+ const entry = editor.api.node(at);
+
+ if (entry) {
+ setEntry(entry);
+
+ return;
+ }
+ }
+
+ const entries = editor.api.blocks({ mode: "lowest" });
+
+ entries.forEach((entry) => {
+ setEntry(entry);
+ });
+ });
+};
+
+export const getBlockType = (block: TElement) => {
+ if (block[KEYS.listType]) {
+ if (block[KEYS.listType] === KEYS.ol) {
+ return KEYS.ol;
+ }
+ if (block[KEYS.listType] === KEYS.listTodo) {
+ return KEYS.listTodo;
+ }
+ return KEYS.ul;
+ }
+
+ return block.type;
+};
diff --git a/surfsense_web/components/editor/utils/escape-mdx.ts b/surfsense_web/components/editor/utils/escape-mdx.ts
new file mode 100644
index 000000000..41e8c9d0a
--- /dev/null
+++ b/surfsense_web/components/editor/utils/escape-mdx.ts
@@ -0,0 +1,25 @@
+// ---------------------------------------------------------------------------
+// MDX curly-brace escaping helper
+// ---------------------------------------------------------------------------
+// remarkMdx treats { } as JSX expression delimiters. Arbitrary markdown
+// (e.g. AI-generated reports) can contain curly braces that are NOT valid JS
+// expressions, which makes acorn throw "Could not parse expression".
+// We escape unescaped { and } *outside* of fenced code blocks and inline code
+// so remarkMdx treats them as literal characters while still parsing
+// , , , etc. tags correctly.
+// ---------------------------------------------------------------------------
+
+const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
+
+export function escapeMdxExpressions(md: string): string {
+ const parts = md.split(FENCED_OR_INLINE_CODE);
+
+ return parts
+ .map((part, i) => {
+ // Odd indices are code blocks / inline code – leave untouched
+ if (i % 2 === 1) return part;
+ // Escape { and } that are NOT already escaped (no preceding \)
+ return part.replace(/(? {
const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
+ if (event.key === "\\" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleCollapsed();
}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx
index 44f05249c..3985e93e0 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx
@@ -4,6 +4,7 @@ import { PanelLeft, PanelLeftClose } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
interface SidebarCollapseButtonProps {
isCollapsed: boolean;
@@ -17,6 +18,7 @@ export function SidebarCollapseButton({
disableTooltip = false,
}: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar");
+ const { shortcut } = usePlatformShortcut();
const button = (
- {/* Report content — skeleton/error/content shown only in this area */}
-
+ {/* Report content — skeleton/error/viewer/editor shown only in this area */}
+
{isLoading ? (
) : error || !reportContent ? (
@@ -381,13 +431,27 @@ function ReportPanelContent({
{error || "An unknown error occurred"}