mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat: implement EditorSaveContext and integrate save functionality in PlateEditor and FixedToolbarButtons
This commit is contained in:
parent
0edfd116af
commit
1768887be8
8 changed files with 446 additions and 290 deletions
25
surfsense_web/components/editor/editor-save-context.tsx
Normal file
25
surfsense_web/components/editor/editor-save-context.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface EditorSaveContextValue {
|
||||
/** Callback to save the current editor content */
|
||||
onSave?: () => void;
|
||||
/** Whether there are unsaved changes */
|
||||
hasUnsavedChanges: boolean;
|
||||
/** Whether a save operation is in progress */
|
||||
isSaving: boolean;
|
||||
/** Whether the user can toggle between editing and viewing modes */
|
||||
canToggleMode: boolean;
|
||||
}
|
||||
|
||||
export const EditorSaveContext = createContext<EditorSaveContextValue>({
|
||||
hasUnsavedChanges: false,
|
||||
isSaving: false,
|
||||
canToggleMode: false,
|
||||
});
|
||||
|
||||
export function useEditorSave() {
|
||||
return useContext(EditorSaveContext);
|
||||
}
|
||||
|
||||
|
|
@ -22,13 +22,19 @@ import { TableKit } from '@/components/editor/plugins/table-kit';
|
|||
import { ToggleKit } from '@/components/editor/plugins/toggle-kit';
|
||||
import { Editor, EditorContainer } from '@/components/ui/editor';
|
||||
import { escapeMdxExpressions } from '@/components/editor/utils/escape-mdx';
|
||||
import { EditorSaveContext } from '@/components/editor/editor-save-context';
|
||||
|
||||
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 */
|
||||
/**
|
||||
* Force permanent read-only mode (e.g. public/shared view).
|
||||
* When true, the editor cannot be toggled to editing mode.
|
||||
* When false (default), the editor starts in viewing mode but
|
||||
* the user can switch to editing via the mode toolbar button.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
|
|
@ -38,6 +44,12 @@ interface PlateEditorProps {
|
|||
editorVariant?: 'default' | 'demo' | 'fullWidth' | 'none';
|
||||
/** Additional className for the container */
|
||||
className?: string;
|
||||
/** Save callback. When provided, a save button appears in the toolbar on unsaved changes. */
|
||||
onSave?: () => void;
|
||||
/** Whether there are unsaved changes */
|
||||
hasUnsavedChanges?: boolean;
|
||||
/** Whether a save is in progress */
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export function PlateEditor({
|
||||
|
|
@ -48,10 +60,17 @@ export function PlateEditor({
|
|||
variant = 'default',
|
||||
editorVariant = 'default',
|
||||
className,
|
||||
onSave,
|
||||
hasUnsavedChanges = false,
|
||||
isSaving = false,
|
||||
}: PlateEditorProps) {
|
||||
const lastMarkdownRef = useRef(markdown);
|
||||
|
||||
// Always initialize the editor in readOnly mode (viewing mode).
|
||||
// For non-forced readOnly, the user can toggle to editing via ModeToolbarButton.
|
||||
// For forced readOnly, the mode button is hidden and readOnly stays true.
|
||||
const editor = usePlateEditor({
|
||||
readOnly: true,
|
||||
plugins: [
|
||||
...BasicNodesKit,
|
||||
...TableKit,
|
||||
|
|
@ -95,21 +114,36 @@ export function PlateEditor({
|
|||
}
|
||||
}, [markdown, editor]);
|
||||
|
||||
// When not forced read-only, the user can toggle between editing/viewing.
|
||||
const canToggleMode = !readOnly;
|
||||
|
||||
return (
|
||||
<Plate
|
||||
editor={editor}
|
||||
readOnly={readOnly}
|
||||
onChange={({ value }) => {
|
||||
if (onMarkdownChange) {
|
||||
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
|
||||
lastMarkdownRef.current = md;
|
||||
onMarkdownChange(md);
|
||||
}
|
||||
<EditorSaveContext.Provider
|
||||
value={{
|
||||
onSave,
|
||||
hasUnsavedChanges,
|
||||
isSaving,
|
||||
canToggleMode,
|
||||
}}
|
||||
>
|
||||
<EditorContainer variant={variant} className={className}>
|
||||
<Editor variant={editorVariant} placeholder={placeholder} readOnly={readOnly} />
|
||||
</EditorContainer>
|
||||
</Plate>
|
||||
<Plate
|
||||
editor={editor}
|
||||
// Only pass readOnly as a controlled prop when forced (permanently read-only).
|
||||
// For non-forced mode, the Plate store manages readOnly internally
|
||||
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
|
||||
{...(readOnly ? { readOnly: true } : {})}
|
||||
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} />
|
||||
</EditorContainer>
|
||||
</Plate>
|
||||
</EditorSaveContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { ChevronDownIcon, SaveIcon, XIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
|
@ -302,21 +302,7 @@ 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 */}
|
||||
{/* Copy button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -440,13 +426,16 @@ function ReportPanelContent({
|
|||
</div>
|
||||
</div>
|
||||
) : reportContent.content ? (
|
||||
<PlateEditor
|
||||
markdown={reportContent.content}
|
||||
onMarkdownChange={shareToken ? undefined : setEditedMarkdown}
|
||||
readOnly={!!shareToken}
|
||||
placeholder="Report content..."
|
||||
editorVariant="default"
|
||||
/>
|
||||
<PlateEditor
|
||||
markdown={reportContent.content}
|
||||
onMarkdownChange={shareToken ? undefined : setEditedMarkdown}
|
||||
readOnly={!!shareToken}
|
||||
placeholder="Report content..."
|
||||
editorVariant="default"
|
||||
onSave={shareToken ? undefined : handleSave}
|
||||
hasUnsavedChanges={!shareToken && editedMarkdown !== null}
|
||||
isSaving={saving}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-5 py-5">
|
||||
<p className="text-muted-foreground italic">No content available.</p>
|
||||
|
|
|
|||
|
|
@ -1,228 +1,257 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
HighlighterIcon,
|
||||
ItalicIcon,
|
||||
RedoIcon,
|
||||
SaveIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
UndoIcon,
|
||||
|
|
@ -15,9 +16,13 @@ import {
|
|||
import { KEYS } from 'platejs';
|
||||
import { useEditorReadOnly, useEditorRef } from 'platejs/react';
|
||||
|
||||
import { useEditorSave } from '@/components/editor/editor-save-context';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
import { InsertToolbarButton } from './insert-toolbar-button';
|
||||
import { LinkToolbarButton } from './link-toolbar-button';
|
||||
import { MarkToolbarButton } from './mark-toolbar-button';
|
||||
import { ModeToolbarButton } from './mode-toolbar-button';
|
||||
import { MoreToolbarButton } from './more-toolbar-button';
|
||||
import { ToolbarButton, ToolbarGroup } from './toolbar';
|
||||
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
|
||||
|
|
@ -25,81 +30,109 @@ import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
|
|||
export function FixedToolbarButtons() {
|
||||
const readOnly = useEditorReadOnly();
|
||||
const editor = useEditorRef();
|
||||
|
||||
if (readOnly) return null;
|
||||
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap">
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip="Undo (⌘+Z)"
|
||||
onClick={() => {
|
||||
editor.undo();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<UndoIcon />
|
||||
</ToolbarButton>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip="Undo (⌘+Z)"
|
||||
onClick={() => {
|
||||
editor.undo();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<UndoIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="Redo (⌘+⇧+Z)"
|
||||
onClick={() => {
|
||||
editor.redo();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<RedoIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip="Redo (⌘+⇧+Z)"
|
||||
onClick={() => {
|
||||
editor.redo();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<RedoIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<InsertToolbarButton />
|
||||
<TurnIntoToolbarButton />
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup>
|
||||
<InsertToolbarButton />
|
||||
<TurnIntoToolbarButton />
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip="Bold (⌘+B)">
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
<ToolbarGroup>
|
||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip="Bold (⌘+B)">
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip="Italic (⌘+I)">
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip="Italic (⌘+I)">
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.underline}
|
||||
tooltip="Underline (⌘+U)"
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.underline}
|
||||
tooltip="Underline (⌘+U)"
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.strikethrough}
|
||||
tooltip="Strikethrough (⌘+⇧+M)"
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.strikethrough}
|
||||
tooltip="Strikethrough (⌘+⇧+M)"
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.code} tooltip="Code (⌘+E)">
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
<MarkToolbarButton nodeType={KEYS.code} tooltip="Code (⌘+E)">
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.highlight}
|
||||
tooltip="Highlight (⌘+⇧+H)"
|
||||
>
|
||||
<HighlighterIcon />
|
||||
</MarkToolbarButton>
|
||||
</ToolbarGroup>
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.highlight}
|
||||
tooltip="Highlight (⌘+⇧+H)"
|
||||
>
|
||||
<HighlighterIcon />
|
||||
</MarkToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<LinkToolbarButton />
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup>
|
||||
<LinkToolbarButton />
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<MoreToolbarButton />
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup>
|
||||
<MoreToolbarButton />
|
||||
</ToolbarGroup>
|
||||
|
||||
{/* Save button — appears when in editing mode with unsaved changes */}
|
||||
{onSave && hasUnsavedChanges && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={isSaving ? 'Saving...' : 'Save (⌘+S)'}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="text-white"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<SaveIcon />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grow" />
|
||||
|
||||
{canToggleMode && (
|
||||
<ToolbarGroup>
|
||||
<ModeToolbarButton />
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
19
surfsense_web/components/ui/mode-toolbar-button.tsx
Normal file
19
surfsense_web/components/ui/mode-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { BookOpenIcon, PenLineIcon } from 'lucide-react';
|
||||
import { usePlateState } from 'platejs/react';
|
||||
|
||||
import { ToolbarButton } from './toolbar';
|
||||
|
||||
export function ModeToolbarButton() {
|
||||
const [readOnly, setReadOnly] = usePlateState('readOnly');
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
tooltip={readOnly ? 'Click to edit' : 'Click to view'}
|
||||
onClick={() => setReadOnly(!readOnly)}
|
||||
>
|
||||
{readOnly ? <BookOpenIcon /> : <PenLineIcon />}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,28 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type * as React from "react";
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
export { Separator }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
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';
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Toolbar({
|
||||
|
|
@ -327,6 +328,32 @@ function withTooltip<T extends React.ElementType>(Component: T) {
|
|||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue