diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx
index 2cbc04ba9..3313a27f0 100644
--- a/surfsense_web/components/editor/plate-editor.tsx
+++ b/surfsense_web/components/editor/plate-editor.tsx
@@ -6,15 +6,15 @@ import { Plate, usePlateEditor } from 'platejs/react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
-import { AutoformatKit } from '@/components/editor/plugins/autoformat-classic-kit';
+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 { IndentKit } from '@/components/editor/plugins/indent-kit';
import { LinkKit } from '@/components/editor/plugins/link-kit';
-import { ListKit } from '@/components/editor/plugins/list-classic-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';
@@ -59,13 +59,13 @@ export function PlateEditor({
...LinkKit,
...CalloutKit,
...ToggleKit,
- ...IndentKit,
...MathKit,
...SelectionKit,
...SlashCommandKit,
...FixedToolbarKit,
...FloatingToolbarKit,
...AutoformatKit,
+ ...DndKit,
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath],
diff --git a/surfsense_web/components/editor/plugins/autoformat-classic-kit.tsx b/surfsense_web/components/editor/plugins/autoformat-kit.tsx
similarity index 65%
rename from surfsense_web/components/editor/plugins/autoformat-classic-kit.tsx
rename to surfsense_web/components/editor/plugins/autoformat-kit.tsx
index 23bbac734..60302a1c5 100644
--- a/surfsense_web/components/editor/plugins/autoformat-classic-kit.tsx
+++ b/surfsense_web/components/editor/plugins/autoformat-kit.tsx
@@ -1,7 +1,6 @@
'use client';
-import type { AutoformatBlockRule, AutoformatRule } from '@platejs/autoformat';
-import type { SlateEditor } from 'platejs';
+import type { AutoformatRule } from '@platejs/autoformat';
import {
autoformatArrow,
@@ -13,38 +12,9 @@ import {
autoformatSmartQuotes,
} from '@platejs/autoformat';
import { insertEmptyCodeBlock } from '@platejs/code-block';
-import { toggleList, toggleTaskList, unwrapList } from '@platejs/list-classic';
+import { toggleList } from '@platejs/list';
import { openNextToggles } from '@platejs/toggle/react';
-import { ElementApi, isType, KEYS } from 'platejs';
-
-const preFormat: AutoformatBlockRule['preFormat'] = (editor) =>
- unwrapList(editor);
-
-const format = (editor: SlateEditor, customFormatting: any) => {
- if (editor.selection) {
- const parentEntry = editor.api.parent(editor.selection);
-
- if (!parentEntry) return;
-
- const [node] = parentEntry;
-
- if (ElementApi.isElement(node) && !isType(editor, node, KEYS.codeBlock)) {
- customFormatting();
- }
- }
-};
-
-const formatTaskList = (editor: SlateEditor, defaultChecked = false) => {
- format(editor, () => toggleTaskList(editor, defaultChecked));
-};
-
-const formatList = (editor: SlateEditor, elementType: string) => {
- format(editor, () =>
- toggleList(editor, {
- type: elementType,
- })
- );
-};
+import { KEYS } from 'platejs';
const autoformatMarks: AutoformatRule[] = [
{
@@ -123,49 +93,41 @@ const autoformatBlocks: AutoformatRule[] = [
{
match: '# ',
mode: 'block',
- preFormat,
type: KEYS.h1,
},
{
match: '## ',
mode: 'block',
- preFormat,
type: KEYS.h2,
},
{
match: '### ',
mode: 'block',
- preFormat,
type: KEYS.h3,
},
{
match: '#### ',
mode: 'block',
- preFormat,
type: KEYS.h4,
},
{
match: '##### ',
mode: 'block',
- preFormat,
type: KEYS.h5,
},
{
match: '###### ',
mode: 'block',
- preFormat,
type: KEYS.h6,
},
{
match: '> ',
mode: 'block',
- preFormat,
type: KEYS.blockquote,
},
{
match: '```',
mode: 'block',
- preFormat,
type: KEYS.codeBlock,
format: (editor) => {
insertEmptyCodeBlock(editor, {
@@ -198,29 +160,52 @@ const autoformatLists: AutoformatRule[] = [
{
match: ['* ', '- '],
mode: 'block',
- preFormat,
- type: KEYS.li,
- format: (editor) => formatList(editor, KEYS.ulClassic),
+ type: 'list',
+ format: (editor) => {
+ toggleList(editor, {
+ listStyleType: KEYS.ul,
+ });
+ },
},
{
match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
matchByRegex: true,
mode: 'block',
- preFormat,
- type: KEYS.li,
- format: (editor) => formatList(editor, KEYS.olClassic),
+ type: 'list',
+ format: (editor, { matchString }) => {
+ toggleList(editor, {
+ listRestartPolite: Number(matchString) || 1,
+ listStyleType: KEYS.ol,
+ });
+ },
},
{
- match: '[] ',
+ match: ['[] '],
mode: 'block',
- type: KEYS.taskList,
- format: (editor) => formatTaskList(editor, false),
+ type: 'list',
+ format: (editor) => {
+ toggleList(editor, {
+ listStyleType: KEYS.listTodo,
+ });
+ editor.tf.setNodes({
+ checked: false,
+ listStyleType: KEYS.listTodo,
+ });
+ },
},
{
- match: '[x] ',
+ match: ['[x] '],
mode: 'block',
- type: KEYS.taskList,
- format: (editor) => formatTaskList(editor, true),
+ type: 'list',
+ format: (editor) => {
+ toggleList(editor, {
+ listStyleType: KEYS.listTodo,
+ });
+ editor.tf.setNodes({
+ checked: true,
+ listStyleType: KEYS.listTodo,
+ });
+ },
},
];
@@ -238,13 +223,16 @@ export const AutoformatKit = [
...autoformatArrow,
...autoformatMath,
...autoformatLists,
- ].map((rule) => ({
- ...rule,
- query: (editor) =>
- !editor.api.some({
- match: { type: editor.getType(KEYS.codeBlock) },
- }),
- })),
+ ].map(
+ (rule): AutoformatRule => ({
+ ...rule,
+ query: (editor) =>
+ !editor.api.some({
+ match: { type: editor.getType(KEYS.codeBlock) },
+ }),
+ })
+ ),
},
}),
];
+
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..89f1ba31c
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/dnd-kit.tsx
@@ -0,0 +1,23 @@
+'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/list-classic-kit.tsx b/surfsense_web/components/editor/plugins/list-classic-kit.tsx
deleted file mode 100644
index 4502ceb6f..000000000
--- a/surfsense_web/components/editor/plugins/list-classic-kit.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-'use client';
-
-import {
- BulletedListPlugin,
- ListItemContentPlugin,
- ListItemPlugin,
- ListPlugin,
- NumberedListPlugin,
- TaskListPlugin,
-} from '@platejs/list-classic/react';
-
-import {
- BulletedListElement,
- ListItemElement,
- NumberedListElement,
- TaskListElement,
-} from '@/components/ui/list-classic-node';
-
-export const ListKit = [
- ListPlugin,
- ListItemPlugin,
- ListItemContentPlugin,
- BulletedListPlugin.configure({
- node: { component: BulletedListElement },
- shortcuts: { toggle: { keys: 'mod+alt+5' } },
- }),
- NumberedListPlugin.configure({
- node: { component: NumberedListElement },
- shortcuts: { toggle: { keys: 'mod+alt+6' } },
- }),
- TaskListPlugin.configure({
- node: { component: TaskListElement },
- shortcuts: { toggle: { keys: 'mod+alt+7' } },
- }),
- ListItemPlugin.withComponent(ListItemElement),
-];
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..559bb3a98
--- /dev/null
+++ b/surfsense_web/components/editor/plugins/list-kit.tsx
@@ -0,0 +1,26 @@
+'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/ui/block-draggable.tsx b/surfsense_web/components/ui/block-draggable.tsx
new file mode 100644
index 000000000..f148e38c8
--- /dev/null
+++ b/surfsense_web/components/ui/block-draggable.tsx
@@ -0,0 +1,513 @@
+'use client';
+
+import * as React from 'react';
+
+import { DndPlugin, useDraggable, useDropLine } from '@platejs/dnd';
+import { expandListItemsWithChildren } from '@platejs/list';
+import { BlockSelectionPlugin } from '@platejs/selection/react';
+import { GripVertical } from 'lucide-react';
+import { type TElement, getPluginByType, isType, KEYS } from 'platejs';
+import {
+ type PlateEditor,
+ type PlateElementProps,
+ type RenderNodeWrapper,
+ MemoizedChildren,
+ useEditorRef,
+ useElement,
+ usePluginOption,
+} from 'platejs/react';
+import { useSelected } from 'platejs/react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+
+const UNDRAGGABLE_KEYS = [KEYS.column, KEYS.tr, KEYS.td];
+
+export const BlockDraggable: RenderNodeWrapper = (props) => {
+ const { editor, element, path } = props;
+
+ const enabled = React.useMemo(() => {
+ if (editor.dom.readOnly) return false;
+
+ if (path.length === 1 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
+ return true;
+ }
+ if (path.length === 3 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
+ const block = editor.api.some({
+ at: path,
+ match: {
+ type: editor.getType(KEYS.column),
+ },
+ });
+
+ if (block) {
+ return true;
+ }
+ }
+ if (path.length === 4 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
+ const block = editor.api.some({
+ at: path,
+ match: {
+ type: editor.getType(KEYS.table),
+ },
+ });
+
+ if (block) {
+ return true;
+ }
+ }
+
+ return false;
+ }, [editor, element, path]);
+
+ if (!enabled) return;
+
+ return (props) => ;
+};
+
+function Draggable(props: PlateElementProps) {
+ const { children, editor, element, path } = props;
+ const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
+
+ const { isAboutToDrag, isDragging, nodeRef, previewRef, handleRef } =
+ useDraggable({
+ element,
+ onDropHandler: (_, { dragItem }) => {
+ const id = (dragItem as { id: string[] | string }).id;
+
+ if (blockSelectionApi) {
+ blockSelectionApi.add(id);
+ }
+ resetPreview();
+ },
+ });
+
+ const isInColumn = path.length === 3;
+ const isInTable = path.length === 4;
+
+ const [previewTop, setPreviewTop] = React.useState(0);
+
+ const resetPreview = () => {
+ if (previewRef.current) {
+ previewRef.current.replaceChildren();
+ previewRef.current?.classList.add('hidden');
+ }
+ };
+
+ // clear up virtual multiple preview when drag end
+ React.useEffect(() => {
+ if (!isDragging) {
+ resetPreview();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isDragging]);
+
+ React.useEffect(() => {
+ if (isAboutToDrag) {
+ previewRef.current?.classList.remove('opacity-0');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isAboutToDrag]);
+
+ const [dragButtonTop, setDragButtonTop] = React.useState(0);
+
+ return (
+
{
+ if (isDragging) return;
+ setDragButtonTop(calcDragButtonTop(editor, element));
+ }}
+ >
+ {!isInTable && (
+
+
+
+ )}
+
+
+
+
+ editor
+ .getApi(BlockSelectionPlugin)
+ .blockSelection.addOnContextMenu({ element, event })
+ }
+ >
+ {children}
+
+
+
+ );
+}
+
+function Gutter({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ const editor = useEditorRef();
+ const element = useElement();
+ const isSelectionAreaVisible = usePluginOption(
+ BlockSelectionPlugin,
+ 'isSelectionAreaVisible'
+ );
+ const selected = useSelected();
+
+ return (
+
+ {children}
+
+ );
+}
+
+const DragHandle = React.memo(function DragHandle({
+ isDragging,
+ previewRef,
+ resetPreview,
+ setPreviewTop,
+}: {
+ isDragging: boolean;
+ previewRef: React.RefObject;
+ resetPreview: () => void;
+ setPreviewTop: (top: number) => void;
+}) {
+ const editor = useEditorRef();
+ const element = useElement();
+
+ return (
+
+
+ {
+ e.preventDefault();
+ editor.getApi(BlockSelectionPlugin).blockSelection.focus();
+ }}
+ onMouseDown={(e) => {
+ resetPreview();
+
+ if ((e.button !== 0 && e.button !== 2) || e.shiftKey) return;
+
+ const blockSelection = editor
+ .getApi(BlockSelectionPlugin)
+ .blockSelection.getNodes({ sort: true });
+
+ let selectionNodes =
+ blockSelection.length > 0
+ ? blockSelection
+ : editor.api.blocks({ mode: 'highest' });
+
+ // If current block is not in selection, use it as the starting point
+ if (!selectionNodes.some(([node]) => node.id === element.id)) {
+ selectionNodes = [[element, editor.api.findPath(element)!]];
+ }
+
+ // Process selection nodes to include list children
+ const blocks = expandListItemsWithChildren(
+ editor,
+ selectionNodes
+ ).map(([node]) => node);
+
+ if (blockSelection.length === 0) {
+ editor.tf.blur();
+ editor.tf.collapse();
+ }
+
+ const elements = createDragPreviewElements(editor, blocks);
+ previewRef.current?.append(...elements);
+ previewRef.current?.classList.remove('hidden');
+ previewRef.current?.classList.add('opacity-0');
+ editor.setOption(DndPlugin, 'multiplePreviewRef', previewRef);
+
+ editor
+ .getApi(BlockSelectionPlugin)
+ .blockSelection.set(blocks.map((block) => block.id as string));
+ }}
+ onMouseEnter={() => {
+ if (isDragging) return;
+
+ const blockSelection = editor
+ .getApi(BlockSelectionPlugin)
+ .blockSelection.getNodes({ sort: true });
+
+ let selectedBlocks =
+ blockSelection.length > 0
+ ? blockSelection
+ : editor.api.blocks({ mode: 'highest' });
+
+ // If current block is not in selection, use it as the starting point
+ if (!selectedBlocks.some(([node]) => node.id === element.id)) {
+ selectedBlocks = [[element, editor.api.findPath(element)!]];
+ }
+
+ // Process selection to include list children
+ const processedBlocks = expandListItemsWithChildren(
+ editor,
+ selectedBlocks
+ );
+
+ const ids = processedBlocks.map((block) => block[0].id as string);
+
+ if (ids.length > 1 && ids.includes(element.id as string)) {
+ const previewTop = calculatePreviewTop(editor, {
+ blocks: processedBlocks.map((block) => block[0]),
+ element,
+ });
+ setPreviewTop(previewTop);
+ } else {
+ setPreviewTop(0);
+ }
+ }}
+ onMouseUp={() => {
+ resetPreview();
+ }}
+ data-plate-prevent-deselect
+ role="button"
+ >
+
+
+
+
+ );
+});
+
+const DropLine = React.memo(function DropLine({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ const { dropLine } = useDropLine();
+
+ if (!dropLine) return null;
+
+ return (
+
+ );
+});
+
+const createDragPreviewElements = (
+ editor: PlateEditor,
+ blocks: TElement[]
+): HTMLElement[] => {
+ const elements: HTMLElement[] = [];
+ const ids: string[] = [];
+
+ /**
+ * Remove data attributes from the element to avoid recognized as slate
+ * elements incorrectly.
+ */
+ const removeDataAttributes = (element: HTMLElement) => {
+ Array.from(element.attributes).forEach((attr) => {
+ if (
+ attr.name.startsWith('data-slate') ||
+ attr.name.startsWith('data-block-id')
+ ) {
+ element.removeAttribute(attr.name);
+ }
+ });
+
+ Array.from(element.children).forEach((child) => {
+ removeDataAttributes(child as HTMLElement);
+ });
+ };
+
+ const resolveElement = (node: TElement, index: number) => {
+ const domNode = editor.api.toDOMNode(node)!;
+ const newDomNode = domNode.cloneNode(true) as HTMLElement;
+
+ // Apply visual compensation for horizontal scroll
+ const applyScrollCompensation = (
+ original: Element,
+ cloned: HTMLElement
+ ) => {
+ const scrollLeft = original.scrollLeft;
+
+ if (scrollLeft > 0) {
+ // Create a wrapper to handle the scroll offset
+ const scrollWrapper = document.createElement('div');
+ scrollWrapper.style.overflow = 'hidden';
+ scrollWrapper.style.width = `${original.clientWidth}px`;
+
+ // Create inner container with the full content
+ const innerContainer = document.createElement('div');
+ innerContainer.style.transform = `translateX(-${scrollLeft}px)`;
+ innerContainer.style.width = `${original.scrollWidth}px`;
+
+ // Move all children to the inner container
+ while (cloned.firstChild) {
+ innerContainer.append(cloned.firstChild);
+ }
+
+ // Apply the original element's styles to maintain appearance
+ const originalStyles = window.getComputedStyle(original);
+ cloned.style.padding = '0';
+ innerContainer.style.padding = originalStyles.padding;
+
+ scrollWrapper.append(innerContainer);
+ cloned.append(scrollWrapper);
+ }
+ };
+
+ applyScrollCompensation(domNode, newDomNode);
+
+ ids.push(node.id as string);
+ const wrapper = document.createElement('div');
+ wrapper.append(newDomNode);
+ wrapper.style.display = 'flow-root';
+
+ const lastDomNode = blocks[index - 1];
+
+ if (lastDomNode) {
+ const lastDomNodeRect = editor.api
+ .toDOMNode(lastDomNode)!
+ .parentElement!.getBoundingClientRect();
+
+ const domNodeRect = domNode.parentElement!.getBoundingClientRect();
+
+ const distance = domNodeRect.top - lastDomNodeRect.bottom;
+
+ // Check if the two elements are adjacent (touching each other)
+ if (distance > 15) {
+ wrapper.style.marginTop = `${distance}px`;
+ }
+ }
+
+ removeDataAttributes(newDomNode);
+ elements.push(wrapper);
+ };
+
+ blocks.forEach((node, index) => {
+ resolveElement(node, index);
+ });
+
+ editor.setOption(DndPlugin, 'draggingId', ids);
+
+ return elements;
+};
+
+const calculatePreviewTop = (
+ editor: PlateEditor,
+ {
+ blocks,
+ element,
+ }: {
+ blocks: TElement[];
+ element: TElement;
+ }
+): number => {
+ const child = editor.api.toDOMNode(element)!;
+ const editable = editor.api.toDOMNode(editor)!;
+ const firstSelectedChild = blocks[0];
+
+ const firstDomNode = editor.api.toDOMNode(firstSelectedChild)!;
+ // Get editor's top padding
+ const editorPaddingTop = Number(
+ window.getComputedStyle(editable).paddingTop.replace('px', '')
+ );
+
+ // Calculate distance from first selected node to editor top
+ const firstNodeToEditorDistance =
+ firstDomNode.getBoundingClientRect().top -
+ editable.getBoundingClientRect().top -
+ editorPaddingTop;
+
+ // Get margin top of first selected node
+ const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop;
+ const marginTop = Number(firstMarginTopString.replace('px', ''));
+
+ // Calculate distance from current node to editor top
+ const currentToEditorDistance =
+ child.getBoundingClientRect().top -
+ editable.getBoundingClientRect().top -
+ editorPaddingTop;
+
+ const currentMarginTopString = window.getComputedStyle(child).marginTop;
+ const currentMarginTop = Number(currentMarginTopString.replace('px', ''));
+
+ const previewElementsTopDistance =
+ currentToEditorDistance -
+ firstNodeToEditorDistance +
+ marginTop -
+ currentMarginTop;
+
+ return previewElementsTopDistance;
+};
+
+const calcDragButtonTop = (editor: PlateEditor, element: TElement): number => {
+ const child = editor.api.toDOMNode(element)!;
+
+ const currentMarginTopString = window.getComputedStyle(child).marginTop;
+ const currentMarginTop = Number(currentMarginTopString.replace('px', ''));
+
+ return currentMarginTop;
+};
diff --git a/surfsense_web/components/ui/block-list-static.tsx b/surfsense_web/components/ui/block-list-static.tsx
new file mode 100644
index 000000000..4efd7c12b
--- /dev/null
+++ b/surfsense_web/components/ui/block-list-static.tsx
@@ -0,0 +1,85 @@
+import * as React from 'react';
+
+import type { RenderStaticNodeWrapper, TListElement } from 'platejs';
+import type { SlateRenderElementProps } from 'platejs/static';
+
+import { isOrderedList } from '@platejs/list';
+import { CheckIcon } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const config: Record<
+ string,
+ {
+ Li: React.FC;
+ Marker: React.FC;
+ }
+> = {
+ todo: {
+ Li: TodoLiStatic,
+ Marker: TodoMarkerStatic,
+ },
+};
+
+export const BlockListStatic: RenderStaticNodeWrapper = (props) => {
+ if (!props.element.listStyleType) return;
+
+ return (props) =>
;
+};
+
+function List(props: SlateRenderElementProps) {
+ const { indent, listStart, listStyleType } = props.element as TListElement & {
+ indent?: number;
+ };
+ const { Li, Marker } = config[listStyleType] ?? {};
+ const List = isOrderedList(props.element) ? 'ol' : 'ul';
+
+ // Apply margin-left for indent (24px per level) for DOCX export compatibility
+ const marginLeft = indent ? `${indent * 24}px` : undefined;
+
+ return (
+
+ {Marker && }
+ {Li ? : {props.children}}
+
+ );
+}
+
+function TodoMarkerStatic(props: SlateRenderElementProps) {
+ const checked = props.element.checked as boolean;
+
+ return (
+
+
+
+ );
+}
+
+function TodoLiStatic(props: SlateRenderElementProps) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/surfsense_web/components/ui/block-list.tsx b/surfsense_web/components/ui/block-list.tsx
new file mode 100644
index 000000000..6cf966327
--- /dev/null
+++ b/surfsense_web/components/ui/block-list.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import React from 'react';
+
+import type { TListElement } from 'platejs';
+
+import { isOrderedList } from '@platejs/list';
+import {
+ useTodoListElement,
+ useTodoListElementState,
+} from '@platejs/list/react';
+import {
+ type PlateElementProps,
+ type RenderNodeWrapper,
+ useReadOnly,
+} from 'platejs/react';
+
+import { Checkbox } from '@/components/ui/checkbox';
+import { cn } from '@/lib/utils';
+
+const config: Record<
+ string,
+ {
+ Li: React.FC;
+ Marker: React.FC;
+ }
+> = {
+ todo: {
+ Li: TodoLi,
+ Marker: TodoMarker,
+ },
+};
+
+export const BlockList: RenderNodeWrapper = (props) => {
+ if (!props.element.listStyleType) return;
+
+ return (props) =>
;
+};
+
+function List(props: PlateElementProps) {
+ const { listStart, listStyleType } = props.element as TListElement;
+ const { Li, Marker } = config[listStyleType] ?? {};
+ const List = isOrderedList(props.element) ? 'ol' : 'ul';
+
+ return (
+
+ {Marker && }
+ {Li ? : {props.children}}
+
+ );
+}
+
+function TodoMarker(props: PlateElementProps) {
+ const state = useTodoListElementState({ element: props.element });
+ const { checkboxProps } = useTodoListElement(state);
+ const readOnly = useReadOnly();
+
+ return (
+
+
+
+ );
+}
+
+function TodoLi(props: PlateElementProps) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/surfsense_web/components/ui/checkbox.tsx b/surfsense_web/components/ui/checkbox.tsx
index 789a6b68b..a3ec18410 100644
--- a/surfsense_web/components/ui/checkbox.tsx
+++ b/surfsense_web/components/ui/checkbox.tsx
@@ -1,30 +1,32 @@
-"use client";
+"use client"
-import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { CheckIcon, MinusIcon } from "lucide-react";
-import type * as React from "react";
+import * as React from "react"
+import { CheckIcon } from "lucide-react"
+import { Checkbox as CheckboxPrimitive } from "radix-ui"
-import { cn } from "@/lib/utils";
+import { cn } from "@/lib/utils"
-function Checkbox({ className, ...props }: React.ComponentProps) {
- return (
-
-
-
-
-
-
- );
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
}
-export { Checkbox };
+export { Checkbox }
diff --git a/surfsense_web/components/ui/list-classic-node.tsx b/surfsense_web/components/ui/list-classic-node.tsx
deleted file mode 100644
index 2146c6b5d..000000000
--- a/surfsense_web/components/ui/list-classic-node.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-'use client';
-
-import * as React from 'react';
-
-import type { PlateElementProps } from 'platejs/react';
-
-import {
- useTodoListElement,
- useTodoListElementState,
-} from '@platejs/list-classic/react';
-import { type VariantProps, cva } from 'class-variance-authority';
-import { PlateElement } from 'platejs/react';
-
-import { Checkbox } from '@/components/ui/checkbox';
-import { cn } from '@/lib/utils';
-
-const listVariants = cva('m-0 py-1 ps-6', {
- variants: {
- variant: {
- ol: 'list-decimal',
- ul: 'list-disc [&_ul]:list-[circle] [&_ul_ul]:list-[square]',
- },
- },
-});
-
-export function ListElement({
- variant,
- ...props
-}: PlateElementProps & VariantProps) {
- return (
-
- {props.children}
-
- );
-}
-
-export function BulletedListElement(props: PlateElementProps) {
- return ;
-}
-
-export function NumberedListElement(props: PlateElementProps) {
- return ;
-}
-
-export function TaskListElement(props: PlateElementProps) {
- return (
-
- {props.children}
-
- );
-}
-
-export function ListItemElement(props: PlateElementProps) {
- const isTaskList = 'checked' in props.element;
-
- if (isTaskList) {
- return ;
- }
-
- return ;
-}
-
-export function BaseListItemElement(props: PlateElementProps) {
- return (
-
- {props.children}
-
- );
-}
-
-export function TaskListItemElement(props: PlateElementProps) {
- const { element } = props;
- const state = useTodoListElementState({ element });
- const { checkboxProps } = useTodoListElement(state);
- const [firstChild, ...otherChildren] = React.Children.toArray(props.children);
-
- return (
-
-
-
- {otherChildren}
-
- );
-}
diff --git a/surfsense_web/components/ui/slash-node.tsx b/surfsense_web/components/ui/slash-node.tsx
index b04118907..16c371f42 100644
--- a/surfsense_web/components/ui/slash-node.tsx
+++ b/surfsense_web/components/ui/slash-node.tsx
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
+import { createPortal } from 'react-dom';
import type { PlateElementProps } from 'platejs/react';
@@ -8,7 +9,6 @@ import { SlashInputPlugin } from '@platejs/slash-command/react';
import {
ChevronRightIcon,
Code2Icon,
- Columns2Icon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
@@ -30,7 +30,6 @@ import {
Command,
CommandEmpty,
CommandGroup,
- CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
@@ -161,49 +160,71 @@ export function SlashInputElement({
...props
}: PlateElementProps) {
const { editor, setOption } = useEditorPlugin(SlashInputPlugin);
+ const anchorRef = React.useRef(null);
+ const [menuPosition, setMenuPosition] = React.useState<{ top: number; left: number } | null>(null);
+
+ React.useEffect(() => {
+ const updatePosition = () => {
+ if (anchorRef.current) {
+ const rect = anchorRef.current.getBoundingClientRect();
+ setMenuPosition({
+ top: rect.bottom + window.scrollY,
+ left: rect.left + window.scrollX,
+ });
+ }
+ };
+
+ updatePosition();
+
+ // Re-position on scroll/resize since the editor may scroll
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, []);
return (
-
- {
- // The value is managed by the slash input plugin
- }}
- autoFocus
- />
-
- No results found.
- {slashCommandGroups.map(({ heading, items }) => (
-
- {items.map(({ icon, keywords, label, onSelect }) => (
- {
- editor.tf.removeNodes({
- match: (n) => (n as any).type === SlashInputPlugin.key,
- });
- onSelect(editor);
- editor.tf.focus();
- }}
- >
-
- {icon}
-
- {label}
-
+
+ {menuPosition &&
+ createPortal(
+
+
+ No results found.
+ {slashCommandGroups.map(({ heading, items }) => (
+
+ {items.map(({ icon, keywords, label, onSelect }) => (
+ {
+ editor.tf.removeNodes({
+ match: (n) => (n as any).type === SlashInputPlugin.key,
+ });
+ onSelect(editor);
+ editor.tf.focus();
+ }}
+ >
+
+ {icon}
+
+ {label}
+
+ ))}
+
))}
-
- ))}
-
-
+
+ ,
+ document.body
+ )}
{children}
);
diff --git a/surfsense_web/components/ui/tooltip.tsx b/surfsense_web/components/ui/tooltip.tsx
index 356467a8a..d80a14468 100644
--- a/surfsense_web/components/ui/tooltip.tsx
+++ b/surfsense_web/components/ui/tooltip.tsx
@@ -1,58 +1,57 @@
-"use client";
+"use client"
-import * as TooltipPrimitive from "@radix-ui/react-tooltip";
-import type * as React from "react";
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
-import { cn } from "@/lib/utils";
+import { cn } from "@/lib/utils"
function TooltipProvider({
- delayDuration = 0,
- disableHoverableContent = true,
- ...props
+ delayDuration = 0,
+ ...props
}: React.ComponentProps) {
- return (
-
- );
+ return (
+
+ )
}
-function Tooltip({ ...props }: React.ComponentProps) {
- return (
-
-
-
- );
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
}
-function TooltipTrigger({ ...props }: React.ComponentProps) {
- return ;
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
}
function TooltipContent({
- className,
- sideOffset = 4,
- children,
- ...props
+ className,
+ sideOffset = 0,
+ children,
+ ...props
}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
- );
+ return (
+
+
+ {children}
+
+
+
+ )
}
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/surfsense_web/package.json b/surfsense_web/package.json
index 764efe1ff..820a24725 100644
--- a/surfsense_web/package.json
+++ b/surfsense_web/package.json
@@ -22,6 +22,7 @@
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
+ "@ariakit/react": "^0.4.21",
"@assistant-ui/react": "^0.11.53",
"@assistant-ui/react-ai-sdk": "^1.1.20",
"@assistant-ui/react-markdown": "^0.11.9",
@@ -39,11 +40,12 @@
"@platejs/basic-nodes": "^52.0.11",
"@platejs/callout": "^52.0.11",
"@platejs/code-block": "^52.0.11",
+ "@platejs/combobox": "^52.0.15",
"@platejs/dnd": "^52.0.11",
"@platejs/floating": "^52.0.11",
"@platejs/indent": "^52.0.11",
"@platejs/link": "^52.0.11",
- "@platejs/list-classic": "^52.0.11",
+ "@platejs/list": "^52.0.11",
"@platejs/markdown": "^52.1.0",
"@platejs/math": "^52.0.11",
"@platejs/resizable": "^52.0.11",
@@ -114,6 +116,8 @@
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-day-picker": "^9.13.2",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.2.3",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.61.1",
diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml
index c5c69d8e0..195795dea 100644
--- a/surfsense_web/pnpm-lock.yaml
+++ b/surfsense_web/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
'@ai-sdk/react':
specifier: ^1.2.12
version: 1.2.12(react@19.2.3)(zod@4.2.1)
+ '@ariakit/react':
+ specifier: ^0.4.21
+ version: 0.4.21(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@assistant-ui/react':
specifier: ^0.11.53
version: 0.11.53(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
@@ -62,6 +65,9 @@ importers:
'@platejs/code-block':
specifier: ^52.0.11
version: 52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@platejs/combobox':
+ specifier: ^52.0.15
+ version: 52.0.15(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@platejs/dnd':
specifier: ^52.0.11
version: 52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dnd-html5-backend@16.0.1)(react-dnd@16.0.1(@types/node@20.19.27)(@types/react@19.2.7)(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -74,7 +80,7 @@ importers:
'@platejs/link':
specifier: ^52.0.11
version: 52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- '@platejs/list-classic':
+ '@platejs/list':
specifier: ^52.0.11
version: 52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@platejs/markdown':
@@ -287,6 +293,12 @@ importers:
react-day-picker:
specifier: ^9.13.2
version: 9.13.2(react@19.2.3)
+ react-dnd:
+ specifier: ^16.0.1
+ version: 16.0.1(@types/node@20.19.27)(@types/react@19.2.7)(react@19.2.3)
+ react-dnd-html5-backend:
+ specifier: ^16.0.1
+ version: 16.0.1
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
@@ -464,6 +476,21 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@ariakit/core@0.4.18':
+ resolution: {integrity: sha512-9urEa+GbZTSyredq3B/3thQjTcSZSUC68XctwCkJNH/xNfKN5O+VThiem2rcJxpsGw8sRUQenhagZi0yB4foyg==}
+
+ '@ariakit/react-core@0.4.21':
+ resolution: {integrity: sha512-rUI9uB/gT3mROFja/ka7/JukkdljIZR3eq3BGiQqX4Ni/KBMDvPK8FvVLnC0TGzWcqNY2bbfve8QllvHzuw4fQ==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@ariakit/react@0.4.21':
+ resolution: {integrity: sha512-UjP99Y7cWxA5seRECEE0RPZFImkLGFIWPflp65t0BVZwlMw4wp9OJZRHMrnkEkKl5KBE2NR/gbbzwHc6VNGzsA==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@@ -2302,8 +2329,8 @@ packages:
react: '>=18.0.0'
react-dom: '>=18.0.0'
- '@platejs/list-classic@52.0.11':
- resolution: {integrity: sha512-cuR0UJcfT7X45dvptofbozEznx604Y263pjlK7vLFerI2k7cUBuXRKOxtUuwVD4VaWE5aHCr8cO+nb+jhdGHSQ==}
+ '@platejs/list@52.0.11':
+ resolution: {integrity: sha512-azmJhBRQsjoWLwVWgTfHxegTg02k81sEJinwNv5gDPyiDlK1Pk98Oys/7H21SpwRWh75tPR/9JfV0ZGMqAHEJA==}
peerDependencies:
platejs: '>=52.0.11'
react: '>=18.0.0'
@@ -7815,6 +7842,22 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
+ '@ariakit/core@0.4.18': {}
+
+ '@ariakit/react-core@0.4.21(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@ariakit/core': 0.4.18
+ '@floating-ui/dom': 1.7.5
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ use-sync-external-store: 1.6.0(react@19.2.3)
+
+ '@ariakit/react@0.4.21(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@ariakit/react-core': 0.4.21(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -9220,7 +9263,7 @@ snapshots:
postcss-selector-parser: 7.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
- tailwind-merge: 3.4.0
+ tailwind-merge: 3.4.1
optionalDependencies:
'@types/react': 19.2.7
next: 16.1.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -9731,9 +9774,10 @@ snapshots:
react-compiler-runtime: 1.0.0(react@19.2.3)
react-dom: 19.2.3(react@19.2.3)
- '@platejs/list-classic@52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ '@platejs/list@52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
- lodash: 4.17.23
+ '@platejs/indent': 52.0.11(platejs@52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ clsx: 2.1.1
platejs: 52.0.17(@types/react@19.2.7)(immer@10.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(scheduler@0.27.0)(use-sync-external-store@1.6.0(react@19.2.3))
react: 19.2.3
react-compiler-runtime: 1.0.0(react@19.2.3)