mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add report content update endpoint and integrate Platejs editor for markdown editing
This commit is contained in:
parent
cb759b64fe
commit
1995fe9ec1
73 changed files with 7447 additions and 77 deletions
|
|
@ -1,8 +1,9 @@
|
|||
"""
|
||||
Report routes for read, export (PDF/DOCX), and delete operations.
|
||||
Report routes for read, update, export (PDF/DOCX), and delete operations.
|
||||
|
||||
No create or update endpoints here — reports are generated inline by the
|
||||
agent tool during chat and stored as Markdown in the database.
|
||||
Reports are generated inline by the agent tool during chat and stored as
|
||||
Markdown in the database. Users can edit report content via the Plate editor
|
||||
and save changes through the PUT endpoint.
|
||||
Export to PDF/DOCX is on-demand — PDF uses pypandoc (Markdown→Typst) + typst-py
|
||||
(Typst→PDF); DOCX uses pypandoc directly.
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ from app.db import (
|
|||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas import ReportContentRead, ReportRead
|
||||
from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead
|
||||
from app.schemas.reports import ReportVersionInfo
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_search_space_access
|
||||
|
|
@ -259,6 +260,47 @@ async def read_report_content(
|
|||
) from None
|
||||
|
||||
|
||||
@router.put("/reports/{report_id}/content", response_model=ReportContentRead)
|
||||
async def update_report_content(
|
||||
report_id: int,
|
||||
body: ReportContentUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update the Markdown content of a report.
|
||||
|
||||
The caller must be a member of the search space the report belongs to.
|
||||
Returns the updated report content including version siblings.
|
||||
"""
|
||||
try:
|
||||
report = await _get_report_with_access(report_id, session, user)
|
||||
|
||||
report.content = body.content
|
||||
session.add(report)
|
||||
await session.commit()
|
||||
await session.refresh(report)
|
||||
|
||||
versions = await _get_version_siblings(session, report)
|
||||
|
||||
return ReportContentRead(
|
||||
id=report.id,
|
||||
title=report.title,
|
||||
content=report.content,
|
||||
report_metadata=report.report_metadata,
|
||||
report_group_id=report.report_group_id,
|
||||
versions=versions,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Database error occurred while updating report content",
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/export")
|
||||
async def export_report(
|
||||
report_id: int,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,13 @@ from .rbac_schemas import (
|
|||
RoleUpdate,
|
||||
UserSearchSpaceAccess,
|
||||
)
|
||||
from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo
|
||||
from .reports import (
|
||||
ReportBase,
|
||||
ReportContentRead,
|
||||
ReportContentUpdate,
|
||||
ReportRead,
|
||||
ReportVersionInfo,
|
||||
)
|
||||
from .search_source_connector import (
|
||||
MCPConnectorCreate,
|
||||
MCPConnectorRead,
|
||||
|
|
@ -189,6 +195,7 @@ __all__ = [
|
|||
# Report schemas
|
||||
"ReportBase",
|
||||
"ReportContentRead",
|
||||
"ReportContentUpdate",
|
||||
"ReportRead",
|
||||
"ReportVersionInfo",
|
||||
"RoleCreate",
|
||||
|
|
|
|||
|
|
@ -51,3 +51,9 @@ class ReportContentRead(BaseModel):
|
|||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReportContentUpdate(BaseModel):
|
||||
"""Schema for updating a report's Markdown content."""
|
||||
|
||||
content: str
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@plugin "tailwind-scrollbar-hide";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
|
|
@ -46,6 +48,8 @@
|
|||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--syntax-bg: #f5f5f5;
|
||||
--brand: oklch(0.623 0.214 259.815);
|
||||
--highlight: oklch(0.852 0.199 91.936);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -82,6 +86,8 @@
|
|||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--syntax-bg: #1e1e1e;
|
||||
--brand: oklch(0.707 0.165 254.624);
|
||||
--highlight: oklch(0.852 0.199 91.936);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
@ -123,6 +129,8 @@
|
|||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-brand: var(--brand);
|
||||
--color-highlight: var(--highlight);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@plate": "https://platejs.org/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
surfsense_web/components/editor/plate-editor.tsx
Normal file
107
surfsense_web/components/editor/plate-editor.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MarkdownPlugin } from '@platejs/markdown';
|
||||
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 { 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 { 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 { MathKit } from '@/components/editor/plugins/math-kit';
|
||||
import { SelectionKit } from '@/components/editor/plugins/selection-kit';
|
||||
import { SlashCommandKit } from '@/components/editor/plugins/slash-command-kit';
|
||||
import { TableKit } from '@/components/editor/plugins/table-kit';
|
||||
import { ToggleKit } from '@/components/editor/plugins/toggle-kit';
|
||||
import { Editor, EditorContainer } from '@/components/ui/editor';
|
||||
|
||||
interface PlateEditorProps {
|
||||
/** Markdown string to load as initial content */
|
||||
markdown?: string;
|
||||
/** Called when the editor content changes, with serialized markdown */
|
||||
onMarkdownChange?: (markdown: string) => void;
|
||||
/** Whether the editor is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Editor container variant */
|
||||
variant?: 'default' | 'demo' | 'comment' | 'select';
|
||||
/** Editor text variant */
|
||||
editorVariant?: 'default' | 'demo' | 'fullWidth' | 'none';
|
||||
/** Additional className for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlateEditor({
|
||||
markdown,
|
||||
onMarkdownChange,
|
||||
readOnly = false,
|
||||
placeholder = 'Type...',
|
||||
variant = 'default',
|
||||
editorVariant = 'default',
|
||||
className,
|
||||
}: PlateEditorProps) {
|
||||
const lastMarkdownRef = useRef(markdown);
|
||||
|
||||
const editor = usePlateEditor({
|
||||
plugins: [
|
||||
...BasicNodesKit,
|
||||
...TableKit,
|
||||
...ListKit,
|
||||
...CodeBlockKit,
|
||||
...LinkKit,
|
||||
...CalloutKit,
|
||||
...ToggleKit,
|
||||
...IndentKit,
|
||||
...MathKit,
|
||||
...SelectionKit,
|
||||
...SlashCommandKit,
|
||||
...FloatingToolbarKit,
|
||||
...AutoformatKit,
|
||||
MarkdownPlugin.configure({
|
||||
options: {
|
||||
remarkPlugins: [remarkGfm, remarkMath],
|
||||
},
|
||||
}),
|
||||
],
|
||||
// Use markdown deserialization for initial value if provided
|
||||
value: markdown
|
||||
? (editor) => editor.getApi(MarkdownPlugin).markdown.deserialize(markdown)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Update editor content when markdown prop changes externally
|
||||
// (e.g., version switching in report panel)
|
||||
useEffect(() => {
|
||||
if (markdown !== undefined && markdown !== lastMarkdownRef.current) {
|
||||
lastMarkdownRef.current = markdown;
|
||||
const newValue = editor.getApi(MarkdownPlugin).markdown.deserialize(markdown);
|
||||
editor.tf.reset();
|
||||
editor.tf.setValue(newValue);
|
||||
}
|
||||
}, [markdown, editor]);
|
||||
|
||||
return (
|
||||
<Plate
|
||||
editor={editor}
|
||||
readOnly={readOnly}
|
||||
onChange={({ value }) => {
|
||||
if (onMarkdownChange) {
|
||||
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
|
||||
lastMarkdownRef.current = md;
|
||||
onMarkdownChange(md);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditorContainer variant={variant} className={className}>
|
||||
<Editor variant={editorVariant} placeholder={placeholder} readOnly={readOnly} />
|
||||
</EditorContainer>
|
||||
</Plate>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
'use client';
|
||||
|
||||
import type { AutoformatBlockRule, AutoformatRule } from '@platejs/autoformat';
|
||||
import type { SlateEditor } from 'platejs';
|
||||
|
||||
import {
|
||||
autoformatArrow,
|
||||
autoformatLegal,
|
||||
autoformatLegalHtml,
|
||||
autoformatMath,
|
||||
AutoformatPlugin,
|
||||
autoformatPunctuation,
|
||||
autoformatSmartQuotes,
|
||||
} from '@platejs/autoformat';
|
||||
import { insertEmptyCodeBlock } from '@platejs/code-block';
|
||||
import { toggleList, toggleTaskList, unwrapList } from '@platejs/list-classic';
|
||||
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,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const autoformatMarks: AutoformatRule[] = [
|
||||
{
|
||||
match: '***',
|
||||
mode: 'mark',
|
||||
type: [KEYS.bold, KEYS.italic],
|
||||
},
|
||||
{
|
||||
match: '__*',
|
||||
mode: 'mark',
|
||||
type: [KEYS.underline, KEYS.italic],
|
||||
},
|
||||
{
|
||||
match: '__**',
|
||||
mode: 'mark',
|
||||
type: [KEYS.underline, KEYS.bold],
|
||||
},
|
||||
{
|
||||
match: '___***',
|
||||
mode: 'mark',
|
||||
type: [KEYS.underline, KEYS.bold, KEYS.italic],
|
||||
},
|
||||
{
|
||||
match: '**',
|
||||
mode: 'mark',
|
||||
type: KEYS.bold,
|
||||
},
|
||||
{
|
||||
match: '__',
|
||||
mode: 'mark',
|
||||
type: KEYS.underline,
|
||||
},
|
||||
{
|
||||
match: '*',
|
||||
mode: 'mark',
|
||||
type: KEYS.italic,
|
||||
},
|
||||
{
|
||||
match: '_',
|
||||
mode: 'mark',
|
||||
type: KEYS.italic,
|
||||
},
|
||||
{
|
||||
match: '~~',
|
||||
mode: 'mark',
|
||||
type: KEYS.strikethrough,
|
||||
},
|
||||
{
|
||||
match: '^',
|
||||
mode: 'mark',
|
||||
type: KEYS.sup,
|
||||
},
|
||||
{
|
||||
match: '~',
|
||||
mode: 'mark',
|
||||
type: KEYS.sub,
|
||||
},
|
||||
{
|
||||
match: '==',
|
||||
mode: 'mark',
|
||||
type: KEYS.highlight,
|
||||
},
|
||||
{
|
||||
match: '≡',
|
||||
mode: 'mark',
|
||||
type: KEYS.highlight,
|
||||
},
|
||||
{
|
||||
match: '`',
|
||||
mode: 'mark',
|
||||
type: KEYS.code,
|
||||
},
|
||||
];
|
||||
|
||||
const autoformatBlocks: AutoformatRule[] = [
|
||||
{
|
||||
match: '# ',
|
||||
mode: 'block',
|
||||
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, {
|
||||
defaultType: KEYS.p,
|
||||
insertNodesOptions: { select: true },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
match: '+ ',
|
||||
mode: 'block',
|
||||
preFormat: openNextToggles,
|
||||
type: KEYS.toggle,
|
||||
},
|
||||
{
|
||||
match: ['---', '—-', '___ '],
|
||||
mode: 'block',
|
||||
type: KEYS.hr,
|
||||
format: (editor) => {
|
||||
editor.tf.setNodes({ type: KEYS.hr });
|
||||
editor.tf.insertNodes({
|
||||
children: [{ text: '' }],
|
||||
type: KEYS.p,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const autoformatLists: AutoformatRule[] = [
|
||||
{
|
||||
match: ['* ', '- '],
|
||||
mode: 'block',
|
||||
preFormat,
|
||||
type: KEYS.li,
|
||||
format: (editor) => formatList(editor, KEYS.ulClassic),
|
||||
},
|
||||
{
|
||||
match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
|
||||
matchByRegex: true,
|
||||
mode: 'block',
|
||||
preFormat,
|
||||
type: KEYS.li,
|
||||
format: (editor) => formatList(editor, KEYS.olClassic),
|
||||
},
|
||||
{
|
||||
match: '[] ',
|
||||
mode: 'block',
|
||||
type: KEYS.taskList,
|
||||
format: (editor) => formatTaskList(editor, false),
|
||||
},
|
||||
{
|
||||
match: '[x] ',
|
||||
mode: 'block',
|
||||
type: KEYS.taskList,
|
||||
format: (editor) => formatTaskList(editor, true),
|
||||
},
|
||||
];
|
||||
|
||||
export const AutoformatKit = [
|
||||
AutoformatPlugin.configure({
|
||||
options: {
|
||||
enableUndoOnDelete: true,
|
||||
rules: [
|
||||
...autoformatBlocks,
|
||||
...autoformatMarks,
|
||||
...autoformatSmartQuotes,
|
||||
...autoformatPunctuation,
|
||||
...autoformatLegal,
|
||||
...autoformatLegalHtml,
|
||||
...autoformatArrow,
|
||||
...autoformatMath,
|
||||
...autoformatLists,
|
||||
].map((rule) => ({
|
||||
...rule,
|
||||
query: (editor) =>
|
||||
!editor.api.some({
|
||||
match: { type: editor.getType(KEYS.codeBlock) },
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import {
|
||||
BaseBlockquotePlugin,
|
||||
BaseH1Plugin,
|
||||
BaseH2Plugin,
|
||||
BaseH3Plugin,
|
||||
BaseH4Plugin,
|
||||
BaseH5Plugin,
|
||||
BaseH6Plugin,
|
||||
BaseHorizontalRulePlugin,
|
||||
} from '@platejs/basic-nodes';
|
||||
import { BaseParagraphPlugin } from 'platejs';
|
||||
|
||||
import { BlockquoteElementStatic } from '@/components/ui/blockquote-node-static';
|
||||
import {
|
||||
H1ElementStatic,
|
||||
H2ElementStatic,
|
||||
H3ElementStatic,
|
||||
H4ElementStatic,
|
||||
H5ElementStatic,
|
||||
H6ElementStatic,
|
||||
} from '@/components/ui/heading-node-static';
|
||||
import { HrElementStatic } from '@/components/ui/hr-node-static';
|
||||
import { ParagraphElementStatic } from '@/components/ui/paragraph-node-static';
|
||||
|
||||
export const BaseBasicBlocksKit = [
|
||||
BaseParagraphPlugin.withComponent(ParagraphElementStatic),
|
||||
BaseH1Plugin.withComponent(H1ElementStatic),
|
||||
BaseH2Plugin.withComponent(H2ElementStatic),
|
||||
BaseH3Plugin.withComponent(H3ElementStatic),
|
||||
BaseH4Plugin.withComponent(H4ElementStatic),
|
||||
BaseH5Plugin.withComponent(H5ElementStatic),
|
||||
BaseH6Plugin.withComponent(H6ElementStatic),
|
||||
BaseBlockquotePlugin.withComponent(BlockquoteElementStatic),
|
||||
BaseHorizontalRulePlugin.withComponent(HrElementStatic),
|
||||
];
|
||||
86
surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
Normal file
86
surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
BlockquotePlugin,
|
||||
H1Plugin,
|
||||
H2Plugin,
|
||||
H3Plugin,
|
||||
H4Plugin,
|
||||
H5Plugin,
|
||||
H6Plugin,
|
||||
HorizontalRulePlugin,
|
||||
} from '@platejs/basic-nodes/react';
|
||||
import { ParagraphPlugin } from 'platejs/react';
|
||||
|
||||
import { BlockquoteElement } from '@/components/ui/blockquote-node';
|
||||
import {
|
||||
H1Element,
|
||||
H2Element,
|
||||
H3Element,
|
||||
H4Element,
|
||||
H5Element,
|
||||
H6Element,
|
||||
} from '@/components/ui/heading-node';
|
||||
import { HrElement } from '@/components/ui/hr-node';
|
||||
import { ParagraphElement } from '@/components/ui/paragraph-node';
|
||||
|
||||
export const BasicBlocksKit = [
|
||||
ParagraphPlugin.withComponent(ParagraphElement),
|
||||
H1Plugin.configure({
|
||||
node: {
|
||||
component: H1Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: 'reset' },
|
||||
},
|
||||
shortcuts: { toggle: { keys: 'mod+alt+1' } },
|
||||
}),
|
||||
H2Plugin.configure({
|
||||
node: {
|
||||
component: H2Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: 'reset' },
|
||||
},
|
||||
shortcuts: { toggle: { keys: 'mod+alt+2' } },
|
||||
}),
|
||||
H3Plugin.configure({
|
||||
node: {
|
||||
component: H3Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: 'reset' },
|
||||
},
|
||||
shortcuts: { toggle: { keys: 'mod+alt+3' } },
|
||||
}),
|
||||
H4Plugin.configure({
|
||||
node: {
|
||||
component: H4Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: 'reset' },
|
||||
},
|
||||
shortcuts: { toggle: { keys: 'mod+alt+4' } },
|
||||
}),
|
||||
H5Plugin.configure({
|
||||
node: {
|
||||
component: H5Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: 'reset' },
|
||||
},
|
||||
}),
|
||||
H6Plugin.configure({
|
||||
node: {
|
||||
component: H6Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: 'reset' },
|
||||
},
|
||||
}),
|
||||
BlockquotePlugin.configure({
|
||||
node: { component: BlockquoteElement },
|
||||
shortcuts: { toggle: { keys: 'mod+shift+period' } },
|
||||
}),
|
||||
HorizontalRulePlugin.withComponent(HrElement),
|
||||
];
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
BaseBoldPlugin,
|
||||
BaseCodePlugin,
|
||||
BaseHighlightPlugin,
|
||||
BaseItalicPlugin,
|
||||
BaseKbdPlugin,
|
||||
BaseStrikethroughPlugin,
|
||||
BaseSubscriptPlugin,
|
||||
BaseSuperscriptPlugin,
|
||||
BaseUnderlinePlugin,
|
||||
} from '@platejs/basic-nodes';
|
||||
|
||||
import { CodeLeafStatic } from '@/components/ui/code-node-static';
|
||||
import { HighlightLeafStatic } from '@/components/ui/highlight-node-static';
|
||||
import { KbdLeafStatic } from '@/components/ui/kbd-node-static';
|
||||
|
||||
export const BaseBasicMarksKit = [
|
||||
BaseBoldPlugin,
|
||||
BaseItalicPlugin,
|
||||
BaseUnderlinePlugin,
|
||||
BaseCodePlugin.withComponent(CodeLeafStatic),
|
||||
BaseStrikethroughPlugin,
|
||||
BaseSubscriptPlugin,
|
||||
BaseSuperscriptPlugin,
|
||||
BaseHighlightPlugin.withComponent(HighlightLeafStatic),
|
||||
BaseKbdPlugin.withComponent(KbdLeafStatic),
|
||||
];
|
||||
41
surfsense_web/components/editor/plugins/basic-marks-kit.tsx
Normal file
41
surfsense_web/components/editor/plugins/basic-marks-kit.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
BoldPlugin,
|
||||
CodePlugin,
|
||||
HighlightPlugin,
|
||||
ItalicPlugin,
|
||||
KbdPlugin,
|
||||
StrikethroughPlugin,
|
||||
SubscriptPlugin,
|
||||
SuperscriptPlugin,
|
||||
UnderlinePlugin,
|
||||
} from '@platejs/basic-nodes/react';
|
||||
|
||||
import { CodeLeaf } from '@/components/ui/code-node';
|
||||
import { HighlightLeaf } from '@/components/ui/highlight-node';
|
||||
import { KbdLeaf } from '@/components/ui/kbd-node';
|
||||
|
||||
export const BasicMarksKit = [
|
||||
BoldPlugin,
|
||||
ItalicPlugin,
|
||||
UnderlinePlugin,
|
||||
CodePlugin.configure({
|
||||
node: { component: CodeLeaf },
|
||||
shortcuts: { toggle: { keys: 'mod+e' } },
|
||||
}),
|
||||
StrikethroughPlugin.configure({
|
||||
shortcuts: { toggle: { keys: 'mod+shift+x' } },
|
||||
}),
|
||||
SubscriptPlugin.configure({
|
||||
shortcuts: { toggle: { keys: 'mod+comma' } },
|
||||
}),
|
||||
SuperscriptPlugin.configure({
|
||||
shortcuts: { toggle: { keys: 'mod+period' } },
|
||||
}),
|
||||
HighlightPlugin.configure({
|
||||
node: { component: HighlightLeaf },
|
||||
shortcuts: { toggle: { keys: 'mod+shift+h' } },
|
||||
}),
|
||||
KbdPlugin.withComponent(KbdLeaf),
|
||||
];
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { BasicBlocksKit } from './basic-blocks-kit';
|
||||
import { BasicMarksKit } from './basic-marks-kit';
|
||||
|
||||
export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];
|
||||
8
surfsense_web/components/editor/plugins/callout-kit.tsx
Normal file
8
surfsense_web/components/editor/plugins/callout-kit.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { CalloutPlugin } from '@platejs/callout/react';
|
||||
|
||||
import { CalloutElement } from '@/components/ui/callout-node';
|
||||
|
||||
export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)];
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import {
|
||||
BaseCodeBlockPlugin,
|
||||
BaseCodeLinePlugin,
|
||||
BaseCodeSyntaxPlugin,
|
||||
} from '@platejs/code-block';
|
||||
import { all, createLowlight } from 'lowlight';
|
||||
|
||||
import {
|
||||
CodeBlockElementStatic,
|
||||
CodeLineElementStatic,
|
||||
CodeSyntaxLeafStatic,
|
||||
} from '@/components/ui/code-block-node-static';
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
export const BaseCodeBlockKit = [
|
||||
BaseCodeBlockPlugin.configure({
|
||||
node: { component: CodeBlockElementStatic },
|
||||
options: { lowlight },
|
||||
}),
|
||||
BaseCodeLinePlugin.withComponent(CodeLineElementStatic),
|
||||
BaseCodeSyntaxPlugin.withComponent(CodeSyntaxLeafStatic),
|
||||
];
|
||||
26
surfsense_web/components/editor/plugins/code-block-kit.tsx
Normal file
26
surfsense_web/components/editor/plugins/code-block-kit.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
CodeBlockPlugin,
|
||||
CodeLinePlugin,
|
||||
CodeSyntaxPlugin,
|
||||
} from '@platejs/code-block/react';
|
||||
import { all, createLowlight } from 'lowlight';
|
||||
|
||||
import {
|
||||
CodeBlockElement,
|
||||
CodeLineElement,
|
||||
CodeSyntaxLeaf,
|
||||
} from '@/components/ui/code-block-node';
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
export const CodeBlockKit = [
|
||||
CodeBlockPlugin.configure({
|
||||
node: { component: CodeBlockElement },
|
||||
options: { lowlight },
|
||||
shortcuts: { toggle: { keys: 'mod+alt+8' } },
|
||||
}),
|
||||
CodeLinePlugin.withComponent(CodeLineElement),
|
||||
CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
|
||||
];
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { createPlatePlugin } from 'platejs/react';
|
||||
|
||||
import { FloatingToolbar } from '@/components/ui/floating-toolbar';
|
||||
import { FloatingToolbarButtons } from '@/components/ui/floating-toolbar-buttons';
|
||||
|
||||
export const FloatingToolbarKit = [
|
||||
createPlatePlugin({
|
||||
key: 'floating-toolbar',
|
||||
render: {
|
||||
afterEditable: () => (
|
||||
<FloatingToolbar>
|
||||
<FloatingToolbarButtons />
|
||||
</FloatingToolbar>
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
19
surfsense_web/components/editor/plugins/indent-kit.tsx
Normal file
19
surfsense_web/components/editor/plugins/indent-kit.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { IndentPlugin } from '@platejs/indent/react';
|
||||
import { KEYS } from 'platejs';
|
||||
|
||||
export const IndentKit = [
|
||||
IndentPlugin.configure({
|
||||
inject: {
|
||||
targetPlugins: [
|
||||
...KEYS.heading,
|
||||
KEYS.p,
|
||||
KEYS.blockquote,
|
||||
KEYS.codeBlock,
|
||||
KEYS.toggle,
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { BaseLinkPlugin } from '@platejs/link';
|
||||
|
||||
import { LinkElementStatic } from '@/components/ui/link-node-static';
|
||||
|
||||
export const BaseLinkKit = [BaseLinkPlugin.withComponent(LinkElementStatic)];
|
||||
15
surfsense_web/components/editor/plugins/link-kit.tsx
Normal file
15
surfsense_web/components/editor/plugins/link-kit.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { LinkPlugin } from '@platejs/link/react';
|
||||
|
||||
import { LinkElement } from '@/components/ui/link-node';
|
||||
import { LinkFloatingToolbar } from '@/components/ui/link-toolbar';
|
||||
|
||||
export const LinkKit = [
|
||||
LinkPlugin.configure({
|
||||
render: {
|
||||
node: LinkElement,
|
||||
afterEditable: () => <LinkFloatingToolbar />,
|
||||
},
|
||||
}),
|
||||
];
|
||||
36
surfsense_web/components/editor/plugins/list-classic-kit.tsx
Normal file
36
surfsense_web/components/editor/plugins/list-classic-kit.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'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),
|
||||
];
|
||||
11
surfsense_web/components/editor/plugins/math-kit.tsx
Normal file
11
surfsense_web/components/editor/plugins/math-kit.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
|
||||
|
||||
import { EquationElement, InlineEquationElement } from '@/components/ui/equation-node';
|
||||
|
||||
export const MathKit = [
|
||||
EquationPlugin.withComponent(EquationElement),
|
||||
InlineEquationPlugin.withComponent(InlineEquationElement),
|
||||
];
|
||||
|
||||
23
surfsense_web/components/editor/plugins/selection-kit.tsx
Normal file
23
surfsense_web/components/editor/plugins/selection-kit.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import { BlockSelectionPlugin } from '@platejs/selection/react';
|
||||
|
||||
import { BlockSelection } from '@/components/ui/block-selection';
|
||||
|
||||
export const SelectionKit = [
|
||||
BlockSelectionPlugin.configure({
|
||||
render: {
|
||||
belowRootNodes: BlockSelection as any,
|
||||
},
|
||||
options: {
|
||||
isSelectable: (element) => {
|
||||
// Exclude specific block types from selection
|
||||
if (['code_line', 'td', 'th'].includes(element.type as string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import { SlashInputPlugin, SlashPlugin } from '@platejs/slash-command/react';
|
||||
import { KEYS } from 'platejs';
|
||||
|
||||
import { SlashInputElement } from '@/components/ui/slash-node';
|
||||
|
||||
export const SlashCommandKit = [
|
||||
SlashPlugin.configure({
|
||||
options: {
|
||||
trigger: '/',
|
||||
triggerPreviousCharPattern: /^\s?$/,
|
||||
triggerQuery: (editor) =>
|
||||
!editor.api.some({
|
||||
match: { type: editor.getType(KEYS.codeBlock) },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
SlashInputPlugin.withComponent(SlashInputElement),
|
||||
];
|
||||
|
||||
20
surfsense_web/components/editor/plugins/table-base-kit.tsx
Normal file
20
surfsense_web/components/editor/plugins/table-base-kit.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
BaseTableCellHeaderPlugin,
|
||||
BaseTableCellPlugin,
|
||||
BaseTablePlugin,
|
||||
BaseTableRowPlugin,
|
||||
} from '@platejs/table';
|
||||
|
||||
import {
|
||||
TableCellElementStatic,
|
||||
TableCellHeaderElementStatic,
|
||||
TableElementStatic,
|
||||
TableRowElementStatic,
|
||||
} from '@/components/ui/table-node-static';
|
||||
|
||||
export const BaseTableKit = [
|
||||
BaseTablePlugin.withComponent(TableElementStatic),
|
||||
BaseTableRowPlugin.withComponent(TableRowElementStatic),
|
||||
BaseTableCellPlugin.withComponent(TableCellElementStatic),
|
||||
BaseTableCellHeaderPlugin.withComponent(TableCellHeaderElementStatic),
|
||||
];
|
||||
22
surfsense_web/components/editor/plugins/table-kit.tsx
Normal file
22
surfsense_web/components/editor/plugins/table-kit.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
TableCellHeaderPlugin,
|
||||
TableCellPlugin,
|
||||
TablePlugin,
|
||||
TableRowPlugin,
|
||||
} from '@platejs/table/react';
|
||||
|
||||
import {
|
||||
TableCellElement,
|
||||
TableCellHeaderElement,
|
||||
TableElement,
|
||||
TableRowElement,
|
||||
} from '@/components/ui/table-node';
|
||||
|
||||
export const TableKit = [
|
||||
TablePlugin.withComponent(TableElement),
|
||||
TableRowPlugin.withComponent(TableRowElement),
|
||||
TableCellPlugin.withComponent(TableCellElement),
|
||||
TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
|
||||
];
|
||||
13
surfsense_web/components/editor/plugins/toggle-kit.tsx
Normal file
13
surfsense_web/components/editor/plugins/toggle-kit.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { TogglePlugin } from '@platejs/toggle/react';
|
||||
|
||||
import { ToggleElement } from '@/components/ui/toggle-node';
|
||||
|
||||
export const ToggleKit = [
|
||||
TogglePlugin.configure({
|
||||
node: { component: ToggleElement },
|
||||
shortcuts: { toggle: { keys: 'mod+alt+9' } },
|
||||
}),
|
||||
];
|
||||
|
||||
184
surfsense_web/components/editor/transforms.ts
Normal file
184
surfsense_web/components/editor/transforms.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
'use client';
|
||||
|
||||
import type { PlateEditor } from 'platejs/react';
|
||||
|
||||
import { insertCallout } from '@platejs/callout';
|
||||
import { insertCodeBlock, toggleCodeBlock } from '@platejs/code-block';
|
||||
import { triggerFloatingLink } from '@platejs/link/react';
|
||||
import { insertInlineEquation } from '@platejs/math';
|
||||
import { TablePlugin } from '@platejs/table/react';
|
||||
import {
|
||||
type NodeEntry,
|
||||
type Path,
|
||||
type TElement,
|
||||
KEYS,
|
||||
PathApi,
|
||||
} from 'platejs';
|
||||
|
||||
const insertList = (editor: PlateEditor, type: string) => {
|
||||
editor.tf.insertNodes(
|
||||
editor.api.create.block({
|
||||
indent: 1,
|
||||
listStyleType: type,
|
||||
}),
|
||||
{ select: true }
|
||||
);
|
||||
};
|
||||
|
||||
const insertBlockMap: Record<
|
||||
string,
|
||||
(editor: PlateEditor, type: string) => void
|
||||
> = {
|
||||
[KEYS.listTodo]: insertList,
|
||||
[KEYS.ol]: insertList,
|
||||
[KEYS.ul]: insertList,
|
||||
[KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),
|
||||
[KEYS.table]: (editor) =>
|
||||
editor.getTransforms(TablePlugin).insert.table({}, { select: true }),
|
||||
[KEYS.callout]: (editor) => insertCallout(editor, { select: true }),
|
||||
[KEYS.toggle]: (editor) => {
|
||||
editor.tf.insertNodes(
|
||||
editor.api.create.block({ type: KEYS.toggle }),
|
||||
{ select: true }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const insertInlineMap: Record<
|
||||
string,
|
||||
(editor: PlateEditor, type: string) => void
|
||||
> = {
|
||||
[KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),
|
||||
[KEYS.equation]: (editor) => insertInlineEquation(editor),
|
||||
};
|
||||
|
||||
type InsertBlockOptions = {
|
||||
upsert?: boolean;
|
||||
};
|
||||
|
||||
export const insertBlock = (
|
||||
editor: PlateEditor,
|
||||
type: string,
|
||||
options: InsertBlockOptions = {}
|
||||
) => {
|
||||
const { upsert = false } = options;
|
||||
|
||||
editor.tf.withoutNormalizing(() => {
|
||||
const block = editor.api.block();
|
||||
|
||||
if (!block) return;
|
||||
|
||||
const [currentNode, path] = block;
|
||||
const isCurrentBlockEmpty = editor.api.isEmpty(currentNode);
|
||||
const currentBlockType = getBlockType(currentNode);
|
||||
|
||||
const isSameBlockType = type === currentBlockType;
|
||||
|
||||
if (upsert && isCurrentBlockEmpty && isSameBlockType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type in insertBlockMap) {
|
||||
insertBlockMap[type](editor, type);
|
||||
} else {
|
||||
editor.tf.insertNodes(editor.api.create.block({ type }), {
|
||||
at: PathApi.next(path),
|
||||
select: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSameBlockType) {
|
||||
editor.tf.removeNodes({ previousEmptyBlock: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const insertInlineElement = (editor: PlateEditor, type: string) => {
|
||||
if (insertInlineMap[type]) {
|
||||
insertInlineMap[type](editor, type);
|
||||
}
|
||||
};
|
||||
|
||||
const setList = (
|
||||
editor: PlateEditor,
|
||||
type: string,
|
||||
entry: NodeEntry<TElement>
|
||||
) => {
|
||||
editor.tf.setNodes(
|
||||
editor.api.create.block({
|
||||
indent: 1,
|
||||
listStyleType: type,
|
||||
}),
|
||||
{
|
||||
at: entry[1],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setBlockMap: Record<
|
||||
string,
|
||||
(editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => void
|
||||
> = {
|
||||
[KEYS.listTodo]: setList,
|
||||
[KEYS.ol]: setList,
|
||||
[KEYS.ul]: setList,
|
||||
[KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),
|
||||
[KEYS.callout]: (editor, _type, entry) => {
|
||||
editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] });
|
||||
},
|
||||
[KEYS.toggle]: (editor, _type, entry) => {
|
||||
editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] });
|
||||
},
|
||||
};
|
||||
|
||||
export const setBlockType = (
|
||||
editor: PlateEditor,
|
||||
type: string,
|
||||
{ at }: { at?: Path } = {}
|
||||
) => {
|
||||
editor.tf.withoutNormalizing(() => {
|
||||
const setEntry = (entry: NodeEntry<TElement>) => {
|
||||
const [node, path] = entry;
|
||||
|
||||
if (node[KEYS.listType]) {
|
||||
editor.tf.unsetNodes([KEYS.listType, 'indent'], { at: path });
|
||||
}
|
||||
if (type in setBlockMap) {
|
||||
return setBlockMap[type](editor, type, entry);
|
||||
}
|
||||
if (node.type !== type) {
|
||||
editor.tf.setNodes({ type }, { at: path });
|
||||
}
|
||||
};
|
||||
|
||||
if (at) {
|
||||
const entry = editor.api.node<TElement>(at);
|
||||
|
||||
if (entry) {
|
||||
setEntry(entry);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = editor.api.blocks({ mode: 'lowest' });
|
||||
|
||||
entries.forEach((entry) => {
|
||||
setEntry(entry);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getBlockType = (block: TElement) => {
|
||||
if (block[KEYS.listType]) {
|
||||
if (block[KEYS.listType] === KEYS.ol) {
|
||||
return KEYS.ol;
|
||||
}
|
||||
if (block[KEYS.listType] === KEYS.listTodo) {
|
||||
return KEYS.listTodo;
|
||||
}
|
||||
return KEYS.ul;
|
||||
}
|
||||
|
||||
return block.type;
|
||||
};
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, SaveIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
|
||||
import {
|
||||
|
|
@ -112,6 +113,10 @@ function ReportPanelContent({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Editor state — tracks the latest markdown from the Plate editor
|
||||
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||
|
||||
// Version state
|
||||
const [activeReportId, setActiveReportId] = useState(reportId);
|
||||
|
|
@ -165,17 +170,25 @@ function ReportPanelContent({
|
|||
};
|
||||
}, [activeReportId, shareToken]);
|
||||
|
||||
// Copy markdown content
|
||||
// The current markdown: use edited version if available, otherwise original
|
||||
const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null;
|
||||
|
||||
// Reset edited markdown when switching versions or reports
|
||||
useEffect(() => {
|
||||
setEditedMarkdown(null);
|
||||
}, [activeReportId]);
|
||||
|
||||
// Copy markdown content (uses latest editor content)
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!reportContent?.content) return;
|
||||
if (!currentMarkdown) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reportContent.content);
|
||||
await navigator.clipboard.writeText(currentMarkdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}, [reportContent?.content]);
|
||||
}, [currentMarkdown]);
|
||||
|
||||
// Export report
|
||||
const handleExport = useCallback(
|
||||
|
|
@ -188,9 +201,9 @@ function ReportPanelContent({
|
|||
.slice(0, 80) || "report";
|
||||
try {
|
||||
if (format === "md") {
|
||||
// Download markdown content directly as a .md file
|
||||
if (!reportContent?.content) return;
|
||||
const blob = new Blob([reportContent.content], {
|
||||
// Download markdown content directly as a .md file (uses latest editor content)
|
||||
if (!currentMarkdown) return;
|
||||
const blob = new Blob([currentMarkdown], {
|
||||
type: "text/markdown;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -227,9 +240,40 @@ function ReportPanelContent({
|
|||
setExporting(null);
|
||||
}
|
||||
},
|
||||
[activeReportId, title, reportContent?.content]
|
||||
[activeReportId, title, currentMarkdown]
|
||||
);
|
||||
|
||||
// Save edited report content
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentMarkdown || !activeReportId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: currentMarkdown }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to save report" }));
|
||||
throw new Error(errorData.detail || "Failed to save report");
|
||||
}
|
||||
|
||||
// Update local state to reflect saved content
|
||||
setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev));
|
||||
setEditedMarkdown(null);
|
||||
toast.success("Report saved successfully");
|
||||
} catch (err) {
|
||||
console.error("Error saving report:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save report");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeReportId, currentMarkdown]);
|
||||
|
||||
// Show full-page skeleton only on initial load (no data loaded yet).
|
||||
// Once we have versions/content from a prior fetch, keep the action bar visible.
|
||||
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
|
||||
|
|
@ -258,6 +302,20 @@ function ReportPanelContent({
|
|||
{/* Action bar — always visible after initial load */}
|
||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Save button — only shown for authenticated users with unsaved edits */}
|
||||
{!shareToken && editedMarkdown !== null && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
|
||||
>
|
||||
<SaveIcon className="size-3.5" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Copy button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -370,8 +428,8 @@ function ReportPanelContent({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Report content — skeleton/error/content shown only in this area */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
{/* Report content — skeleton/error/editor shown only in this area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<ReportPanelSkeleton />
|
||||
) : error || !reportContent ? (
|
||||
|
|
@ -381,13 +439,17 @@ function ReportPanelContent({
|
|||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : reportContent.content ? (
|
||||
<PlateEditor
|
||||
markdown={reportContent.content}
|
||||
onMarkdownChange={shareToken ? undefined : setEditedMarkdown}
|
||||
readOnly={!!shareToken}
|
||||
placeholder="Report content..."
|
||||
editorVariant="default"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-5 py-5">
|
||||
{reportContent.content ? (
|
||||
<MarkdownViewer content={reportContent.content} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">No content available.</p>
|
||||
)}
|
||||
<p className="text-muted-foreground italic">No content available.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
44
surfsense_web/components/ui/block-selection.tsx
Normal file
44
surfsense_web/components/ui/block-selection.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { DndPlugin } from '@platejs/dnd';
|
||||
import { useBlockSelected } from '@platejs/selection/react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { type PlateElementProps, usePluginOption } from 'platejs/react';
|
||||
|
||||
export const blockSelectionVariants = cva(
|
||||
'pointer-events-none absolute inset-0 z-1 bg-brand/[.13] transition-opacity',
|
||||
{
|
||||
defaultVariants: {
|
||||
active: true,
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
false: 'opacity-0',
|
||||
true: 'opacity-100',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function BlockSelection(props: PlateElementProps) {
|
||||
const isBlockSelected = useBlockSelected();
|
||||
const isDragging = usePluginOption(DndPlugin, 'isDragging');
|
||||
|
||||
if (
|
||||
!isBlockSelected ||
|
||||
props.plugin.key === 'tr' ||
|
||||
props.plugin.key === 'table'
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={blockSelectionVariants({
|
||||
active: isBlockSelected && !isDragging,
|
||||
})}
|
||||
data-slot="block-selection"
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
surfsense_web/components/ui/blockquote-node-static.tsx
Normal file
13
surfsense_web/components/ui/blockquote-node-static.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { type SlateElementProps, SlateElement } from 'platejs/static';
|
||||
|
||||
export function BlockquoteElementStatic(props: SlateElementProps) {
|
||||
return (
|
||||
<SlateElement
|
||||
as="blockquote"
|
||||
className="my-1 border-l-2 pl-6 italic"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
surfsense_web/components/ui/blockquote-node.tsx
Normal file
13
surfsense_web/components/ui/blockquote-node.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { type PlateElementProps, PlateElement } from 'platejs/react';
|
||||
|
||||
export function BlockquoteElement(props: PlateElementProps) {
|
||||
return (
|
||||
<PlateElement
|
||||
as="blockquote"
|
||||
className="my-1 border-l-2 pl-6 italic"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
83
surfsense_web/components/ui/callout-node.tsx
Normal file
83
surfsense_web/components/ui/callout-node.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { TCalloutElement } from 'platejs';
|
||||
|
||||
import { CalloutPlugin } from '@platejs/callout/react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { type PlateElementProps, PlateElement, useEditorPlugin } from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const calloutVariants = cva(
|
||||
'my-1 flex w-full items-start gap-2 rounded-lg border p-4',
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: 'info',
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
info: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/50',
|
||||
warning: 'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950/50',
|
||||
error: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50',
|
||||
success: 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50',
|
||||
note: 'border-muted bg-muted/50',
|
||||
tip: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-950/50',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const calloutIcons: Record<string, string> = {
|
||||
info: '💡',
|
||||
warning: '⚠️',
|
||||
error: '🚨',
|
||||
success: '✅',
|
||||
note: '📝',
|
||||
tip: '💜',
|
||||
};
|
||||
|
||||
const variantCycle = ['info', 'warning', 'error', 'success', 'note', 'tip'] as const;
|
||||
|
||||
export function CalloutElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps<TCalloutElement>) {
|
||||
const { editor } = useEditorPlugin(CalloutPlugin);
|
||||
const element = props.element;
|
||||
const variant = element.variant || 'info';
|
||||
const icon = element.icon || calloutIcons[variant] || '💡';
|
||||
|
||||
const cycleVariant = React.useCallback(() => {
|
||||
const currentIndex = variantCycle.indexOf(variant as (typeof variantCycle)[number]);
|
||||
const nextIndex = (currentIndex + 1) % variantCycle.length;
|
||||
const nextVariant = variantCycle[nextIndex];
|
||||
|
||||
editor.tf.setNodes(
|
||||
{
|
||||
variant: nextVariant,
|
||||
icon: calloutIcons[nextVariant],
|
||||
},
|
||||
{ at: props.path }
|
||||
);
|
||||
}, [editor, variant, props.path]);
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
className={cn(calloutVariants({ variant: variant as any }), props.className)}
|
||||
>
|
||||
<button
|
||||
className="mt-0.5 shrink-0 cursor-pointer select-none text-lg leading-none"
|
||||
contentEditable={false}
|
||||
onClick={cycleVariant}
|
||||
type="button"
|
||||
aria-label="Change callout type"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
161
surfsense_web/components/ui/code-block-node-static.tsx
Normal file
161
surfsense_web/components/ui/code-block-node-static.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { TCodeBlockElement } from 'platejs';
|
||||
|
||||
import {
|
||||
type SlateElementProps,
|
||||
type SlateLeafProps,
|
||||
SlateElement,
|
||||
SlateLeaf,
|
||||
} from 'platejs/static';
|
||||
|
||||
export function CodeBlockElementStatic(
|
||||
props: SlateElementProps<TCodeBlockElement>
|
||||
) {
|
||||
return (
|
||||
<SlateElement
|
||||
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
|
||||
{...props}
|
||||
>
|
||||
<div className="relative rounded-md bg-muted/50">
|
||||
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
|
||||
<code>{props.children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeLineElementStatic(props: SlateElementProps) {
|
||||
return <SlateElement {...props} />;
|
||||
}
|
||||
|
||||
export function CodeSyntaxLeafStatic(props: SlateLeafProps) {
|
||||
const tokenClassName = props.leaf.className as string;
|
||||
|
||||
return <SlateLeaf className={tokenClassName} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOCX-compatible code block components.
|
||||
* Uses inline styles for proper rendering in Word documents.
|
||||
*/
|
||||
|
||||
export function CodeBlockElementDocx(
|
||||
props: SlateElementProps<TCodeBlockElement>
|
||||
) {
|
||||
return (
|
||||
<SlateElement {...props}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8pt 0',
|
||||
padding: '12pt',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeLineElementDocx(props: SlateElementProps) {
|
||||
return (
|
||||
<SlateElement
|
||||
{...props}
|
||||
as="p"
|
||||
style={{
|
||||
fontFamily: "'Courier New', Consolas, monospace",
|
||||
fontSize: '10pt',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Syntax highlighting color map for common token types
|
||||
const syntaxColors: Record<string, string> = {
|
||||
'hljs-addition': '#22863a',
|
||||
'hljs-attr': '#005cc5',
|
||||
'hljs-attribute': '#005cc5',
|
||||
'hljs-built_in': '#e36209',
|
||||
'hljs-bullet': '#735c0f',
|
||||
'hljs-comment': '#6a737d',
|
||||
'hljs-deletion': '#b31d28',
|
||||
'hljs-doctag': '#d73a49',
|
||||
'hljs-emphasis': '#24292e',
|
||||
'hljs-formula': '#6a737d',
|
||||
'hljs-keyword': '#d73a49',
|
||||
'hljs-literal': '#005cc5',
|
||||
'hljs-meta': '#005cc5',
|
||||
'hljs-name': '#22863a',
|
||||
'hljs-number': '#005cc5',
|
||||
'hljs-operator': '#005cc5',
|
||||
'hljs-quote': '#22863a',
|
||||
'hljs-regexp': '#032f62',
|
||||
'hljs-section': '#005cc5',
|
||||
'hljs-selector-attr': '#005cc5',
|
||||
'hljs-selector-class': '#005cc5',
|
||||
'hljs-selector-id': '#005cc5',
|
||||
'hljs-selector-pseudo': '#22863a',
|
||||
'hljs-selector-tag': '#22863a',
|
||||
'hljs-string': '#032f62',
|
||||
'hljs-strong': '#24292e',
|
||||
'hljs-symbol': '#e36209',
|
||||
'hljs-template-tag': '#d73a49',
|
||||
'hljs-template-variable': '#d73a49',
|
||||
'hljs-title': '#6f42c1',
|
||||
'hljs-type': '#d73a49',
|
||||
'hljs-variable': '#005cc5',
|
||||
};
|
||||
|
||||
// Convert regular spaces to non-breaking spaces to preserve indentation in Word
|
||||
const preserveSpaces = (text: string): string => {
|
||||
// Replace regular spaces with non-breaking spaces
|
||||
return text.replace(/ /g, '\u00A0');
|
||||
};
|
||||
|
||||
export function CodeSyntaxLeafDocx(props: SlateLeafProps) {
|
||||
const tokenClassName = props.leaf.className as string;
|
||||
|
||||
// Extract color from className
|
||||
let color: string | undefined;
|
||||
let fontWeight: string | undefined;
|
||||
let fontStyle: string | undefined;
|
||||
|
||||
if (tokenClassName) {
|
||||
const classes = tokenClassName.split(' ');
|
||||
for (const cls of classes) {
|
||||
if (syntaxColors[cls]) {
|
||||
color = syntaxColors[cls];
|
||||
}
|
||||
if (cls === 'hljs-strong' || cls === 'hljs-section') {
|
||||
fontWeight = 'bold';
|
||||
}
|
||||
if (cls === 'hljs-emphasis') {
|
||||
fontStyle = 'italic';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the text content and preserve spaces
|
||||
const text = props.leaf.text as string;
|
||||
const preservedText = preserveSpaces(text);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slate-leaf="true"
|
||||
style={{
|
||||
color,
|
||||
fontFamily: "'Courier New', Consolas, monospace",
|
||||
fontSize: '10pt',
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
}}
|
||||
>
|
||||
{preservedText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
289
surfsense_web/components/ui/code-block-node.tsx
Normal file
289
surfsense_web/components/ui/code-block-node.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { formatCodeBlock, isLangSupported } from '@platejs/code-block';
|
||||
import { BracesIcon, Check, CheckIcon, CopyIcon } from 'lucide-react';
|
||||
import { type TCodeBlockElement, type TCodeSyntaxLeaf, NodeApi } from 'platejs';
|
||||
import {
|
||||
type PlateElementProps,
|
||||
type PlateLeafProps,
|
||||
PlateElement,
|
||||
PlateLeaf,
|
||||
} from 'platejs/react';
|
||||
import { useEditorRef, useElement, useReadOnly } from 'platejs/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CodeBlockElement(props: PlateElementProps<TCodeBlockElement>) {
|
||||
const { editor, element } = props;
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
|
||||
{...props}
|
||||
>
|
||||
<div className="relative rounded-md bg-muted/50">
|
||||
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
|
||||
<code>{props.children}</code>
|
||||
</pre>
|
||||
|
||||
<div
|
||||
className="absolute top-1 right-1 z-10 flex select-none gap-0.5"
|
||||
contentEditable={false}
|
||||
>
|
||||
{isLangSupported(element.lang) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 text-xs"
|
||||
onClick={() => formatCodeBlock(editor, { element })}
|
||||
title="Format code"
|
||||
>
|
||||
<BracesIcon className="!size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<CodeBlockCombobox />
|
||||
|
||||
<CopyButton
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 gap-1 text-muted-foreground text-xs"
|
||||
value={() => NodeApi.string(element)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockCombobox() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const readOnly = useReadOnly();
|
||||
const editor = useEditorRef();
|
||||
const element = useElement<TCodeBlockElement>();
|
||||
const value = element.lang || 'plaintext';
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
languages.filter(
|
||||
(language) =>
|
||||
!searchValue ||
|
||||
language.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
),
|
||||
[searchValue]
|
||||
);
|
||||
|
||||
if (readOnly) return null;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 select-none justify-between gap-1 px-2 text-muted-foreground text-xs"
|
||||
aria-expanded={open}
|
||||
role="combobox"
|
||||
>
|
||||
{languages.find((language) => language.value === value)?.label ??
|
||||
'Plain Text'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-0"
|
||||
onCloseAutoFocus={() => setSearchValue('')}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
className="h-9"
|
||||
value={searchValue}
|
||||
onValueChange={(value) => setSearchValue(value)}
|
||||
placeholder="Search language..."
|
||||
/>
|
||||
<CommandEmpty>No language found.</CommandEmpty>
|
||||
|
||||
<CommandList className="h-[344px] overflow-y-auto">
|
||||
<CommandGroup>
|
||||
{items.map((language) => (
|
||||
<CommandItem
|
||||
key={language.label}
|
||||
className="cursor-pointer"
|
||||
value={language.value}
|
||||
onSelect={(value) => {
|
||||
editor.tf.setNodes<TCodeBlockElement>(
|
||||
{ lang: value },
|
||||
{ at: element }
|
||||
);
|
||||
setSearchValue(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
value === language.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{language.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
value,
|
||||
...props
|
||||
}: { value: (() => string) | string } & Omit<
|
||||
React.ComponentProps<typeof Button>,
|
||||
'value'
|
||||
>) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setHasCopied(false);
|
||||
}, 2000);
|
||||
}, [hasCopied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
typeof value === 'function' ? value() : value
|
||||
);
|
||||
setHasCopied(true);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? (
|
||||
<CheckIcon className="!size-3" />
|
||||
) : (
|
||||
<CopyIcon className="!size-3" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeLineElement(props: PlateElementProps) {
|
||||
return <PlateElement {...props} />;
|
||||
}
|
||||
|
||||
export function CodeSyntaxLeaf(props: PlateLeafProps<TCodeSyntaxLeaf>) {
|
||||
const tokenClassName = props.leaf.className as string;
|
||||
|
||||
return <PlateLeaf className={tokenClassName} {...props} />;
|
||||
}
|
||||
|
||||
const languages: { label: string; value: string }[] = [
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'Plain Text', value: 'plaintext' },
|
||||
{ label: 'ABAP', value: 'abap' },
|
||||
{ label: 'Agda', value: 'agda' },
|
||||
{ label: 'Arduino', value: 'arduino' },
|
||||
{ label: 'ASCII Art', value: 'ascii' },
|
||||
{ label: 'Assembly', value: 'x86asm' },
|
||||
{ label: 'Bash', value: 'bash' },
|
||||
{ label: 'BASIC', value: 'basic' },
|
||||
{ label: 'BNF', value: 'bnf' },
|
||||
{ label: 'C', value: 'c' },
|
||||
{ label: 'C#', value: 'csharp' },
|
||||
{ label: 'C++', value: 'cpp' },
|
||||
{ label: 'Clojure', value: 'clojure' },
|
||||
{ label: 'CoffeeScript', value: 'coffeescript' },
|
||||
{ label: 'Coq', value: 'coq' },
|
||||
{ label: 'CSS', value: 'css' },
|
||||
{ label: 'Dart', value: 'dart' },
|
||||
{ label: 'Dhall', value: 'dhall' },
|
||||
{ label: 'Diff', value: 'diff' },
|
||||
{ label: 'Docker', value: 'dockerfile' },
|
||||
{ label: 'EBNF', value: 'ebnf' },
|
||||
{ label: 'Elixir', value: 'elixir' },
|
||||
{ label: 'Elm', value: 'elm' },
|
||||
{ label: 'Erlang', value: 'erlang' },
|
||||
{ label: 'F#', value: 'fsharp' },
|
||||
{ label: 'Flow', value: 'flow' },
|
||||
{ label: 'Fortran', value: 'fortran' },
|
||||
{ label: 'Gherkin', value: 'gherkin' },
|
||||
{ label: 'GLSL', value: 'glsl' },
|
||||
{ label: 'Go', value: 'go' },
|
||||
{ label: 'GraphQL', value: 'graphql' },
|
||||
{ label: 'Groovy', value: 'groovy' },
|
||||
{ label: 'Haskell', value: 'haskell' },
|
||||
{ label: 'HCL', value: 'hcl' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
{ label: 'Idris', value: 'idris' },
|
||||
{ label: 'Java', value: 'java' },
|
||||
{ label: 'JavaScript', value: 'javascript' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'Julia', value: 'julia' },
|
||||
{ label: 'Kotlin', value: 'kotlin' },
|
||||
{ label: 'LaTeX', value: 'latex' },
|
||||
{ label: 'Less', value: 'less' },
|
||||
{ label: 'Lisp', value: 'lisp' },
|
||||
{ label: 'LiveScript', value: 'livescript' },
|
||||
{ label: 'LLVM IR', value: 'llvm' },
|
||||
{ label: 'Lua', value: 'lua' },
|
||||
{ label: 'Makefile', value: 'makefile' },
|
||||
{ label: 'Markdown', value: 'markdown' },
|
||||
{ label: 'Markup', value: 'markup' },
|
||||
{ label: 'MATLAB', value: 'matlab' },
|
||||
{ label: 'Mathematica', value: 'mathematica' },
|
||||
{ label: 'Mermaid', value: 'mermaid' },
|
||||
{ label: 'Nix', value: 'nix' },
|
||||
{ label: 'Notion Formula', value: 'notion' },
|
||||
{ label: 'Objective-C', value: 'objectivec' },
|
||||
{ label: 'OCaml', value: 'ocaml' },
|
||||
{ label: 'Pascal', value: 'pascal' },
|
||||
{ label: 'Perl', value: 'perl' },
|
||||
{ label: 'PHP', value: 'php' },
|
||||
{ label: 'PowerShell', value: 'powershell' },
|
||||
{ label: 'Prolog', value: 'prolog' },
|
||||
{ label: 'Protocol Buffers', value: 'protobuf' },
|
||||
{ label: 'PureScript', value: 'purescript' },
|
||||
{ label: 'Python', value: 'python' },
|
||||
{ label: 'R', value: 'r' },
|
||||
{ label: 'Racket', value: 'racket' },
|
||||
{ label: 'Reason', value: 'reasonml' },
|
||||
{ label: 'Ruby', value: 'ruby' },
|
||||
{ label: 'Rust', value: 'rust' },
|
||||
{ label: 'Sass', value: 'scss' },
|
||||
{ label: 'Scala', value: 'scala' },
|
||||
{ label: 'Scheme', value: 'scheme' },
|
||||
{ label: 'SCSS', value: 'scss' },
|
||||
{ label: 'Shell', value: 'shell' },
|
||||
{ label: 'Smalltalk', value: 'smalltalk' },
|
||||
{ label: 'Solidity', value: 'solidity' },
|
||||
{ label: 'SQL', value: 'sql' },
|
||||
{ label: 'Swift', value: 'swift' },
|
||||
{ label: 'TOML', value: 'toml' },
|
||||
{ label: 'TypeScript', value: 'typescript' },
|
||||
{ label: 'VB.Net', value: 'vbnet' },
|
||||
{ label: 'Verilog', value: 'verilog' },
|
||||
{ label: 'VHDL', value: 'vhdl' },
|
||||
{ label: 'Visual Basic', value: 'vbnet' },
|
||||
{ label: 'WebAssembly', value: 'wasm' },
|
||||
{ label: 'XML', value: 'xml' },
|
||||
{ label: 'YAML', value: 'yaml' },
|
||||
];
|
||||
17
surfsense_web/components/ui/code-node-static.tsx
Normal file
17
surfsense_web/components/ui/code-node-static.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { SlateLeafProps } from 'platejs/static';
|
||||
|
||||
import { SlateLeaf } from 'platejs/static';
|
||||
|
||||
export function CodeLeafStatic(props: SlateLeafProps) {
|
||||
return (
|
||||
<SlateLeaf
|
||||
{...props}
|
||||
as="code"
|
||||
className="whitespace-pre-wrap rounded-md bg-muted px-[0.3em] py-[0.2em] font-mono text-sm"
|
||||
>
|
||||
{props.children}
|
||||
</SlateLeaf>
|
||||
);
|
||||
}
|
||||
19
surfsense_web/components/ui/code-node.tsx
Normal file
19
surfsense_web/components/ui/code-node.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateLeafProps } from 'platejs/react';
|
||||
|
||||
import { PlateLeaf } from 'platejs/react';
|
||||
|
||||
export function CodeLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf
|
||||
{...props}
|
||||
as="code"
|
||||
className="whitespace-pre-wrap rounded-md bg-muted px-[0.3em] py-[0.2em] font-mono text-sm"
|
||||
>
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
55
surfsense_web/components/ui/editor-static.tsx
Normal file
55
surfsense_web/components/ui/editor-static.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { type PlateStaticProps, PlateStatic } from 'platejs/static';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const editorVariants = cva(
|
||||
cn(
|
||||
'group/editor',
|
||||
'relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words',
|
||||
'rounded-md ring-offset-background focus-visible:outline-none',
|
||||
'placeholder:text-muted-foreground/80 **:data-slate-placeholder:top-[auto_!important] **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!',
|
||||
'[&_strong]:font-bold'
|
||||
),
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: 'none',
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: 'cursor-not-allowed opacity-50',
|
||||
},
|
||||
focused: {
|
||||
true: 'ring-2 ring-ring ring-offset-2',
|
||||
},
|
||||
variant: {
|
||||
ai: 'w-full px-0 text-base md:text-sm',
|
||||
aiChat:
|
||||
'max-h-[min(70vh,320px)] w-full overflow-y-auto px-5 py-3 text-base md:text-sm',
|
||||
default:
|
||||
'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
|
||||
demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
|
||||
fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24',
|
||||
none: '',
|
||||
select: 'px-3 py-2 text-base data-readonly:w-fit',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function EditorStatic({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: PlateStaticProps & VariantProps<typeof editorVariants>) {
|
||||
return (
|
||||
<PlateStatic
|
||||
className={cn(editorVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
132
surfsense_web/components/ui/editor.tsx
Normal file
132
surfsense_web/components/ui/editor.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { PlateContentProps, PlateViewProps } from 'platejs/react';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { PlateContainer, PlateContent, PlateView } from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const editorContainerVariants = cva(
|
||||
'relative w-full cursor-text select-text overflow-y-auto caret-primary selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15',
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
comment: cn(
|
||||
'flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm',
|
||||
'rounded-md border-[1.5px] border-transparent bg-transparent',
|
||||
'has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30',
|
||||
'has-aria-disabled:border-input has-aria-disabled:bg-muted'
|
||||
),
|
||||
default: 'h-full',
|
||||
demo: 'h-[650px]',
|
||||
select: cn(
|
||||
'group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
||||
'has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]'
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function EditorContainer({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof editorContainerVariants>) {
|
||||
return (
|
||||
<PlateContainer
|
||||
className={cn(
|
||||
'ignore-click-outside/toolbar',
|
||||
editorContainerVariants({ variant }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const editorVariants = cva(
|
||||
cn(
|
||||
'group/editor',
|
||||
'relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words',
|
||||
'rounded-md ring-offset-background focus-visible:outline-none',
|
||||
'**:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 placeholder:text-muted-foreground/80 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!',
|
||||
'[&_strong]:font-bold'
|
||||
),
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: 'cursor-not-allowed opacity-50',
|
||||
},
|
||||
focused: {
|
||||
true: 'ring-2 ring-ring ring-offset-2',
|
||||
},
|
||||
variant: {
|
||||
ai: 'w-full px-0 text-base md:text-sm',
|
||||
aiChat:
|
||||
'max-h-[min(70vh,320px)] w-full overflow-y-auto px-3 py-2 text-base md:text-sm',
|
||||
comment: cn('rounded-none border-none bg-transparent text-sm'),
|
||||
default:
|
||||
'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
|
||||
demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]',
|
||||
fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24',
|
||||
none: '',
|
||||
select: 'px-3 py-2 text-base data-readonly:w-fit',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type EditorProps = PlateContentProps &
|
||||
VariantProps<typeof editorVariants>;
|
||||
|
||||
export const Editor = ({
|
||||
className,
|
||||
disabled,
|
||||
focused,
|
||||
variant,
|
||||
ref,
|
||||
...props
|
||||
}: EditorProps & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<PlateContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
editorVariants({
|
||||
disabled,
|
||||
focused,
|
||||
variant,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
disableDefaultStyles
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Editor.displayName = 'Editor';
|
||||
|
||||
export function EditorView({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: PlateViewProps & VariantProps<typeof editorVariants>) {
|
||||
return (
|
||||
<PlateView
|
||||
{...props}
|
||||
className={cn(editorVariants({ variant }), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EditorView.displayName = 'EditorView';
|
||||
183
surfsense_web/components/ui/equation-node.tsx
Normal file
183
surfsense_web/components/ui/equation-node.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { TEquationElement } from 'platejs';
|
||||
|
||||
import {
|
||||
useEquationElement,
|
||||
useEquationInput,
|
||||
} from '@platejs/math/react';
|
||||
import { RadicalIcon } from 'lucide-react';
|
||||
import { type PlateElementProps, PlateElement, useSelected } from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function EquationElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps<TEquationElement>) {
|
||||
const element = props.element;
|
||||
const selected = useSelected();
|
||||
const katexRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
useEquationElement({
|
||||
element,
|
||||
katexRef,
|
||||
options: {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { props: inputProps, ref: inputRef, onDismiss, onSubmit } = useEquationInput({
|
||||
isInline: false,
|
||||
open: isEditing,
|
||||
onClose: () => setIsEditing(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
className={cn(
|
||||
'my-3 rounded-md py-2',
|
||||
selected && 'ring-2 ring-ring ring-offset-2',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-center"
|
||||
contentEditable={false}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
{element.texExpression ? (
|
||||
<div ref={katexRef} className="text-center" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RadicalIcon className="size-4" />
|
||||
<span>Add an equation</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div
|
||||
className="mt-2 rounded-md border bg-muted/50 p-2"
|
||||
contentEditable={false}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="w-full resize-none rounded border-none bg-transparent p-2 font-mono text-sm outline-none"
|
||||
placeholder="E = mc^2"
|
||||
rows={3}
|
||||
{...inputProps}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineEquationElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps<TEquationElement>) {
|
||||
const element = props.element;
|
||||
const selected = useSelected();
|
||||
const katexRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
useEquationElement({
|
||||
element,
|
||||
katexRef,
|
||||
options: {
|
||||
displayMode: false,
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { props: inputProps, ref: inputRef, onDismiss, onSubmit } = useEquationInput({
|
||||
isInline: true,
|
||||
open: isEditing,
|
||||
onClose: () => setIsEditing(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
as="span"
|
||||
className={cn(
|
||||
'inline rounded-sm px-0.5',
|
||||
selected && 'bg-brand/15',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
contentEditable={false}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
{element.texExpression ? (
|
||||
<span ref={katexRef} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<RadicalIcon className="inline size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isEditing && (
|
||||
<span
|
||||
className="absolute z-50 mt-1 rounded-md border bg-popover p-2 shadow-md"
|
||||
contentEditable={false}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="w-48 resize-none rounded border-none bg-transparent p-1 font-mono text-sm outline-none"
|
||||
placeholder="x^2"
|
||||
rows={1}
|
||||
{...inputProps}
|
||||
/>
|
||||
<span className="mt-1 flex justify-end gap-1">
|
||||
<button
|
||||
className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
27
surfsense_web/components/ui/equation-toolbar-button.tsx
Normal file
27
surfsense_web/components/ui/equation-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { insertInlineEquation } from '@platejs/math';
|
||||
import { RadicalIcon } from 'lucide-react';
|
||||
import { useEditorRef } from 'platejs/react';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
export function InlineEquationToolbarButton(
|
||||
props: React.ComponentProps<typeof ToolbarButton>
|
||||
) {
|
||||
const editor = useEditorRef();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
{...props}
|
||||
onClick={() => {
|
||||
insertInlineEquation(editor);
|
||||
}}
|
||||
tooltip="Mark as equation"
|
||||
>
|
||||
<RadicalIcon />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
65
surfsense_web/components/ui/floating-toolbar-buttons.tsx
Normal file
65
surfsense_web/components/ui/floating-toolbar-buttons.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
BoldIcon,
|
||||
Code2Icon,
|
||||
ItalicIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
} from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import { useEditorReadOnly } from 'platejs/react';
|
||||
|
||||
import { LinkToolbarButton } from './link-toolbar-button';
|
||||
import { MarkToolbarButton } from './mark-toolbar-button';
|
||||
import { MoreToolbarButton } from './more-toolbar-button';
|
||||
import { ToolbarGroup } from './toolbar';
|
||||
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
|
||||
|
||||
export function FloatingToolbarButtons() {
|
||||
const readOnly = useEditorReadOnly();
|
||||
|
||||
if (readOnly) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarGroup>
|
||||
<TurnIntoToolbarButton />
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip="Bold (⌘+B)">
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip="Italic (⌘+I)">
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.underline}
|
||||
tooltip="Underline (⌘+U)"
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.strikethrough}
|
||||
tooltip="Strikethrough (⌘+⇧+M)"
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.code} tooltip="Code (⌘+E)">
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<LinkToolbarButton />
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<MoreToolbarButton />
|
||||
</ToolbarGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
surfsense_web/components/ui/floating-toolbar.tsx
Normal file
86
surfsense_web/components/ui/floating-toolbar.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
type FloatingToolbarState,
|
||||
flip,
|
||||
offset,
|
||||
useFloatingToolbar,
|
||||
useFloatingToolbarState,
|
||||
} from '@platejs/floating';
|
||||
import { useComposedRef } from '@udecode/cn';
|
||||
import { KEYS } from 'platejs';
|
||||
import {
|
||||
useEditorId,
|
||||
useEventEditorValue,
|
||||
usePluginOption,
|
||||
} from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Toolbar } from './toolbar';
|
||||
|
||||
export function FloatingToolbar({
|
||||
children,
|
||||
className,
|
||||
state,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Toolbar> & {
|
||||
state?: FloatingToolbarState;
|
||||
}) {
|
||||
const editorId = useEditorId();
|
||||
const focusedEditorId = useEventEditorValue('focus');
|
||||
const isFloatingLinkOpen = !!usePluginOption({ key: KEYS.link }, 'mode');
|
||||
|
||||
const floatingToolbarState = useFloatingToolbarState({
|
||||
editorId,
|
||||
focusedEditorId,
|
||||
hideToolbar: isFloatingLinkOpen,
|
||||
...state,
|
||||
floatingOptions: {
|
||||
middleware: [
|
||||
offset(12),
|
||||
flip({
|
||||
fallbackPlacements: [
|
||||
'top-start',
|
||||
'top-end',
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
],
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
placement: 'top',
|
||||
...state?.floatingOptions,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
clickOutsideRef,
|
||||
hidden,
|
||||
props: rootProps,
|
||||
ref: floatingRef,
|
||||
} = useFloatingToolbar(floatingToolbarState);
|
||||
|
||||
const ref = useComposedRef<HTMLDivElement>(props.ref, floatingRef);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<div ref={clickOutsideRef}>
|
||||
<Toolbar
|
||||
{...props}
|
||||
{...rootProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden',
|
||||
'max-w-[80vw]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
surfsense_web/components/ui/heading-node-static.tsx
Normal file
72
surfsense_web/components/ui/heading-node-static.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { SlateElementProps } from 'platejs/static';
|
||||
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { SlateElement } from 'platejs/static';
|
||||
|
||||
const headingVariants = cva('relative mb-1', {
|
||||
variants: {
|
||||
variant: {
|
||||
h1: 'mt-[1.6em] pb-1 font-bold font-heading text-4xl',
|
||||
h2: 'mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight',
|
||||
h3: 'mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight',
|
||||
h4: 'mt-[0.75em] font-heading font-semibold text-lg tracking-tight',
|
||||
h5: 'mt-[0.75em] font-semibold text-lg tracking-tight',
|
||||
h6: 'mt-[0.75em] font-semibold text-base tracking-tight',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function HeadingElementStatic({
|
||||
variant = 'h1',
|
||||
...props
|
||||
}: SlateElementProps & VariantProps<typeof headingVariants>) {
|
||||
const id = props.element.id as string | undefined;
|
||||
|
||||
return (
|
||||
<SlateElement
|
||||
as={variant!}
|
||||
className={headingVariants({ variant })}
|
||||
{...props}
|
||||
>
|
||||
{/* Bookmark anchor for DOCX TOC internal links */}
|
||||
{id && <span id={id} />}
|
||||
{props.children}
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function H1ElementStatic(props: SlateElementProps) {
|
||||
return <HeadingElementStatic variant="h1" {...props} />;
|
||||
}
|
||||
|
||||
export function H2ElementStatic(
|
||||
props: React.ComponentProps<typeof HeadingElementStatic>
|
||||
) {
|
||||
return <HeadingElementStatic variant="h2" {...props} />;
|
||||
}
|
||||
|
||||
export function H3ElementStatic(
|
||||
props: React.ComponentProps<typeof HeadingElementStatic>
|
||||
) {
|
||||
return <HeadingElementStatic variant="h3" {...props} />;
|
||||
}
|
||||
|
||||
export function H4ElementStatic(
|
||||
props: React.ComponentProps<typeof HeadingElementStatic>
|
||||
) {
|
||||
return <HeadingElementStatic variant="h4" {...props} />;
|
||||
}
|
||||
|
||||
export function H5ElementStatic(
|
||||
props: React.ComponentProps<typeof HeadingElementStatic>
|
||||
) {
|
||||
return <HeadingElementStatic variant="h5" {...props} />;
|
||||
}
|
||||
|
||||
export function H6ElementStatic(
|
||||
props: React.ComponentProps<typeof HeadingElementStatic>
|
||||
) {
|
||||
return <HeadingElementStatic variant="h6" {...props} />;
|
||||
}
|
||||
60
surfsense_web/components/ui/heading-node.tsx
Normal file
60
surfsense_web/components/ui/heading-node.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateElementProps } from 'platejs/react';
|
||||
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { PlateElement } from 'platejs/react';
|
||||
|
||||
const headingVariants = cva('relative mb-1', {
|
||||
variants: {
|
||||
variant: {
|
||||
h1: 'mt-[1.6em] pb-1 font-bold font-heading text-4xl',
|
||||
h2: 'mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight',
|
||||
h3: 'mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight',
|
||||
h4: 'mt-[0.75em] font-heading font-semibold text-lg tracking-tight',
|
||||
h5: 'mt-[0.75em] font-semibold text-lg tracking-tight',
|
||||
h6: 'mt-[0.75em] font-semibold text-base tracking-tight',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function HeadingElement({
|
||||
variant = 'h1',
|
||||
...props
|
||||
}: PlateElementProps & VariantProps<typeof headingVariants>) {
|
||||
return (
|
||||
<PlateElement
|
||||
as={variant!}
|
||||
className={headingVariants({ variant })}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function H1Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h1" {...props} />;
|
||||
}
|
||||
|
||||
export function H2Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h2" {...props} />;
|
||||
}
|
||||
|
||||
export function H3Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h3" {...props} />;
|
||||
}
|
||||
|
||||
export function H4Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h4" {...props} />;
|
||||
}
|
||||
|
||||
export function H5Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h5" {...props} />;
|
||||
}
|
||||
|
||||
export function H6Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h6" {...props} />;
|
||||
}
|
||||
13
surfsense_web/components/ui/highlight-node-static.tsx
Normal file
13
surfsense_web/components/ui/highlight-node-static.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { SlateLeafProps } from 'platejs/static';
|
||||
|
||||
import { SlateLeaf } from 'platejs/static';
|
||||
|
||||
export function HighlightLeafStatic(props: SlateLeafProps) {
|
||||
return (
|
||||
<SlateLeaf {...props} as="mark" className="bg-highlight/30 text-inherit">
|
||||
{props.children}
|
||||
</SlateLeaf>
|
||||
);
|
||||
}
|
||||
15
surfsense_web/components/ui/highlight-node.tsx
Normal file
15
surfsense_web/components/ui/highlight-node.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateLeafProps } from 'platejs/react';
|
||||
|
||||
import { PlateLeaf } from 'platejs/react';
|
||||
|
||||
export function HighlightLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf {...props} as="mark" className="bg-highlight/30 text-inherit">
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
22
surfsense_web/components/ui/hr-node-static.tsx
Normal file
22
surfsense_web/components/ui/hr-node-static.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { SlateElementProps } from 'platejs/static';
|
||||
|
||||
import { SlateElement } from 'platejs/static';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function HrElementStatic(props: SlateElementProps) {
|
||||
return (
|
||||
<SlateElement {...props}>
|
||||
<div className="cursor-text py-6" contentEditable={false}>
|
||||
<hr
|
||||
className={cn(
|
||||
'h-0.5 rounded-sm border-none bg-muted bg-clip-content'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{props.children}
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
35
surfsense_web/components/ui/hr-node.tsx
Normal file
35
surfsense_web/components/ui/hr-node.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateElementProps } from 'platejs/react';
|
||||
|
||||
import {
|
||||
PlateElement,
|
||||
useFocused,
|
||||
useReadOnly,
|
||||
useSelected,
|
||||
} from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function HrElement(props: PlateElementProps) {
|
||||
const readOnly = useReadOnly();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
||||
return (
|
||||
<PlateElement {...props}>
|
||||
<div className="py-6" contentEditable={false}>
|
||||
<hr
|
||||
className={cn(
|
||||
'h-0.5 rounded-sm border-none bg-muted bg-clip-content',
|
||||
selected && focused && 'ring-2 ring-ring ring-offset-2',
|
||||
!readOnly && 'cursor-pointer'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
17
surfsense_web/components/ui/kbd-node-static.tsx
Normal file
17
surfsense_web/components/ui/kbd-node-static.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { SlateLeafProps } from 'platejs/static';
|
||||
|
||||
import { SlateLeaf } from 'platejs/static';
|
||||
|
||||
export function KbdLeafStatic(props: SlateLeafProps) {
|
||||
return (
|
||||
<SlateLeaf
|
||||
{...props}
|
||||
as="kbd"
|
||||
className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-sm shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(248,_249,_250)_0px_1px_5px_0px_inset,_rgb(193,_200,_205)_0px_0px_0px_0.5px,_rgb(193,_200,_205)_0px_2px_1px_-1px,_rgb(193,_200,_205)_0px_1px_0px_0px] dark:shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(26,_29,_30)_0px_1px_5px_0px_inset,_rgb(76,_81,_85)_0px_0px_0px_0.5px,_rgb(76,_81,_85)_0px_2px_1px_-1px,_rgb(76,_81,_85)_0px_1px_0px_0px]"
|
||||
>
|
||||
{props.children}
|
||||
</SlateLeaf>
|
||||
);
|
||||
}
|
||||
19
surfsense_web/components/ui/kbd-node.tsx
Normal file
19
surfsense_web/components/ui/kbd-node.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateLeafProps } from 'platejs/react';
|
||||
|
||||
import { PlateLeaf } from 'platejs/react';
|
||||
|
||||
export function KbdLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf
|
||||
{...props}
|
||||
as="kbd"
|
||||
className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-sm shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(248,_249,_250)_0px_1px_5px_0px_inset,_rgb(193,_200,_205)_0px_0px_0px_0.5px,_rgb(193,_200,_205)_0px_2px_1px_-1px,_rgb(193,_200,_205)_0px_1px_0px_0px] dark:shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(26,_29,_30)_0px_1px_5px_0px_inset,_rgb(76,_81,_85)_0px_0px_0px_0.5px,_rgb(76,_81,_85)_0px_2px_1px_-1px,_rgb(76,_81,_85)_0px_1px_0px_0px]"
|
||||
>
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
23
surfsense_web/components/ui/link-node-static.tsx
Normal file
23
surfsense_web/components/ui/link-node-static.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { TLinkElement } from 'platejs';
|
||||
import type { SlateElementProps } from 'platejs/static';
|
||||
|
||||
import { getLinkAttributes } from '@platejs/link';
|
||||
import { SlateElement } from 'platejs/static';
|
||||
|
||||
export function LinkElementStatic(props: SlateElementProps<TLinkElement>) {
|
||||
return (
|
||||
<SlateElement
|
||||
{...props}
|
||||
as="a"
|
||||
className="font-medium text-primary underline decoration-primary underline-offset-4"
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
...getLinkAttributes(props.editor, props.element),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
32
surfsense_web/components/ui/link-node.tsx
Normal file
32
surfsense_web/components/ui/link-node.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { TLinkElement } from 'platejs';
|
||||
import type { PlateElementProps } from 'platejs/react';
|
||||
|
||||
import { getLinkAttributes } from '@platejs/link';
|
||||
import { PlateElement } from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function LinkElement(props: PlateElementProps<TLinkElement>) {
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
as="a"
|
||||
className={cn(
|
||||
'font-medium text-primary underline decoration-primary underline-offset-4'
|
||||
)}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
...getLinkAttributes(props.editor, props.element),
|
||||
onMouseOver: (e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
24
surfsense_web/components/ui/link-toolbar-button.tsx
Normal file
24
surfsense_web/components/ui/link-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
useLinkToolbarButton,
|
||||
useLinkToolbarButtonState,
|
||||
} from '@platejs/link/react';
|
||||
import { Link } from 'lucide-react';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
export function LinkToolbarButton(
|
||||
props: React.ComponentProps<typeof ToolbarButton>
|
||||
) {
|
||||
const state = useLinkToolbarButtonState();
|
||||
const { props: buttonProps } = useLinkToolbarButton(state);
|
||||
|
||||
return (
|
||||
<ToolbarButton {...props} {...buttonProps} data-plate-focus tooltip="Link">
|
||||
<Link />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
208
surfsense_web/components/ui/link-toolbar.tsx
Normal file
208
surfsense_web/components/ui/link-toolbar.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { TLinkElement } from 'platejs';
|
||||
|
||||
import {
|
||||
type UseVirtualFloatingOptions,
|
||||
flip,
|
||||
offset,
|
||||
} from '@platejs/floating';
|
||||
import { getLinkAttributes } from '@platejs/link';
|
||||
import {
|
||||
type LinkFloatingToolbarState,
|
||||
FloatingLinkUrlInput,
|
||||
useFloatingLinkEdit,
|
||||
useFloatingLinkEditState,
|
||||
useFloatingLinkInsert,
|
||||
useFloatingLinkInsertState,
|
||||
} from '@platejs/link/react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ExternalLink, Link, Text, Unlink } from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import {
|
||||
useEditorRef,
|
||||
useEditorSelection,
|
||||
useFormInputProps,
|
||||
usePluginOption,
|
||||
} from 'platejs/react';
|
||||
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
const popoverVariants = cva(
|
||||
'z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden'
|
||||
);
|
||||
|
||||
const inputVariants = cva(
|
||||
'flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-transparent md:text-sm'
|
||||
);
|
||||
|
||||
export function LinkFloatingToolbar({
|
||||
state,
|
||||
}: {
|
||||
state?: LinkFloatingToolbarState;
|
||||
}) {
|
||||
const activeCommentId = usePluginOption({ key: KEYS.comment }, 'activeId');
|
||||
const activeSuggestionId = usePluginOption(
|
||||
{ key: KEYS.suggestion },
|
||||
'activeId'
|
||||
);
|
||||
|
||||
const floatingOptions: UseVirtualFloatingOptions = React.useMemo(
|
||||
() => ({
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({
|
||||
fallbackPlacements: ['bottom-end', 'top-start', 'top-end'],
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
placement:
|
||||
activeSuggestionId || activeCommentId ? 'top-start' : 'bottom-start',
|
||||
}),
|
||||
[activeCommentId, activeSuggestionId]
|
||||
);
|
||||
|
||||
const insertState = useFloatingLinkInsertState({
|
||||
...state,
|
||||
floatingOptions: {
|
||||
...floatingOptions,
|
||||
...state?.floatingOptions,
|
||||
},
|
||||
});
|
||||
const {
|
||||
hidden,
|
||||
props: insertProps,
|
||||
ref: insertRef,
|
||||
textInputProps,
|
||||
} = useFloatingLinkInsert(insertState);
|
||||
|
||||
const editState = useFloatingLinkEditState({
|
||||
...state,
|
||||
floatingOptions: {
|
||||
...floatingOptions,
|
||||
...state?.floatingOptions,
|
||||
},
|
||||
});
|
||||
const {
|
||||
editButtonProps,
|
||||
props: editProps,
|
||||
ref: editRef,
|
||||
unlinkButtonProps,
|
||||
} = useFloatingLinkEdit(editState);
|
||||
const inputProps = useFormInputProps({
|
||||
preventDefaultOnEnterKeydown: true,
|
||||
});
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const input = (
|
||||
<div className="flex w-[330px] flex-col" {...inputProps}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
|
||||
<Link className="size-4" />
|
||||
</div>
|
||||
|
||||
<FloatingLinkUrlInput
|
||||
className={inputVariants()}
|
||||
placeholder="Paste link"
|
||||
data-plate-focus
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
|
||||
<Text className="size-4" />
|
||||
</div>
|
||||
<input
|
||||
className={inputVariants()}
|
||||
placeholder="Text to display"
|
||||
data-plate-focus
|
||||
{...textInputProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editContent = editState.isEditing ? (
|
||||
input
|
||||
) : (
|
||||
<div className="box-content flex items-center">
|
||||
<button
|
||||
className={buttonVariants({ size: 'sm', variant: 'ghost' })}
|
||||
type="button"
|
||||
{...editButtonProps}
|
||||
>
|
||||
Edit link
|
||||
</button>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<LinkOpenButton />
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<button
|
||||
className={buttonVariants({
|
||||
size: 'sm',
|
||||
variant: 'ghost',
|
||||
})}
|
||||
type="button"
|
||||
{...unlinkButtonProps}
|
||||
>
|
||||
<Unlink width={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={insertRef} className={popoverVariants()} {...insertProps}>
|
||||
{input}
|
||||
</div>
|
||||
|
||||
<div ref={editRef} className={popoverVariants()} {...editProps}>
|
||||
{editContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOpenButton() {
|
||||
const editor = useEditorRef();
|
||||
const selection = useEditorSelection();
|
||||
|
||||
const attributes = React.useMemo(
|
||||
() => {
|
||||
const entry = editor.api.node<TLinkElement>({
|
||||
match: { type: editor.getType(KEYS.link) },
|
||||
});
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
const [element] = entry;
|
||||
return getLinkAttributes(editor, element);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[editor, selection]
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attributes}
|
||||
className={buttonVariants({
|
||||
size: 'sm',
|
||||
variant: 'ghost',
|
||||
})}
|
||||
onMouseOver={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label="Open link in a new tab"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink width={18} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
105
surfsense_web/components/ui/list-classic-node.tsx
Normal file
105
surfsense_web/components/ui/list-classic-node.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
69
surfsense_web/components/ui/list-classic-toolbar-button.tsx
Normal file
69
surfsense_web/components/ui/list-classic-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { indentListItems, unindentListItems } from '@platejs/list-classic';
|
||||
import {
|
||||
useListToolbarButton,
|
||||
useListToolbarButtonState,
|
||||
} from '@platejs/list-classic/react';
|
||||
import {
|
||||
IndentIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
OutdentIcon,
|
||||
} from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import { useEditorRef } from 'platejs/react';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
const nodeTypeMap: Record<string, { icon: React.JSX.Element; label: string }> =
|
||||
{
|
||||
[KEYS.olClassic]: { icon: <ListOrdered />, label: 'Numbered List' },
|
||||
[KEYS.taskList]: { icon: <ListTodo />, label: 'Task List' },
|
||||
[KEYS.ulClassic]: { icon: <List />, label: 'Bulleted List' },
|
||||
};
|
||||
|
||||
export function ListToolbarButton({
|
||||
nodeType = KEYS.ulClassic,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarButton> & {
|
||||
nodeType?: string;
|
||||
}) {
|
||||
const state = useListToolbarButtonState({ nodeType });
|
||||
const { props: buttonProps } = useListToolbarButton(state);
|
||||
const { icon, label } = nodeTypeMap[nodeType] ?? nodeTypeMap[KEYS.ulClassic];
|
||||
|
||||
return (
|
||||
<ToolbarButton {...props} {...buttonProps} tooltip={label}>
|
||||
{icon}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function IndentToolbarButton({
|
||||
reverse = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarButton> & {
|
||||
reverse?: boolean;
|
||||
}) {
|
||||
const editor = useEditorRef();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
{...props}
|
||||
onClick={() => {
|
||||
if (reverse) {
|
||||
unindentListItems(editor);
|
||||
} else {
|
||||
indentListItems(editor);
|
||||
}
|
||||
}}
|
||||
tooltip={reverse ? 'Outdent' : 'Indent'}
|
||||
>
|
||||
{reverse ? <OutdentIcon /> : <IndentIcon />}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
21
surfsense_web/components/ui/mark-toolbar-button.tsx
Normal file
21
surfsense_web/components/ui/mark-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { useMarkToolbarButton, useMarkToolbarButtonState } from 'platejs/react';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
export function MarkToolbarButton({
|
||||
clear,
|
||||
nodeType,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarButton> & {
|
||||
nodeType: string;
|
||||
clear?: string[] | string;
|
||||
}) {
|
||||
const state = useMarkToolbarButtonState({ clear, nodeType });
|
||||
const { props: buttonProps } = useMarkToolbarButton(state);
|
||||
|
||||
return <ToolbarButton {...props} {...buttonProps} />;
|
||||
}
|
||||
108
surfsense_web/components/ui/more-toolbar-button.tsx
Normal file
108
surfsense_web/components/ui/more-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
import { insertInlineEquation } from '@platejs/math';
|
||||
import {
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
MoreHorizontalIcon,
|
||||
RadicalIcon,
|
||||
SubscriptIcon,
|
||||
SuperscriptIcon,
|
||||
} from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import { useEditorRef } from 'platejs/react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
export function MoreToolbarButton(props: DropdownMenuProps) {
|
||||
const editor = useEditorRef();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton pressed={open} tooltip="Insert">
|
||||
<MoreHorizontalIcon />
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="ignore-click-outside/toolbar flex max-h-[500px] min-w-[180px] flex-col overflow-y-auto"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
editor.tf.toggleMark(KEYS.kbd);
|
||||
editor.tf.collapse({ edge: 'end' });
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<KeyboardIcon />
|
||||
Keyboard input
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
editor.tf.toggleMark(KEYS.sup, {
|
||||
remove: KEYS.sub,
|
||||
});
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<SuperscriptIcon />
|
||||
Superscript
|
||||
{/* (⌘+,) */}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
editor.tf.toggleMark(KEYS.sub, {
|
||||
remove: KEYS.sup,
|
||||
});
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<SubscriptIcon />
|
||||
Subscript
|
||||
{/* (⌘+.) */}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
insertInlineEquation(editor);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<RadicalIcon />
|
||||
Equation
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
editor.tf.insertNodes(
|
||||
editor.api.create.block({ type: KEYS.callout }),
|
||||
{ select: true }
|
||||
);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<InfoIcon />
|
||||
Callout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
15
surfsense_web/components/ui/paragraph-node-static.tsx
Normal file
15
surfsense_web/components/ui/paragraph-node-static.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { SlateElementProps } from 'platejs/static';
|
||||
|
||||
import { SlateElement } from 'platejs/static';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ParagraphElementStatic(props: SlateElementProps) {
|
||||
return (
|
||||
<SlateElement {...props} className={cn('m-0 px-0 py-1')}>
|
||||
{props.children}
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
17
surfsense_web/components/ui/paragraph-node.tsx
Normal file
17
surfsense_web/components/ui/paragraph-node.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateElementProps } from 'platejs/react';
|
||||
|
||||
import { PlateElement } from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ParagraphElement(props: PlateElementProps) {
|
||||
return (
|
||||
<PlateElement {...props} className={cn('m-0 px-0 py-1')}>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
89
surfsense_web/components/ui/resize-handle.tsx
Normal file
89
surfsense_web/components/ui/resize-handle.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
import {
|
||||
type ResizeHandle as ResizeHandlePrimitive,
|
||||
Resizable as ResizablePrimitive,
|
||||
useResizeHandle,
|
||||
useResizeHandleState,
|
||||
} from '@platejs/resizable';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const mediaResizeHandleVariants = cva(
|
||||
cn(
|
||||
'top-0 flex w-6 select-none flex-col justify-center',
|
||||
"after:flex after:h-16 after:w-[3px] after:rounded-[6px] after:bg-ring after:opacity-0 after:content-['_'] group-hover:after:opacity-100"
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
direction: {
|
||||
left: '-left-3 -ml-3 pl-3',
|
||||
right: '-right-3 -mr-3 items-end pr-3',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const resizeHandleVariants = cva('absolute z-40', {
|
||||
variants: {
|
||||
direction: {
|
||||
bottom: 'w-full cursor-row-resize',
|
||||
left: 'h-full cursor-col-resize',
|
||||
right: 'h-full cursor-col-resize',
|
||||
top: 'w-full cursor-row-resize',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function ResizeHandle({
|
||||
className,
|
||||
options,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizeHandlePrimitive> &
|
||||
VariantProps<typeof resizeHandleVariants>) {
|
||||
const state = useResizeHandleState(options ?? {});
|
||||
const resizeHandle = useResizeHandle(state);
|
||||
|
||||
if (state.readOnly) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
resizeHandleVariants({ direction: options?.direction }),
|
||||
className
|
||||
)}
|
||||
data-resizing={state.isResizing}
|
||||
{...resizeHandle.props}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const resizableVariants = cva('', {
|
||||
variants: {
|
||||
align: {
|
||||
center: 'mx-auto',
|
||||
left: 'mr-auto',
|
||||
right: 'ml-auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function Resizable({
|
||||
align,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive> &
|
||||
VariantProps<typeof resizableVariants>) {
|
||||
return (
|
||||
<ResizablePrimitive
|
||||
{...props}
|
||||
className={cn(resizableVariants({ align }), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
211
surfsense_web/components/ui/slash-node.tsx
Normal file
211
surfsense_web/components/ui/slash-node.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { PlateElementProps } from 'platejs/react';
|
||||
|
||||
import { SlashInputPlugin } from '@platejs/slash-command/react';
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
Code2Icon,
|
||||
Columns2Icon,
|
||||
FileCodeIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
MinusIcon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
RadicalIcon,
|
||||
SquareIcon,
|
||||
TableIcon,
|
||||
} from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import { PlateElement, useEditorPlugin, useEditorRef } from 'platejs/react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { insertBlock, insertInlineElement } from '@/components/editor/transforms';
|
||||
|
||||
interface SlashCommandItem {
|
||||
icon: React.ReactNode;
|
||||
keywords: string[];
|
||||
label: string;
|
||||
onSelect: (editor: any) => void;
|
||||
}
|
||||
|
||||
const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [
|
||||
{
|
||||
heading: 'Basic Blocks',
|
||||
items: [
|
||||
{
|
||||
icon: <PilcrowIcon />,
|
||||
keywords: ['paragraph', 'text', 'plain'],
|
||||
label: 'Text',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.p),
|
||||
},
|
||||
{
|
||||
icon: <Heading1Icon />,
|
||||
keywords: ['title', 'h1', 'heading'],
|
||||
label: 'Heading 1',
|
||||
onSelect: (editor) => insertBlock(editor, 'h1'),
|
||||
},
|
||||
{
|
||||
icon: <Heading2Icon />,
|
||||
keywords: ['subtitle', 'h2', 'heading'],
|
||||
label: 'Heading 2',
|
||||
onSelect: (editor) => insertBlock(editor, 'h2'),
|
||||
},
|
||||
{
|
||||
icon: <Heading3Icon />,
|
||||
keywords: ['subtitle', 'h3', 'heading'],
|
||||
label: 'Heading 3',
|
||||
onSelect: (editor) => insertBlock(editor, 'h3'),
|
||||
},
|
||||
{
|
||||
icon: <QuoteIcon />,
|
||||
keywords: ['citation', 'blockquote'],
|
||||
label: 'Quote',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.blockquote),
|
||||
},
|
||||
{
|
||||
icon: <MinusIcon />,
|
||||
keywords: ['divider', 'separator', 'line'],
|
||||
label: 'Divider',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.hr),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Lists',
|
||||
items: [
|
||||
{
|
||||
icon: <ListIcon />,
|
||||
keywords: ['unordered', 'ul', 'bullet'],
|
||||
label: 'Bulleted list',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.ul),
|
||||
},
|
||||
{
|
||||
icon: <ListOrderedIcon />,
|
||||
keywords: ['ordered', 'ol', 'numbered'],
|
||||
label: 'Numbered list',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.ol),
|
||||
},
|
||||
{
|
||||
icon: <SquareIcon />,
|
||||
keywords: ['checklist', 'task', 'checkbox', 'todo'],
|
||||
label: 'To-do list',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.listTodo),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Advanced',
|
||||
items: [
|
||||
{
|
||||
icon: <TableIcon />,
|
||||
keywords: ['table', 'grid'],
|
||||
label: 'Table',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.table),
|
||||
},
|
||||
{
|
||||
icon: <FileCodeIcon />,
|
||||
keywords: ['code', 'codeblock', 'snippet'],
|
||||
label: 'Code block',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.codeBlock),
|
||||
},
|
||||
{
|
||||
icon: <InfoIcon />,
|
||||
keywords: ['callout', 'note', 'info', 'warning', 'tip'],
|
||||
label: 'Callout',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.callout),
|
||||
},
|
||||
{
|
||||
icon: <ChevronRightIcon />,
|
||||
keywords: ['toggle', 'collapsible', 'expand'],
|
||||
label: 'Toggle',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.toggle),
|
||||
},
|
||||
{
|
||||
icon: <RadicalIcon />,
|
||||
keywords: ['equation', 'math', 'formula', 'latex'],
|
||||
label: 'Equation',
|
||||
onSelect: (editor) => insertInlineElement(editor, KEYS.equation),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Inline',
|
||||
items: [
|
||||
{
|
||||
icon: <Code2Icon />,
|
||||
keywords: ['link', 'url', 'href'],
|
||||
label: 'Link',
|
||||
onSelect: (editor) => insertInlineElement(editor, KEYS.link),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function SlashInputElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps) {
|
||||
const { editor, setOption } = useEditorPlugin(SlashInputPlugin);
|
||||
|
||||
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>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
862
surfsense_web/components/ui/table-icons.tsx
Normal file
862
surfsense_web/components/ui/table-icons.tsx
Normal file
|
|
@ -0,0 +1,862 @@
|
|||
'use client';
|
||||
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
|
||||
export function BorderAllIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border All</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M0.25 1C0.25 0.585786 0.585786 0.25 1 0.25H14C14.4142 0.25 14.75 0.585786 14.75 1V14C14.75 14.4142 14.4142 14.75 14 14.75H1C0.585786 14.75 0.25 14.4142 0.25 14V1ZM1.75 1.75V13.25H13.25V1.75H1.75Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderBottomIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Bottom</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M1 13.25L14 13.25V14.75L1 14.75V13.25Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="11" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderLeftIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Left</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M1.75 1L1.75 14L0.249999 14L0.25 1L1.75 1Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 10 7)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 10 13)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 12 7)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 12 13)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 7)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 7)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 13)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 13)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 5)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="5"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 5)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="5"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 3)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="3"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 3)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="3"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 9)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="9"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 9)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="9"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 11)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="11"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 11)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="11"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 6 7)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 6 13)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 4 7)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 4 13)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 10 1)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 12 1)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 1)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 1)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 6 1)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 4 1)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderNoneIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border None</title>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7.025" />
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
width="1"
|
||||
x="13"
|
||||
y="13.025"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7.025" />
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
width="1"
|
||||
x="11"
|
||||
y="13.025"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11.025" />
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
width="1"
|
||||
x="13"
|
||||
y="11.025"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11.025" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderRightIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Right</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M13.25 1L13.25 14L14.75 14L14.75 1L13.25 1Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 5 7)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 5 13)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 3 7)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 3 13)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 7)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 7)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 13)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 13)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 5)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 5)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 3)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 3)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 9)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 9)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 11)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 11)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 9 7)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 9 13)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 11 7)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 11 13)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 5 1)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 3 1)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 7 1)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 1 1)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 9 1)"
|
||||
width="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="matrix(0 1 1 0 11 1)"
|
||||
width="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderTopIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Top</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M14 1.75L1 1.75L1 0.249999L14 0.25L14 1.75Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 10)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 10)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 12)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="12"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 12)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="12"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 8)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 14)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 8)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 14)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 10 8)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 10 14)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 12 8)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 12 14)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 6 8)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 6 14)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 4 8)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 4 14)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 6)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="6"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 6)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="6"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 4)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 4)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 10)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 12)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="12"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 8)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 14)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 6)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="6"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 4)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
101
surfsense_web/components/ui/table-node-static.tsx
Normal file
101
surfsense_web/components/ui/table-node-static.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import type { TTableCellElement, TTableElement } from 'platejs';
|
||||
import type { SlateElementProps } from 'platejs/static';
|
||||
|
||||
import { BaseTablePlugin } from '@platejs/table';
|
||||
import { SlateElement } from 'platejs/static';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TableElementStatic({
|
||||
children,
|
||||
...props
|
||||
}: SlateElementProps<TTableElement>) {
|
||||
const { disableMarginLeft } = props.editor.getOptions(BaseTablePlugin);
|
||||
const marginLeft = disableMarginLeft ? 0 : props.element.marginLeft;
|
||||
|
||||
return (
|
||||
<SlateElement
|
||||
{...props}
|
||||
className="overflow-x-auto py-5"
|
||||
style={{ paddingLeft: marginLeft }}
|
||||
>
|
||||
<div className="group/table relative w-fit">
|
||||
<table
|
||||
className="mr-0 ml-px table h-px table-fixed border-collapse"
|
||||
style={{ borderCollapse: 'collapse', width: '100%' }}
|
||||
>
|
||||
<tbody className="min-w-full">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRowElementStatic(props: SlateElementProps) {
|
||||
return (
|
||||
<SlateElement {...props} as="tr" className="h-full">
|
||||
{props.children}
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCellElementStatic({
|
||||
isHeader,
|
||||
...props
|
||||
}: SlateElementProps<TTableCellElement> & {
|
||||
isHeader?: boolean;
|
||||
}) {
|
||||
const { editor, element } = props;
|
||||
const { api } = editor.getPlugin(BaseTablePlugin);
|
||||
|
||||
const { minHeight, width } = api.table.getCellSize({ element });
|
||||
const borders = api.table.getCellBorders({ element });
|
||||
|
||||
return (
|
||||
<SlateElement
|
||||
{...props}
|
||||
as={isHeader ? 'th' : 'td'}
|
||||
className={cn(
|
||||
'h-full overflow-visible border-none bg-background p-0',
|
||||
element.background ? 'bg-(--cellBackground)' : 'bg-background',
|
||||
isHeader && 'text-left font-normal *:m-0',
|
||||
'before:size-full',
|
||||
"before:absolute before:box-border before:select-none before:content-['']",
|
||||
borders &&
|
||||
cn(
|
||||
borders.bottom?.size && 'before:border-b before:border-b-border',
|
||||
borders.right?.size && 'before:border-r before:border-r-border',
|
||||
borders.left?.size && 'before:border-l before:border-l-border',
|
||||
borders.top?.size && 'before:border-t before:border-t-border'
|
||||
)
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--cellBackground': element.background,
|
||||
maxWidth: width || 240,
|
||||
minWidth: width || 120,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
colSpan: api.table.getColSpan(element),
|
||||
rowSpan: api.table.getRowSpan(element),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative z-20 box-border h-full px-4 py-2"
|
||||
style={{ minHeight }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</SlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCellHeaderElementStatic(
|
||||
props: SlateElementProps<TTableCellElement>
|
||||
) {
|
||||
return <TableCellElementStatic {...props} isHeader />;
|
||||
}
|
||||
657
surfsense_web/components/ui/table-node.tsx
Normal file
657
surfsense_web/components/ui/table-node.tsx
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
import { useDraggable, useDropLine } from '@platejs/dnd';
|
||||
import {
|
||||
BlockSelectionPlugin,
|
||||
useBlockSelected,
|
||||
} from '@platejs/selection/react';
|
||||
import { setCellBackground } from '@platejs/table';
|
||||
import {
|
||||
TablePlugin,
|
||||
TableProvider,
|
||||
useTableBordersDropdownMenuContentState,
|
||||
useTableCellElement,
|
||||
useTableCellElementResizable,
|
||||
useTableElement,
|
||||
useTableMergeState,
|
||||
} from '@platejs/table/react';
|
||||
import { PopoverAnchor } from '@radix-ui/react-popover';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
CombineIcon,
|
||||
EraserIcon,
|
||||
Grid2X2Icon,
|
||||
GripVertical,
|
||||
PaintBucketIcon,
|
||||
SquareSplitHorizontalIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type TElement,
|
||||
type TTableCellElement,
|
||||
type TTableElement,
|
||||
type TTableRowElement,
|
||||
KEYS,
|
||||
PathApi,
|
||||
} from 'platejs';
|
||||
import {
|
||||
type PlateElementProps,
|
||||
PlateElement,
|
||||
useComposedRef,
|
||||
useEditorPlugin,
|
||||
useEditorRef,
|
||||
useEditorSelector,
|
||||
useElement,
|
||||
useFocusedLast,
|
||||
usePluginOption,
|
||||
useReadOnly,
|
||||
useRemoveNodeButton,
|
||||
useSelected,
|
||||
withHOC,
|
||||
} from 'platejs/react';
|
||||
import { useElementSelector } from 'platejs/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Popover, PopoverContent } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { blockSelectionVariants } from './block-selection';
|
||||
import {
|
||||
ColorDropdownMenuItems,
|
||||
DEFAULT_COLORS,
|
||||
} from './font-color-toolbar-button';
|
||||
import { ResizeHandle } from './resize-handle';
|
||||
import {
|
||||
BorderAllIcon,
|
||||
BorderBottomIcon,
|
||||
BorderLeftIcon,
|
||||
BorderNoneIcon,
|
||||
BorderRightIcon,
|
||||
BorderTopIcon,
|
||||
} from './table-icons';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarButton,
|
||||
ToolbarGroup,
|
||||
ToolbarMenuGroup,
|
||||
} from './toolbar';
|
||||
export const TableElement = withHOC(
|
||||
TableProvider,
|
||||
function TableElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps<TTableElement>) {
|
||||
const readOnly = useReadOnly();
|
||||
const isSelectionAreaVisible = usePluginOption(
|
||||
BlockSelectionPlugin,
|
||||
'isSelectionAreaVisible'
|
||||
);
|
||||
const hasControls = !readOnly && !isSelectionAreaVisible;
|
||||
const {
|
||||
isSelectingCell,
|
||||
marginLeft,
|
||||
props: tableProps,
|
||||
} = useTableElement();
|
||||
|
||||
const isSelectingTable = useBlockSelected(props.element.id as string);
|
||||
|
||||
const content = (
|
||||
<PlateElement
|
||||
{...props}
|
||||
className={cn(
|
||||
'overflow-x-auto py-5',
|
||||
hasControls && '-ml-2 *:data-[slot=block-selection]:left-2'
|
||||
)}
|
||||
style={{ paddingLeft: marginLeft }}
|
||||
>
|
||||
<div className="group/table relative w-fit">
|
||||
<table
|
||||
className={cn(
|
||||
'mr-0 ml-px table h-px w-full table-fixed border-collapse',
|
||||
isSelectingCell && 'selection:bg-transparent'
|
||||
)}
|
||||
{...tableProps}
|
||||
>
|
||||
<tbody className="min-w-full">{children}</tbody>
|
||||
</table>
|
||||
|
||||
{isSelectingTable && (
|
||||
<div className={blockSelectionVariants()} contentEditable={false} />
|
||||
)}
|
||||
</div>
|
||||
</PlateElement>
|
||||
);
|
||||
|
||||
if (readOnly) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <TableFloatingToolbar>{content}</TableFloatingToolbar>;
|
||||
}
|
||||
);
|
||||
|
||||
function TableFloatingToolbar({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverContent>) {
|
||||
const { tf } = useEditorPlugin(TablePlugin);
|
||||
const selected = useSelected();
|
||||
const element = useElement<TTableElement>();
|
||||
const { props: buttonProps } = useRemoveNodeButton({ element });
|
||||
const collapsedInside = useEditorSelector(
|
||||
(editor) => selected && editor.api.isCollapsed(),
|
||||
[selected]
|
||||
);
|
||||
const isFocusedLast = useFocusedLast();
|
||||
|
||||
const { canMerge, canSplit } = useTableMergeState();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isFocusedLast && (canMerge || canSplit || collapsedInside)}
|
||||
modal={false}
|
||||
>
|
||||
<PopoverAnchor asChild>{children}</PopoverAnchor>
|
||||
<PopoverContent
|
||||
asChild
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
contentEditable={false}
|
||||
{...props}
|
||||
>
|
||||
<Toolbar
|
||||
className="scrollbar-hide flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border bg-popover p-1 shadow-md print:hidden"
|
||||
contentEditable={false}
|
||||
>
|
||||
<ToolbarGroup>
|
||||
<ColorDropdownMenu tooltip="Background color">
|
||||
<PaintBucketIcon />
|
||||
</ColorDropdownMenu>
|
||||
{canMerge && (
|
||||
<ToolbarButton
|
||||
onClick={() => tf.table.merge()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Merge cells"
|
||||
>
|
||||
<CombineIcon />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{canSplit && (
|
||||
<ToolbarButton
|
||||
onClick={() => tf.table.split()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Split cell"
|
||||
>
|
||||
<SquareSplitHorizontalIcon />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton tooltip="Cell borders">
|
||||
<Grid2X2Icon />
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<TableBordersDropdownMenuContent />
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
|
||||
{collapsedInside && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton tooltip="Delete table" {...buttonProps}>
|
||||
<Trash2Icon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</ToolbarGroup>
|
||||
|
||||
{collapsedInside && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableRow({ before: true });
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert row before"
|
||||
>
|
||||
<ArrowUp />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableRow();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert row after"
|
||||
>
|
||||
<ArrowDown />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.remove.tableRow();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Delete row"
|
||||
>
|
||||
<XIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
|
||||
{collapsedInside && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableColumn({ before: true });
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert column before"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableColumn();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert column after"
|
||||
>
|
||||
<ArrowRight />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.remove.tableColumn();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Delete column"
|
||||
>
|
||||
<XIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</Toolbar>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBordersDropdownMenuContent(
|
||||
props: React.ComponentProps<typeof DropdownMenuPrimitive.Content>
|
||||
) {
|
||||
const editor = useEditorRef();
|
||||
const {
|
||||
getOnSelectTableBorder,
|
||||
hasBottomBorder,
|
||||
hasLeftBorder,
|
||||
hasNoBorders,
|
||||
hasOuterBorders,
|
||||
hasRightBorder,
|
||||
hasTopBorder,
|
||||
} = useTableBordersDropdownMenuContentState();
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
className="min-w-[220px]"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={0}
|
||||
{...props}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasTopBorder}
|
||||
onCheckedChange={getOnSelectTableBorder('top')}
|
||||
>
|
||||
<BorderTopIcon />
|
||||
<div>Top Border</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasRightBorder}
|
||||
onCheckedChange={getOnSelectTableBorder('right')}
|
||||
>
|
||||
<BorderRightIcon />
|
||||
<div>Right Border</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasBottomBorder}
|
||||
onCheckedChange={getOnSelectTableBorder('bottom')}
|
||||
>
|
||||
<BorderBottomIcon />
|
||||
<div>Bottom Border</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasLeftBorder}
|
||||
onCheckedChange={getOnSelectTableBorder('left')}
|
||||
>
|
||||
<BorderLeftIcon />
|
||||
<div>Left Border</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasNoBorders}
|
||||
onCheckedChange={getOnSelectTableBorder('none')}
|
||||
>
|
||||
<BorderNoneIcon />
|
||||
<div>No Border</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasOuterBorders}
|
||||
onCheckedChange={getOnSelectTableBorder('outer')}
|
||||
>
|
||||
<BorderAllIcon />
|
||||
<div>Outside Borders</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorDropdownMenu({
|
||||
children,
|
||||
tooltip,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tooltip: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const editor = useEditorRef();
|
||||
const selectedCells = usePluginOption(TablePlugin, 'selectedCells');
|
||||
|
||||
const onUpdateColor = React.useCallback(
|
||||
(color: string) => {
|
||||
setOpen(false);
|
||||
setCellBackground(editor, { color, selectedCells: selectedCells ?? [] });
|
||||
},
|
||||
[selectedCells, editor]
|
||||
);
|
||||
|
||||
const onClearColor = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setCellBackground(editor, {
|
||||
color: null,
|
||||
selectedCells: selectedCells ?? [],
|
||||
});
|
||||
}, [selectedCells, editor]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton tooltip={tooltip}>{children}</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<ToolbarMenuGroup label="Colors">
|
||||
<ColorDropdownMenuItems
|
||||
className="px-2"
|
||||
colors={DEFAULT_COLORS}
|
||||
updateColor={onUpdateColor}
|
||||
/>
|
||||
</ToolbarMenuGroup>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem className="p-2" onClick={onClearColor}>
|
||||
<EraserIcon />
|
||||
<span>Clear</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRowElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps<TTableRowElement>) {
|
||||
const { element } = props;
|
||||
const readOnly = useReadOnly();
|
||||
const selected = useSelected();
|
||||
const editor = useEditorRef();
|
||||
const isSelectionAreaVisible = usePluginOption(
|
||||
BlockSelectionPlugin,
|
||||
'isSelectionAreaVisible'
|
||||
);
|
||||
const hasControls = !readOnly && !isSelectionAreaVisible;
|
||||
|
||||
const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({
|
||||
element,
|
||||
type: element.type,
|
||||
canDropNode: ({ dragEntry, dropEntry }) =>
|
||||
PathApi.equals(
|
||||
PathApi.parent(dragEntry[1]),
|
||||
PathApi.parent(dropEntry[1])
|
||||
),
|
||||
onDropHandler: (_, { dragItem }) => {
|
||||
const dragElement = (dragItem as { element: TElement }).element;
|
||||
|
||||
if (dragElement) {
|
||||
editor.tf.select(dragElement);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
ref={useComposedRef(props.ref, previewRef, nodeRef)}
|
||||
as="tr"
|
||||
className={cn('group/row', isDragging && 'opacity-50')}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
'data-selected': selected ? 'true' : undefined,
|
||||
}}
|
||||
>
|
||||
{hasControls && (
|
||||
<td className="w-2 select-none" contentEditable={false}>
|
||||
<RowDragHandle dragRef={handleRef} />
|
||||
<RowDropLine />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDragHandle({ dragRef }: { dragRef: React.Ref<any> }) {
|
||||
const editor = useEditorRef();
|
||||
const element = useElement();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={dragRef}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'-translate-y-1/2 absolute top-1/2 left-0 z-51 h-6 w-4 p-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'cursor-grab active:cursor-grabbing',
|
||||
'opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-data-[resizing="true"]/row:opacity-0'
|
||||
)}
|
||||
onClick={() => {
|
||||
editor.tf.select(element);
|
||||
}}
|
||||
>
|
||||
<GripVertical className="text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDropLine() {
|
||||
const { dropLine } = useDropLine();
|
||||
|
||||
if (!dropLine) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 left-2 z-50 h-0.5 bg-brand/50',
|
||||
dropLine === 'top' ? '-top-px' : '-bottom-px'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCellElement({
|
||||
isHeader,
|
||||
...props
|
||||
}: PlateElementProps<TTableCellElement> & {
|
||||
isHeader?: boolean;
|
||||
}) {
|
||||
const { api } = useEditorPlugin(TablePlugin);
|
||||
const readOnly = useReadOnly();
|
||||
const element = props.element;
|
||||
|
||||
const tableId = useElementSelector(([node]) => node.id as string, [], {
|
||||
key: KEYS.table,
|
||||
});
|
||||
const rowId = useElementSelector(([node]) => node.id as string, [], {
|
||||
key: KEYS.tr,
|
||||
});
|
||||
const isSelectingTable = useBlockSelected(tableId);
|
||||
const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;
|
||||
const isSelectionAreaVisible = usePluginOption(
|
||||
BlockSelectionPlugin,
|
||||
'isSelectionAreaVisible'
|
||||
);
|
||||
|
||||
const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } =
|
||||
useTableCellElement();
|
||||
|
||||
const { bottomProps, hiddenLeft, leftProps, rightProps } =
|
||||
useTableCellElementResizable({
|
||||
colIndex,
|
||||
colSpan,
|
||||
rowIndex,
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
as={isHeader ? 'th' : 'td'}
|
||||
className={cn(
|
||||
'h-full overflow-visible border-none bg-background p-0',
|
||||
element.background ? 'bg-(--cellBackground)' : 'bg-background',
|
||||
isHeader && 'text-left *:m-0',
|
||||
'before:size-full',
|
||||
selected && 'before:z-10 before:bg-brand/5',
|
||||
"before:absolute before:box-border before:select-none before:content-['']",
|
||||
borders.bottom?.size && 'before:border-b before:border-b-border',
|
||||
borders.right?.size && 'before:border-r before:border-r-border',
|
||||
borders.left?.size && 'before:border-l before:border-l-border',
|
||||
borders.top?.size && 'before:border-t before:border-t-border'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--cellBackground': element.background,
|
||||
minWidth: width || 48,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
colSpan: api.table.getColSpan(element),
|
||||
rowSpan: api.table.getRowSpan(element),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative z-20 box-border h-full px-3 py-2"
|
||||
style={{ minHeight }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{!isSelectionAreaVisible && (
|
||||
<div
|
||||
className="group absolute top-0 size-full select-none"
|
||||
contentEditable={false}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
{...rightProps}
|
||||
className="-top-2 -right-1 h-[calc(100%_+_8px)] w-2"
|
||||
data-col={colIndex}
|
||||
/>
|
||||
<ResizeHandle {...bottomProps} className="-bottom-1 h-2" />
|
||||
{!hiddenLeft && (
|
||||
<ResizeHandle
|
||||
{...leftProps}
|
||||
className="-left-1 top-0 w-2"
|
||||
data-resizer-left={colIndex === 0 ? 'true' : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 z-30 hidden h-full w-1 bg-ring',
|
||||
'right-[-1.5px]',
|
||||
columnResizeVariants({ colIndex: colIndex as any })
|
||||
)}
|
||||
/>
|
||||
{colIndex === 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 z-30 h-full w-1 bg-ring',
|
||||
'left-[-1.5px]',
|
||||
'fade-in hidden animate-in group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing="true"]]/table:block'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelectingRow && (
|
||||
<div className={blockSelectionVariants()} contentEditable={false} />
|
||||
)}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCellHeaderElement(
|
||||
props: React.ComponentProps<typeof TableCellElement>
|
||||
) {
|
||||
return <TableCellElement {...props} isHeader />;
|
||||
}
|
||||
|
||||
const columnResizeVariants = cva('fade-in hidden animate-in', {
|
||||
variants: {
|
||||
colIndex: {
|
||||
0: 'group-has-[[data-col="0"]:hover]/table:block group-has-[[data-col="0"][data-resizing="true"]]/table:block',
|
||||
1: 'group-has-[[data-col="1"]:hover]/table:block group-has-[[data-col="1"][data-resizing="true"]]/table:block',
|
||||
2: 'group-has-[[data-col="2"]:hover]/table:block group-has-[[data-col="2"][data-resizing="true"]]/table:block',
|
||||
3: 'group-has-[[data-col="3"]:hover]/table:block group-has-[[data-col="3"][data-resizing="true"]]/table:block',
|
||||
4: 'group-has-[[data-col="4"]:hover]/table:block group-has-[[data-col="4"][data-resizing="true"]]/table:block',
|
||||
5: 'group-has-[[data-col="5"]:hover]/table:block group-has-[[data-col="5"][data-resizing="true"]]/table:block',
|
||||
6: 'group-has-[[data-col="6"]:hover]/table:block group-has-[[data-col="6"][data-resizing="true"]]/table:block',
|
||||
7: 'group-has-[[data-col="7"]:hover]/table:block group-has-[[data-col="7"][data-resizing="true"]]/table:block',
|
||||
8: 'group-has-[[data-col="8"]:hover]/table:block group-has-[[data-col="8"][data-resizing="true"]]/table:block',
|
||||
9: 'group-has-[[data-col="9"]:hover]/table:block group-has-[[data-col="9"][data-resizing="true"]]/table:block',
|
||||
10: 'group-has-[[data-col="10"]:hover]/table:block group-has-[[data-col="10"][data-resizing="true"]]/table:block',
|
||||
},
|
||||
},
|
||||
});
|
||||
266
surfsense_web/components/ui/table-toolbar-button.tsx
Normal file
266
surfsense_web/components/ui/table-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
import { TablePlugin, useTableMergeState } from '@platejs/table/react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Combine,
|
||||
Grid3x3Icon,
|
||||
Table,
|
||||
Trash2Icon,
|
||||
Ungroup,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import { useEditorPlugin, useEditorSelector } from 'platejs/react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
export function TableToolbarButton(props: DropdownMenuProps) {
|
||||
const tableSelected = useEditorSelector(
|
||||
(editor) => editor.api.some({ match: { type: KEYS.table } }),
|
||||
[]
|
||||
);
|
||||
|
||||
const { editor, tf } = useEditorPlugin(TablePlugin);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const mergeState = useTableMergeState();
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton pressed={open} tooltip="Table" isDropdown>
|
||||
<Table />
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="flex w-[180px] min-w-0 flex-col"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
|
||||
<Grid3x3Icon className="size-4" />
|
||||
<span>Table</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="m-0 p-0">
|
||||
<TablePicker />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
disabled={!tableSelected}
|
||||
>
|
||||
<div className="size-4" />
|
||||
<span>Cell</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!mergeState.canMerge}
|
||||
onSelect={() => {
|
||||
tf.table.merge();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<Combine />
|
||||
Merge cells
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!mergeState.canSplit}
|
||||
onSelect={() => {
|
||||
tf.table.split();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<Ungroup />
|
||||
Split cell
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
disabled={!tableSelected}
|
||||
>
|
||||
<div className="size-4" />
|
||||
<span>Row</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.insert.tableRow({ before: true });
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<ArrowUp />
|
||||
Insert row before
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.insert.tableRow();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<ArrowDown />
|
||||
Insert row after
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.remove.tableRow();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
Delete row
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
disabled={!tableSelected}
|
||||
>
|
||||
<div className="size-4" />
|
||||
<span>Column</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.insert.tableColumn({ before: true });
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Insert column before
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.insert.tableColumn();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<ArrowRight />
|
||||
Insert column after
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.remove.tableColumn();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
Delete column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="min-w-[180px]"
|
||||
disabled={!tableSelected}
|
||||
onSelect={() => {
|
||||
tf.remove.table();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<Trash2Icon />
|
||||
Delete table
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function TablePicker() {
|
||||
const { editor, tf } = useEditorPlugin(TablePlugin);
|
||||
|
||||
const [tablePicker, setTablePicker] = React.useState({
|
||||
grid: Array.from({ length: 8 }, () => Array.from({ length: 8 }).fill(0)),
|
||||
size: { colCount: 0, rowCount: 0 },
|
||||
});
|
||||
|
||||
const onCellMove = (rowIndex: number, colIndex: number) => {
|
||||
const newGrid = [...tablePicker.grid];
|
||||
|
||||
for (let i = 0; i < newGrid.length; i++) {
|
||||
for (let j = 0; j < newGrid[i].length; j++) {
|
||||
newGrid[i][j] =
|
||||
i >= 0 && i <= rowIndex && j >= 0 && j <= colIndex ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
setTablePicker({
|
||||
grid: newGrid,
|
||||
size: { colCount: colIndex + 1, rowCount: rowIndex + 1 },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex! m-0 flex-col p-0"
|
||||
onClick={() => {
|
||||
tf.insert.table(tablePicker.size, { select: true });
|
||||
editor.tf.focus();
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<div className="grid size-[130px] grid-cols-8 gap-0.5 p-1">
|
||||
{tablePicker.grid.map((rows, rowIndex) =>
|
||||
rows.map((value, columIndex) => (
|
||||
<div
|
||||
key={`(${rowIndex},${columIndex})`}
|
||||
className={cn(
|
||||
'col-span-1 size-3 border border-solid bg-secondary',
|
||||
!!value && 'border-current'
|
||||
)}
|
||||
onMouseMove={() => {
|
||||
onCellMove(rowIndex, columIndex);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-current text-xs">
|
||||
{tablePicker.size.rowCount} x {tablePicker.size.colCount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
surfsense_web/components/ui/toggle-node.tsx
Normal file
39
surfsense_web/components/ui/toggle-node.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { useToggleButton, useToggleButtonState } from '@platejs/toggle/react';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { type PlateElementProps, PlateElement } from 'platejs/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ToggleElement({
|
||||
children,
|
||||
...props
|
||||
}: PlateElementProps) {
|
||||
const element = props.element;
|
||||
const state = useToggleButtonState(element.id as string);
|
||||
const { buttonProps, open } = useToggleButton(state);
|
||||
|
||||
return (
|
||||
<PlateElement {...props} className="relative py-1 pl-6">
|
||||
<button
|
||||
className={cn(
|
||||
'absolute top-1.5 left-0 flex size-6 cursor-pointer select-none items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
contentEditable={false}
|
||||
type="button"
|
||||
{...buttonProps}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'size-4 transition-transform duration-200',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div>{children}</div>
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
389
surfsense_web/components/ui/toolbar.tsx
Normal file
389
surfsense_web/components/ui/toolbar.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as ToolbarPrimitive from '@radix-ui/react-toolbar';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Toolbar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
|
||||
return (
|
||||
<ToolbarPrimitive.Root
|
||||
className={cn('relative flex select-none items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarToggleGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.ToolbarToggleGroup>) {
|
||||
return (
|
||||
<ToolbarPrimitive.ToolbarToggleGroup
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.Link>) {
|
||||
return (
|
||||
<ToolbarPrimitive.Link
|
||||
className={cn('font-medium underline underline-offset-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
|
||||
return (
|
||||
<ToolbarPrimitive.Separator
|
||||
className={cn('mx-2 my-1 w-px shrink-0 bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// From toggleVariants
|
||||
const toolbarButtonVariants = cva(
|
||||
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-checked:bg-accent aria-checked:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-9 min-w-9 px-2',
|
||||
lg: 'h-10 min-w-10 px-2.5',
|
||||
sm: 'h-8 min-w-8 px-1.5',
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const dropdownArrowVariants = cva(
|
||||
cn(
|
||||
'inline-flex items-center justify-center rounded-r-md font-medium text-foreground text-sm transition-colors disabled:pointer-events-none disabled:opacity-50'
|
||||
),
|
||||
{
|
||||
defaultVariants: {
|
||||
size: 'sm',
|
||||
variant: 'default',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-9 w-6',
|
||||
lg: 'h-10 w-8',
|
||||
sm: 'h-8 w-4',
|
||||
},
|
||||
variant: {
|
||||
default:
|
||||
'bg-transparent hover:bg-muted hover:text-muted-foreground aria-checked:bg-accent aria-checked:text-accent-foreground',
|
||||
outline:
|
||||
'border border-input border-l-0 bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type ToolbarButtonProps = {
|
||||
isDropdown?: boolean;
|
||||
pressed?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
|
||||
'asChild' | 'value'
|
||||
> &
|
||||
VariantProps<typeof toolbarButtonVariants>;
|
||||
|
||||
export const ToolbarButton = withTooltip(function ToolbarButton({
|
||||
children,
|
||||
className,
|
||||
isDropdown,
|
||||
pressed,
|
||||
size = 'sm',
|
||||
variant,
|
||||
...props
|
||||
}: ToolbarButtonProps) {
|
||||
return typeof pressed === 'boolean' ? (
|
||||
<ToolbarToggleGroup disabled={props.disabled} value="single" type="single">
|
||||
<ToolbarToggleItem
|
||||
className={cn(
|
||||
toolbarButtonVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
isDropdown && 'justify-between gap-1 pr-1',
|
||||
className
|
||||
)}
|
||||
value={pressed ? 'single' : ''}
|
||||
{...props}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<>
|
||||
<div className="flex flex-1 items-center gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</div>
|
||||
<div>
|
||||
<ChevronDown
|
||||
className="size-3.5 text-muted-foreground"
|
||||
data-icon
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ToolbarToggleItem>
|
||||
</ToolbarToggleGroup>
|
||||
) : (
|
||||
<ToolbarPrimitive.Button
|
||||
className={cn(
|
||||
toolbarButtonVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
isDropdown && 'pr-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToolbarPrimitive.Button>
|
||||
);
|
||||
});
|
||||
|
||||
export function ToolbarSplitButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ToolbarButton>) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
className={cn('group flex gap-0 px-0 hover:bg-transparent', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolbarSplitButtonPrimaryProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
|
||||
'value'
|
||||
> &
|
||||
VariantProps<typeof toolbarButtonVariants>;
|
||||
|
||||
export function ToolbarSplitButtonPrimary({
|
||||
children,
|
||||
className,
|
||||
size = 'sm',
|
||||
variant,
|
||||
...props
|
||||
}: ToolbarSplitButtonPrimaryProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
toolbarButtonVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
'rounded-r-none',
|
||||
'group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSplitButtonSecondary({
|
||||
className,
|
||||
size,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'span'> &
|
||||
VariantProps<typeof dropdownArrowVariants>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dropdownArrowVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
'group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarToggleItem({
|
||||
className,
|
||||
size = 'sm',
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.ToggleItem> &
|
||||
VariantProps<typeof toolbarButtonVariants>) {
|
||||
return (
|
||||
<ToolbarPrimitive.ToggleItem
|
||||
className={cn(toolbarButtonVariants({ size, variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarGroup({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/toolbar-group',
|
||||
'relative hidden has-[button]:flex',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">{children}</div>
|
||||
|
||||
<div className="group-last/toolbar-group:hidden! mx-1.5 py-0.5">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipProps<T extends React.ElementType> = {
|
||||
tooltip?: React.ReactNode;
|
||||
tooltipContentProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof TooltipContent>,
|
||||
'children'
|
||||
>;
|
||||
tooltipProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof Tooltip>,
|
||||
'children'
|
||||
>;
|
||||
tooltipTriggerProps?: React.ComponentPropsWithoutRef<typeof TooltipTrigger>;
|
||||
} & React.ComponentProps<T>;
|
||||
|
||||
function withTooltip<T extends React.ElementType>(Component: T) {
|
||||
return function ExtendComponent({
|
||||
tooltip,
|
||||
tooltipContentProps,
|
||||
tooltipProps,
|
||||
tooltipTriggerProps,
|
||||
...props
|
||||
}: TooltipProps<T>) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const component = <Component {...(props as React.ComponentProps<T>)} />;
|
||||
|
||||
if (tooltip && mounted) {
|
||||
return (
|
||||
<Tooltip {...tooltipProps}>
|
||||
<TooltipTrigger asChild {...tooltipTriggerProps}>
|
||||
{component}
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent {...tooltipContentProps}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return component;
|
||||
};
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
children,
|
||||
className,
|
||||
// CHANGE
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) text-balance rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-xs',
|
||||
className
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{/* CHANGE */}
|
||||
{/* <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" /> */}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarMenuGroup({
|
||||
children,
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuRadioGroup> & { label?: string }) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator
|
||||
className={cn(
|
||||
'hidden',
|
||||
'mb-0 shrink-0 peer-has-[[role=menuitem]]/menu-group:block peer-has-[[role=menuitemradio]]/menu-group:block peer-has-[[role=option]]/menu-group:block'
|
||||
)}
|
||||
/>
|
||||
|
||||
<DropdownMenuRadioGroup
|
||||
{...props}
|
||||
className={cn(
|
||||
'hidden',
|
||||
'peer/menu-group group/menu-group my-1.5 has-[[role=menuitem]]:block has-[[role=menuitemradio]]:block has-[[role=option]]:block',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<DropdownMenuLabel className="select-none font-semibold text-muted-foreground text-xs">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{children}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
surfsense_web/components/ui/turn-into-toolbar-button.tsx
Normal file
191
surfsense_web/components/ui/turn-into-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
|
||||
import type { TElement } from 'platejs';
|
||||
|
||||
import { DropdownMenuItemIndicator } from '@radix-ui/react-dropdown-menu';
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
FileCodeIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
SquareIcon,
|
||||
} from 'lucide-react';
|
||||
import { KEYS } from 'platejs';
|
||||
import { useEditorRef, useSelectionFragmentProp } from 'platejs/react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
getBlockType,
|
||||
setBlockType,
|
||||
} from '@/components/editor/transforms';
|
||||
|
||||
import { ToolbarButton, ToolbarMenuGroup } from './toolbar';
|
||||
|
||||
export const turnIntoItems = [
|
||||
{
|
||||
icon: <PilcrowIcon />,
|
||||
keywords: ['paragraph'],
|
||||
label: 'Text',
|
||||
value: KEYS.p,
|
||||
},
|
||||
{
|
||||
icon: <Heading1Icon />,
|
||||
keywords: ['title', 'h1'],
|
||||
label: 'Heading 1',
|
||||
value: 'h1',
|
||||
},
|
||||
{
|
||||
icon: <Heading2Icon />,
|
||||
keywords: ['subtitle', 'h2'],
|
||||
label: 'Heading 2',
|
||||
value: 'h2',
|
||||
},
|
||||
{
|
||||
icon: <Heading3Icon />,
|
||||
keywords: ['subtitle', 'h3'],
|
||||
label: 'Heading 3',
|
||||
value: 'h3',
|
||||
},
|
||||
{
|
||||
icon: <Heading4Icon />,
|
||||
keywords: ['subtitle', 'h4'],
|
||||
label: 'Heading 4',
|
||||
value: 'h4',
|
||||
},
|
||||
{
|
||||
icon: <Heading5Icon />,
|
||||
keywords: ['subtitle', 'h5'],
|
||||
label: 'Heading 5',
|
||||
value: 'h5',
|
||||
},
|
||||
{
|
||||
icon: <Heading6Icon />,
|
||||
keywords: ['subtitle', 'h6'],
|
||||
label: 'Heading 6',
|
||||
value: 'h6',
|
||||
},
|
||||
{
|
||||
icon: <ListIcon />,
|
||||
keywords: ['unordered', 'ul', '-'],
|
||||
label: 'Bulleted list',
|
||||
value: KEYS.ul,
|
||||
},
|
||||
{
|
||||
icon: <ListOrderedIcon />,
|
||||
keywords: ['ordered', 'ol', '1'],
|
||||
label: 'Numbered list',
|
||||
value: KEYS.ol,
|
||||
},
|
||||
{
|
||||
icon: <SquareIcon />,
|
||||
keywords: ['checklist', 'task', 'checkbox', '[]'],
|
||||
label: 'To-do list',
|
||||
value: KEYS.listTodo,
|
||||
},
|
||||
{
|
||||
icon: <FileCodeIcon />,
|
||||
keywords: ['```'],
|
||||
label: 'Code',
|
||||
value: KEYS.codeBlock,
|
||||
},
|
||||
{
|
||||
icon: <QuoteIcon />,
|
||||
keywords: ['citation', 'blockquote', '>'],
|
||||
label: 'Quote',
|
||||
value: KEYS.blockquote,
|
||||
},
|
||||
{
|
||||
icon: <InfoIcon />,
|
||||
keywords: ['callout', 'note', 'info', 'warning', 'tip'],
|
||||
label: 'Callout',
|
||||
value: KEYS.callout,
|
||||
},
|
||||
{
|
||||
icon: <ChevronRightIcon />,
|
||||
keywords: ['toggle', 'collapsible', 'expand'],
|
||||
label: 'Toggle',
|
||||
value: KEYS.toggle,
|
||||
},
|
||||
];
|
||||
|
||||
export function TurnIntoToolbarButton(props: DropdownMenuProps) {
|
||||
const editor = useEditorRef();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const value = useSelectionFragmentProp({
|
||||
defaultValue: KEYS.p,
|
||||
getProp: (node) => getBlockType(node as TElement),
|
||||
});
|
||||
const selectedItem = React.useMemo(
|
||||
() =>
|
||||
turnIntoItems.find((item) => item.value === (value ?? KEYS.p)) ??
|
||||
turnIntoItems[0],
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton
|
||||
className="min-w-[125px]"
|
||||
pressed={open}
|
||||
tooltip="Turn into"
|
||||
isDropdown
|
||||
>
|
||||
{selectedItem.label}
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="ignore-click-outside/toolbar min-w-0"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
align="start"
|
||||
>
|
||||
<ToolbarMenuGroup
|
||||
value={value}
|
||||
onValueChange={(type) => {
|
||||
setBlockType(editor, type);
|
||||
}}
|
||||
label="Turn into"
|
||||
>
|
||||
{turnIntoItems.map(({ icon, label, value: itemValue }) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={itemValue}
|
||||
className="min-w-[180px] pl-2 *:first:[span]:hidden"
|
||||
value={itemValue}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
{icon}
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</ToolbarMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
18
surfsense_web/hooks/use-debounce.ts
Normal file
18
surfsense_web/hooks/use-debounce.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export const useDebounce = <T>(value: T, delay = 500) => {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler: NodeJS.Timeout = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
11
surfsense_web/hooks/use-mounted.ts
Normal file
11
surfsense_web/hooks/use-mounted.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export function useMounted() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
|
@ -35,6 +35,23 @@
|
|||
"@electric-sql/react": "^1.0.26",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@platejs/autoformat": "^52.0.11",
|
||||
"@platejs/basic-nodes": "^52.0.11",
|
||||
"@platejs/callout": "^52.0.11",
|
||||
"@platejs/code-block": "^52.0.11",
|
||||
"@platejs/dnd": "^52.0.11",
|
||||
"@platejs/floating": "^52.0.11",
|
||||
"@platejs/indent": "^52.0.11",
|
||||
"@platejs/layout": "^52.0.11",
|
||||
"@platejs/link": "^52.0.11",
|
||||
"@platejs/list-classic": "^52.0.11",
|
||||
"@platejs/markdown": "^52.1.0",
|
||||
"@platejs/math": "^52.0.11",
|
||||
"@platejs/resizable": "^52.0.11",
|
||||
"@platejs/selection": "^52.0.16",
|
||||
"@platejs/slash-command": "^52.0.15",
|
||||
"@platejs/table": "^52.0.11",
|
||||
"@platejs/toggle": "^52.0.11",
|
||||
"@posthog/react": "^1.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
|
|
@ -56,6 +73,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-toolbar": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
|
|
@ -66,6 +84,7 @@
|
|||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@udecode/cn": "^52.0.11",
|
||||
"ai": "^4.3.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -82,17 +101,21 @@
|
|||
"jotai": "^2.15.1",
|
||||
"jotai-tanstack-query": "^0.11.0",
|
||||
"katex": "^0.16.28",
|
||||
"lodash": "^4.17.23",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.477.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.1.0",
|
||||
"next-intl": "^4.6.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"platejs": "^52.0.17",
|
||||
"postgres": "^3.4.7",
|
||||
"posthog-js": "^1.336.1",
|
||||
"posthog-node": "^5.24.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.61.1",
|
||||
|
|
@ -108,6 +131,7 @@
|
|||
"sonner": "^2.0.6",
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
|
|
|||
1395
surfsense_web/pnpm-lock.yaml
generated
1395
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue