mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-30 19:36:25 +02:00
feat: add new autoformat and drag-and-drop functionality to the editor, refactor list handling and update dependencies
This commit is contained in:
parent
1450e22f54
commit
93a0487e56
14 changed files with 972 additions and 321 deletions
513
surfsense_web/components/ui/block-draggable.tsx
Normal file
513
surfsense_web/components/ui/block-draggable.tsx
Normal file
|
|
@ -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) => <Draggable {...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 (
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
isDragging && 'opacity-50',
|
||||
getPluginByType(editor, element.type)?.node.isContainer
|
||||
? 'group/container'
|
||||
: 'group'
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (isDragging) return;
|
||||
setDragButtonTop(calcDragButtonTop(editor, element));
|
||||
}}
|
||||
>
|
||||
{!isInTable && (
|
||||
<Gutter>
|
||||
<div
|
||||
className={cn(
|
||||
'slate-blockToolbarWrapper',
|
||||
'flex h-[1.5em]',
|
||||
isInColumn && 'h-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'slate-blockToolbar relative w-4.5',
|
||||
'pointer-events-auto mr-1 flex items-center',
|
||||
isInColumn && 'mr-1.5'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={handleRef}
|
||||
variant="ghost"
|
||||
className="-left-0 absolute h-6 w-full p-0"
|
||||
style={{ top: `${dragButtonTop + 3}px` }}
|
||||
data-plate-prevent-deselect
|
||||
>
|
||||
<DragHandle
|
||||
isDragging={isDragging}
|
||||
previewRef={previewRef}
|
||||
resetPreview={resetPreview}
|
||||
setPreviewTop={setPreviewTop}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Gutter>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={cn('-left-0 absolute hidden w-full')}
|
||||
style={{ top: `${-previewTop}px` }}
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="slate-blockWrapper flow-root"
|
||||
onContextMenu={(event) =>
|
||||
editor
|
||||
.getApi(BlockSelectionPlugin)
|
||||
.blockSelection.addOnContextMenu({ element, event })
|
||||
}
|
||||
>
|
||||
<MemoizedChildren>{children}</MemoizedChildren>
|
||||
<DropLine />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gutter({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
const editor = useEditorRef();
|
||||
const element = useElement();
|
||||
const isSelectionAreaVisible = usePluginOption(
|
||||
BlockSelectionPlugin,
|
||||
'isSelectionAreaVisible'
|
||||
);
|
||||
const selected = useSelected();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'slate-gutterLeft',
|
||||
'-translate-x-full absolute top-0 z-50 flex h-full cursor-text hover:opacity-100 sm:opacity-0',
|
||||
getPluginByType(editor, element.type)?.node.isContainer
|
||||
? 'group-hover/container:opacity-100'
|
||||
: 'group-hover:opacity-100',
|
||||
isSelectionAreaVisible && 'hidden',
|
||||
!selected && 'opacity-0',
|
||||
className
|
||||
)}
|
||||
contentEditable={false}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DragHandle = React.memo(function DragHandle({
|
||||
isDragging,
|
||||
previewRef,
|
||||
resetPreview,
|
||||
setPreviewTop,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
previewRef: React.RefObject<HTMLDivElement | null>;
|
||||
resetPreview: () => void;
|
||||
setPreviewTop: (top: number) => void;
|
||||
}) {
|
||||
const editor = useEditorRef();
|
||||
const element = useElement();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex size-full items-center justify-center"
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<GripVertical className="text-muted-foreground" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const DropLine = React.memo(function DropLine({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
const { dropLine } = useDropLine();
|
||||
|
||||
if (!dropLine) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'slate-dropLine',
|
||||
'absolute inset-x-0 h-0.5 opacity-100 transition-opacity',
|
||||
'bg-brand/50',
|
||||
dropLine === 'top' && '-top-px',
|
||||
dropLine === 'bottom' && '-bottom-px',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
85
surfsense_web/components/ui/block-list-static.tsx
Normal file
85
surfsense_web/components/ui/block-list-static.tsx
Normal file
|
|
@ -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<SlateRenderElementProps>;
|
||||
Marker: React.FC<SlateRenderElementProps>;
|
||||
}
|
||||
> = {
|
||||
todo: {
|
||||
Li: TodoLiStatic,
|
||||
Marker: TodoMarkerStatic,
|
||||
},
|
||||
};
|
||||
|
||||
export const BlockListStatic: RenderStaticNodeWrapper = (props) => {
|
||||
if (!props.element.listStyleType) return;
|
||||
|
||||
return (props) => <List {...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 (
|
||||
<List
|
||||
className="relative m-0 p-0"
|
||||
style={{ listStyleType, marginLeft }}
|
||||
start={listStart}
|
||||
>
|
||||
{Marker && <Marker {...props} />}
|
||||
{Li ? <Li {...props} /> : <li>{props.children}</li>}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoMarkerStatic(props: SlateRenderElementProps) {
|
||||
const checked = props.element.checked as boolean;
|
||||
|
||||
return (
|
||||
<div contentEditable={false}>
|
||||
<button
|
||||
className={cn(
|
||||
'peer -left-6 pointer-events-none absolute top-1 size-4 shrink-0 rounded-sm border border-primary bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.className
|
||||
)}
|
||||
data-state={checked ? 'checked' : 'unchecked'}
|
||||
type="button"
|
||||
>
|
||||
<div className={cn('flex items-center justify-center text-current')}>
|
||||
{checked && <CheckIcon className="size-4" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoLiStatic(props: SlateRenderElementProps) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'list-none',
|
||||
(props.element.checked as boolean) &&
|
||||
'text-muted-foreground line-through'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
87
surfsense_web/components/ui/block-list.tsx
Normal file
87
surfsense_web/components/ui/block-list.tsx
Normal file
|
|
@ -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<PlateElementProps>;
|
||||
Marker: React.FC<PlateElementProps>;
|
||||
}
|
||||
> = {
|
||||
todo: {
|
||||
Li: TodoLi,
|
||||
Marker: TodoMarker,
|
||||
},
|
||||
};
|
||||
|
||||
export const BlockList: RenderNodeWrapper = (props) => {
|
||||
if (!props.element.listStyleType) return;
|
||||
|
||||
return (props) => <List {...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 (
|
||||
<List
|
||||
className="relative m-0 p-0"
|
||||
style={{ listStyleType }}
|
||||
start={listStart}
|
||||
>
|
||||
{Marker && <Marker {...props} />}
|
||||
{Li ? <Li {...props} /> : <li>{props.children}</li>}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoMarker(props: PlateElementProps) {
|
||||
const state = useTodoListElementState({ element: props.element });
|
||||
const { checkboxProps } = useTodoListElement(state);
|
||||
const readOnly = useReadOnly();
|
||||
|
||||
return (
|
||||
<div contentEditable={false}>
|
||||
<Checkbox
|
||||
className={cn(
|
||||
'-left-6 absolute top-1',
|
||||
readOnly && 'pointer-events-none'
|
||||
)}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoLi(props: PlateElementProps) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'list-none',
|
||||
(props.element.checked as boolean) &&
|
||||
'text-muted-foreground line-through'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-transparent data-[state=indeterminate]:text-foreground data-[state=indeterminate]:border-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="group flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5 hidden group-data-[state=checked]:block" />
|
||||
<MinusIcon className="size-3.5 hidden group-data-[state=indeterminate]:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
export { Checkbox }
|
||||
|
|
|
|||
|
|
@ -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<typeof listVariants>) {
|
||||
return (
|
||||
<PlateElement
|
||||
as={variant!}
|
||||
className={listVariants({ variant })}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulletedListElement(props: PlateElementProps) {
|
||||
return <ListElement variant="ul" {...props} />;
|
||||
}
|
||||
|
||||
export function NumberedListElement(props: PlateElementProps) {
|
||||
return <ListElement variant="ol" {...props} />;
|
||||
}
|
||||
|
||||
export function TaskListElement(props: PlateElementProps) {
|
||||
return (
|
||||
<PlateElement as="ul" className="m-0 list-none! py-1 ps-6" {...props}>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItemElement(props: PlateElementProps) {
|
||||
const isTaskList = 'checked' in props.element;
|
||||
|
||||
if (isTaskList) {
|
||||
return <TaskListItemElement {...props} />;
|
||||
}
|
||||
|
||||
return <BaseListItemElement {...props} />;
|
||||
}
|
||||
|
||||
export function BaseListItemElement(props: PlateElementProps) {
|
||||
return (
|
||||
<PlateElement as="li" {...props}>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<BaseListItemElement {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch *:nth-[2]:flex-1 *:nth-[2]:focus:outline-none',
|
||||
{
|
||||
'*:nth-[2]:text-muted-foreground *:nth-[2]:line-through':
|
||||
state.checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="-ms-5 me-1.5 flex w-fit select-none items-start justify-center pt-[0.275em]"
|
||||
contentEditable={false}
|
||||
>
|
||||
<Checkbox {...checkboxProps} />
|
||||
</div>
|
||||
|
||||
{firstChild}
|
||||
</div>
|
||||
|
||||
{otherChildren}
|
||||
</BaseListItemElement>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLSpanElement>(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 (
|
||||
<PlateElement {...props} as="span">
|
||||
<Command
|
||||
className="relative z-50 min-w-[280px] overflow-hidden rounded-lg border bg-popover shadow-md"
|
||||
shouldFilter={true}
|
||||
>
|
||||
<CommandInput
|
||||
className="hidden"
|
||||
value={props.element.value as string}
|
||||
onValueChange={(value) => {
|
||||
// The value is managed by the slash input plugin
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<CommandList className="max-h-[300px] overflow-y-auto p-1">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{slashCommandGroups.map(({ heading, items }) => (
|
||||
<CommandGroup key={heading} heading={heading}>
|
||||
{items.map(({ icon, keywords, label, onSelect }) => (
|
||||
<CommandItem
|
||||
key={label}
|
||||
className="flex items-center gap-2 px-2"
|
||||
keywords={keywords}
|
||||
value={label}
|
||||
onSelect={() => {
|
||||
editor.tf.removeNodes({
|
||||
match: (n) => (n as any).type === SlashInputPlugin.key,
|
||||
});
|
||||
onSelect(editor);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</CommandItem>
|
||||
<span ref={anchorRef} />
|
||||
{menuPosition &&
|
||||
createPortal(
|
||||
<Command
|
||||
className="fixed z-50 w-[330px] overflow-hidden rounded-lg border bg-popover shadow-lg"
|
||||
style={{ top: menuPosition.top, left: menuPosition.left }}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<CommandList className="max-h-[min(400px,40vh)] overflow-y-auto p-1">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{slashCommandGroups.map(({ heading, items }) => (
|
||||
<CommandGroup key={heading} heading={heading}>
|
||||
{items.map(({ icon, keywords, label, onSelect }) => (
|
||||
<CommandItem
|
||||
key={label}
|
||||
className="flex items-center gap-3 px-2 py-2"
|
||||
keywords={keywords}
|
||||
value={label}
|
||||
onSelect={() => {
|
||||
editor.tf.removeNodes({
|
||||
match: (n) => (n as any).type === SlashInputPlugin.key,
|
||||
});
|
||||
onSelect(editor);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
document.body
|
||||
)}
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue