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 && ( + +
+
+ +
+
+
+ )} + + + ); +} + +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 ( - -
    -
    - -
    - - {firstChild} -
    - - {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)