Compare commits
6 Commits
8e85d7c835
...
620071e19e
| Author | SHA1 | Date | |
|---|---|---|---|
| 620071e19e | |||
| 8f8bd8dcac | |||
| 94fce5652a | |||
| 5f7f3995f8 | |||
| 35d968e2d8 | |||
| a096047a61 |
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tasgrid",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 4001",
|
||||
@@ -63,4 +63,4 @@
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-solid": "^2.11.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import { Type, Underline, Code, Quote, ImageIcon, Heading1, Heading2, Heading3, List, ListTodo, Film, FileText, AppWindow, Minus, Grid3X3, Highlighter, AlignLeft, AlignCenter, AlignRight, Trash2, Bold, Italic } from "lucide-solid";
|
||||
import { Type, Code, Quote, Heading1, Heading2, Heading3, List, ListTodo, FileText, AppWindow, Minus, Grid3X3, Trash2 } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CommandItem {
|
||||
@@ -20,33 +20,6 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bold",
|
||||
description: "Strong text. (**text**)",
|
||||
aliases: ["b", "strong"],
|
||||
icon: Bold,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Italic",
|
||||
description: "Emphasized text. (*text*)",
|
||||
aliases: ["i", "emphasis"],
|
||||
icon: Italic,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Underline",
|
||||
description: "Underlined text.",
|
||||
aliases: ["u"],
|
||||
icon: Underline,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleUnderline().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
@@ -107,29 +80,12 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image.",
|
||||
icon: ImageIcon,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).uploadImage().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Video",
|
||||
description: "Upload a video.",
|
||||
aliases: ["movie", "mp4", "mov"],
|
||||
icon: Film,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).uploadVideo().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "File",
|
||||
description: "Upload a document or file.",
|
||||
aliases: ["file", "pdf", "doc", "upload"],
|
||||
title: "Upload",
|
||||
description: "Upload an image, video, or document.",
|
||||
aliases: ["image", "video", "file", "upload", "pdf", "movie"],
|
||||
icon: FileText,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).uploadFile().run();
|
||||
editor.chain().focus().deleteRange(range).uploadMedia().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -158,87 +114,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Highlight",
|
||||
description: "Highlight selected text.",
|
||||
aliases: ["mark"],
|
||||
icon: Highlighter,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleHighlight().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Left",
|
||||
description: "Align text to the left.",
|
||||
icon: AlignLeft,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("left").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Center",
|
||||
description: "Align text to the center.",
|
||||
icon: AlignCenter,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("center").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Right",
|
||||
description: "Align text to the right.",
|
||||
icon: AlignRight,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("right").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Row Above",
|
||||
description: "Insert a new row above the cursor.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addRowBefore().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Row Below",
|
||||
description: "Insert a new row below the cursor.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addRowAfter().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Delete Row",
|
||||
description: "Delete the current row.",
|
||||
icon: Trash2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).deleteRow().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Column Left",
|
||||
description: "Insert a new column to the left.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addColumnBefore().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Column Right",
|
||||
description: "Insert a new column to the right.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addColumnAfter().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Delete Column",
|
||||
description: "Delete the current column.",
|
||||
icon: Trash2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).deleteColumn().run();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: "Delete Table",
|
||||
description: "Delete the entire table.",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type Component, createEffect, createSignal, For, createMemo, onCleanup } from "solid-js";
|
||||
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon, Film, FileText } from "lucide-solid";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, FileText } from "lucide-solid";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -18,6 +18,8 @@ import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { getRecurrenceProgress } from "@/lib/recurrence";
|
||||
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
||||
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
||||
|
||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
|
||||
@@ -404,7 +406,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Shortcut */}
|
||||
{/* Unified Media Upload Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -413,48 +415,12 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
instance.chain().focus().uploadImage().run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">Image</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Video Upload Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
instance.chain().focus().uploadVideo().run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Film size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">Video</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
instance.chain().focus().uploadFile().run();
|
||||
instance.chain().focus().uploadMedia().run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileText size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">File</span>
|
||||
<span class="hidden sm:inline">Upload</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -493,10 +459,19 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-border mt-5" />
|
||||
|
||||
<Show when={editorInstance()}>
|
||||
{(editor: any) => (
|
||||
<div class="flex flex-col pt-1 pb-1 relative z-30">
|
||||
<TextBubbleMenu editor={editor()} />
|
||||
<TableBubbleMenu editor={editor()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-20 flex flex-col">
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-20 pt-4 flex flex-col">
|
||||
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||
<TaskEditor
|
||||
content={props.task.content}
|
||||
|
||||
@@ -30,12 +30,9 @@ import { Dropcursor } from "@tiptap/extension-dropcursor";
|
||||
import { Mention } from "@tiptap/extension-mention";
|
||||
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
||||
import { FileViewer } from "@/lib/extensions/file-viewer";
|
||||
import { ImageUpload } from "@/lib/extensions/image-upload";
|
||||
import { Video } from "@/lib/extensions/video";
|
||||
import { VideoUpload } from "@/lib/extensions/video-upload";
|
||||
import { FileAttachment } from "@/lib/extensions/file-attachment";
|
||||
import { FileUpload } from "@/lib/extensions/file-upload";
|
||||
import { TableBubbleMenu } from "./TableBubbleMenu";
|
||||
import { MediaUpload } from "@/lib/extensions/media-upload";
|
||||
|
||||
interface TaskEditorProps {
|
||||
content?: string;
|
||||
@@ -215,11 +212,9 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
width: 2,
|
||||
}),
|
||||
FileViewer,
|
||||
ImageUpload,
|
||||
Video,
|
||||
VideoUpload,
|
||||
FileAttachment,
|
||||
FileUpload,
|
||||
MediaUpload,
|
||||
],
|
||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||
content: untrack(() => props.content) || "",
|
||||
@@ -323,9 +318,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
// Handle full screen images as before
|
||||
return (
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<Show when={editor()}>
|
||||
{(e) => <TableBubbleMenu editor={e()} />}
|
||||
</Show>
|
||||
<div class="w-full flex-1 flex flex-col">
|
||||
<div
|
||||
class="w-full h-8 cursor-pointer group flex items-center justify-center shrink-0 transition-all"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { type Component, createSignal, createEffect, For } from "solid-js";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Highlighter,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading,
|
||||
Eraser,
|
||||
ListTodo,
|
||||
Grid3X3,
|
||||
AlignJustify
|
||||
} from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
interface GenericAction {
|
||||
label: string;
|
||||
icon: Component;
|
||||
isActive?: () => boolean;
|
||||
command: () => void;
|
||||
iconClass?: string;
|
||||
}
|
||||
|
||||
interface FormatGroup {
|
||||
name: string;
|
||||
actions: GenericAction[];
|
||||
collapseOnMobile?: boolean;
|
||||
mobileIcon?: Component;
|
||||
}
|
||||
|
||||
export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => {
|
||||
// Track state of active styles so we can highlight the buttons
|
||||
const [activeFormats, setActiveFormats] = createSignal<Record<string, boolean>>({});
|
||||
|
||||
createEffect(() => {
|
||||
const updateState = () => {
|
||||
const editor = props.editor;
|
||||
if (!editor) return;
|
||||
|
||||
setActiveFormats({
|
||||
bold: editor.isActive('bold'),
|
||||
italic: editor.isActive('italic'),
|
||||
underline: editor.isActive('underline'),
|
||||
highlight: editor.isActive('highlight'),
|
||||
alignLeft: editor.isActive({ textAlign: 'left' }),
|
||||
alignCenter: editor.isActive({ textAlign: 'center' }),
|
||||
alignRight: editor.isActive({ textAlign: 'right' }),
|
||||
h1: editor.isActive('heading', { level: 1 }),
|
||||
h2: editor.isActive('heading', { level: 2 }),
|
||||
h3: editor.isActive('heading', { level: 3 }),
|
||||
checklist: editor.isActive('taskList'),
|
||||
table: editor.isActive('table'),
|
||||
});
|
||||
};
|
||||
|
||||
props.editor.on("selectionUpdate", updateState);
|
||||
props.editor.on("transaction", updateState);
|
||||
|
||||
return () => {
|
||||
props.editor.off("selectionUpdate", updateState);
|
||||
props.editor.off("transaction", updateState);
|
||||
};
|
||||
});
|
||||
|
||||
const groups: FormatGroup[] = [
|
||||
{
|
||||
name: "Style",
|
||||
actions: [
|
||||
{ label: "Bold", icon: () => <Bold size={16} />, isActive: () => activeFormats().bold, command: () => props.editor.chain().focus().toggleBold().run() },
|
||||
{ label: "Italic", icon: () => <Italic size={16} />, isActive: () => activeFormats().italic, command: () => props.editor.chain().focus().toggleItalic().run() },
|
||||
{ label: "Underline", icon: () => <Underline size={16} />, isActive: () => activeFormats().underline, command: () => props.editor.chain().focus().toggleUnderline().run() },
|
||||
{ label: "Highlight", icon: () => <Highlighter size={16} />, isActive: () => activeFormats().highlight, command: () => props.editor.chain().focus().toggleHighlight().run() },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Headings",
|
||||
collapseOnMobile: true,
|
||||
mobileIcon: () => <Heading size={16} />,
|
||||
actions: [
|
||||
{ label: "Heading 1", icon: () => <Heading1 size={16} />, isActive: () => activeFormats().h1, command: () => props.editor.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ label: "Heading 2", icon: () => <Heading2 size={16} />, isActive: () => activeFormats().h2, command: () => props.editor.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ label: "Heading 3", icon: () => <Heading3 size={16} />, isActive: () => activeFormats().h3, command: () => props.editor.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Alignment",
|
||||
collapseOnMobile: true,
|
||||
mobileIcon: () => <AlignJustify size={16} />,
|
||||
actions: [
|
||||
{ label: "Align Left", icon: () => <AlignLeft size={16} />, isActive: () => activeFormats().alignLeft, command: () => props.editor.chain().focus().setTextAlign('left').run() },
|
||||
{ label: "Align Center", icon: () => <AlignCenter size={16} />, isActive: () => activeFormats().alignCenter, command: () => props.editor.chain().focus().setTextAlign('center').run() },
|
||||
{ label: "Align Right", icon: () => <AlignRight size={16} />, isActive: () => activeFormats().alignRight, command: () => props.editor.chain().focus().setTextAlign('right').run() },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Blocks",
|
||||
actions: [
|
||||
{ label: "Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() },
|
||||
{ label: "Table", icon: () => <Grid3X3 size={16} />, isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Clear",
|
||||
actions: [
|
||||
{ label: "Clear Formatting", icon: () => <Eraser size={16} />, command: () => props.editor.chain().focus().clearNodes().unsetAllMarks().run() },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"relative z-[140] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col h-9 opacity-100"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-center h-full px-2 max-w-4xl mx-auto w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<For each={groups}>
|
||||
{(group, groupIdx) => (
|
||||
<>
|
||||
<div class={cn("flex items-center gap-0 px-0.5", group.collapseOnMobile ? "hidden sm:flex" : "")}>
|
||||
<For each={group.actions}>
|
||||
{(action) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
action.command();
|
||||
}}
|
||||
class={cn(
|
||||
"p-1.5 rounded-md transition-colors group relative shrink-0",
|
||||
action.isActive?.()
|
||||
? "bg-primary/20 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
action.iconClass
|
||||
)}
|
||||
title={action.label}
|
||||
>
|
||||
<action.icon />
|
||||
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-2 py-1 text-[10px] font-bold bg-foreground text-background rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[200] shadow-lg">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{group.collapseOnMobile && group.mobileIcon && (
|
||||
<div class="flex sm:hidden items-center gap-0 px-0.5">
|
||||
<Popover>
|
||||
<PopoverTrigger class={cn(
|
||||
"p-1.5 rounded-md transition-colors group relative shrink-0 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
group.actions.some(a => a.isActive?.()) && "bg-primary/20 text-primary"
|
||||
)}>
|
||||
<group.mobileIcon />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-1 flex items-center gap-1 shadow-xl" align="center">
|
||||
<For each={group.actions}>
|
||||
{(action) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
action.command();
|
||||
}}
|
||||
class={cn(
|
||||
"p-1.5 rounded-md transition-colors relative shrink-0",
|
||||
action.isActive?.()
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
action.iconClass
|
||||
)}
|
||||
title={action.label}
|
||||
>
|
||||
<action.icon />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{groupIdx() < groups.length - 1 && (
|
||||
<div class="w-[1px] h-4 bg-border/50 mx-1" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -182,7 +182,7 @@ export const FileAttachment = Node.create<FileAttachmentOptions>({
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-1.5', 'right-1.5', 'hidden', 'group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
overlay.classList.add('absolute', 'top-1.5', 'left-1.5', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
|
||||
@@ -95,7 +95,7 @@ export const FileViewer = Node.create<FileViewerOptions>({
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
|
||||
+105
-2
@@ -13,6 +13,14 @@ export const CustomImage = Image.extend({
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('width'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.width) return {};
|
||||
return { width: attributes.width };
|
||||
},
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
@@ -39,9 +47,16 @@ export const CustomImage = Image.extend({
|
||||
return ({ node, getPos, editor }) => {
|
||||
const wrapper = document.createElement('div');
|
||||
// Give it position: relative and flex/inline-block to wrap the image tightly
|
||||
wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-fit', 'max-w-full');
|
||||
wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full');
|
||||
wrapper.style.display = 'block'; // Block level to not mess up flow
|
||||
wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow
|
||||
|
||||
if (node.attrs.width) {
|
||||
const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width;
|
||||
wrapper.style.width = w;
|
||||
} else {
|
||||
wrapper.style.width = 'fit-content';
|
||||
}
|
||||
|
||||
const img = document.createElement('img');
|
||||
Object.entries(node.attrs).forEach(([key, value]) => {
|
||||
@@ -54,10 +69,97 @@ export const CustomImage = Image.extend({
|
||||
}
|
||||
});
|
||||
img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default');
|
||||
img.style.width = '100%';
|
||||
|
||||
// Resize handle
|
||||
const resizer = document.createElement('div');
|
||||
// Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging
|
||||
resizer.style.touchAction = 'none';
|
||||
resizer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8M3 16.2V21m0 0h4.8M3 21l6-6M21 7.8V3m0 0h-4.8M21 3l-6 6M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>';
|
||||
resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm');
|
||||
resizer.contentEditable = 'false';
|
||||
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
const initResize = (clientX: number) => {
|
||||
isResizing = true;
|
||||
startX = clientX;
|
||||
startWidth = wrapper.offsetWidth;
|
||||
};
|
||||
|
||||
const doResize = (clientX: number, minW: number) => {
|
||||
if (!isResizing) return;
|
||||
const dx = clientX - startX;
|
||||
const newWidth = Math.max(minW, startWidth + dx);
|
||||
wrapper.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
if (!isResizing) return;
|
||||
isResizing = false;
|
||||
if (typeof getPos === 'function') {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.commands.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
width: wrapper.offsetWidth,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
initResize(e.clientX);
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
doResize(ev.clientX, 100);
|
||||
};
|
||||
|
||||
const onMouseUp = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
stopResize();
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
resizer.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
initResize(e.touches[0].clientX);
|
||||
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
doResize(ev.touches[0].clientX, 100);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
// touchend doesn't have touches[0] usually
|
||||
stopResize();
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
document.addEventListener('touchcancel', onTouchEnd);
|
||||
}, { passive: false });
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
@@ -76,6 +178,7 @@ export const CustomImage = Image.extend({
|
||||
|
||||
overlay.appendChild(deleteBtn);
|
||||
wrapper.appendChild(img);
|
||||
wrapper.appendChild(resizer);
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
|
||||
import { convertImageToWebpFile } from "@/lib/utils";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
mediaUpload: {
|
||||
uploadMedia: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const MediaUpload = Extension.create({
|
||||
name: 'mediaUpload',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
uploadMedia: () => ({ editor }) => {
|
||||
const taskId = activeTaskId();
|
||||
const noteId = activeNoteId();
|
||||
|
||||
if (!taskId && !noteId) {
|
||||
toast.error("Cannot upload media outside of a task or note.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const originalFile = input.files[0];
|
||||
const isImage = originalFile.type.startsWith('image/');
|
||||
const isVideo = originalFile.type.startsWith('video/');
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
let fileToUpload = originalFile;
|
||||
if (isImage && !originalFile.type.includes('svg') && !originalFile.type.includes('gif')) {
|
||||
fileToUpload = await convertImageToWebpFile(originalFile);
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
return uploadTaskAttachment(taskId, fileToUpload);
|
||||
} else {
|
||||
return uploadNoteAttachment(noteId!, fileToUpload);
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: "Processing & uploading media...",
|
||||
success: ({ url, filename }) => {
|
||||
if (isImage) {
|
||||
// @ts-ignore
|
||||
editor.chain().focus().setImage({ src: url, filename }).run();
|
||||
} else if (isVideo) {
|
||||
editor.chain().focus().setVideo({ src: url, filename }).run();
|
||||
} else {
|
||||
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: originalFile.size.toString() }).run();
|
||||
}
|
||||
|
||||
// Manually force a new paragraph at the end so the user can keep typing
|
||||
editor.chain()
|
||||
.focus('end')
|
||||
.insertContent('<p></p>')
|
||||
.run();
|
||||
|
||||
return "Media uploaded.";
|
||||
},
|
||||
error: "Failed to upload media."
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+106
-2
@@ -27,6 +27,14 @@ export const Video = Node.create<VideoOptions>({
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('width'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.width) return {};
|
||||
return { width: attributes.width };
|
||||
},
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
@@ -89,8 +97,16 @@ export const Video = Node.create<VideoOptions>({
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-full', 'max-w-full');
|
||||
wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full');
|
||||
wrapper.style.display = 'block';
|
||||
wrapper.style.marginInline = 'auto'; // Center if desired
|
||||
|
||||
if (node.attrs.width) {
|
||||
const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width;
|
||||
wrapper.style.width = w;
|
||||
} else {
|
||||
wrapper.style.width = '100%';
|
||||
}
|
||||
|
||||
const video = document.createElement('video');
|
||||
Object.entries(node.attrs).forEach(([key, value]) => {
|
||||
@@ -107,10 +123,97 @@ export const Video = Node.create<VideoOptions>({
|
||||
video.setAttribute('controls', 'true');
|
||||
}
|
||||
video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden');
|
||||
video.style.width = '100%';
|
||||
|
||||
// Resize handle
|
||||
const resizer = document.createElement('div');
|
||||
// Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging
|
||||
resizer.style.touchAction = 'none';
|
||||
resizer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8M3 16.2V21m0 0h4.8M3 21l6-6M21 7.8V3m0 0h-4.8M21 3l-6 6M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>';
|
||||
resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm');
|
||||
resizer.contentEditable = 'false';
|
||||
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
const initResize = (clientX: number) => {
|
||||
isResizing = true;
|
||||
startX = clientX;
|
||||
startWidth = wrapper.offsetWidth;
|
||||
};
|
||||
|
||||
const doResize = (clientX: number, minW: number) => {
|
||||
if (!isResizing) return;
|
||||
const dx = clientX - startX;
|
||||
const newWidth = Math.max(minW, startWidth + dx);
|
||||
wrapper.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
if (!isResizing) return;
|
||||
isResizing = false;
|
||||
if (typeof getPos === 'function') {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.commands.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
width: wrapper.offsetWidth,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', (e) => {
|
||||
// Prevent event from dragging the whole block
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
initResize(e.clientX);
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
doResize(ev.clientX, 200);
|
||||
};
|
||||
|
||||
const onMouseUp = (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
stopResize();
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
resizer.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
initResize(e.touches[0].clientX);
|
||||
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
doResize(ev.touches[0].clientX, 200);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
stopResize();
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
document.addEventListener('touchcancel', onTouchEnd);
|
||||
}, { passive: false });
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
@@ -129,6 +232,7 @@ export const Video = Node.create<VideoOptions>({
|
||||
|
||||
overlay.appendChild(deleteBtn);
|
||||
wrapper.appendChild(video);
|
||||
wrapper.appendChild(resizer);
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
return {
|
||||
|
||||
+14
-30
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, createEffect, onCleanup, For,
|
||||
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { type Note } from "@/store";
|
||||
import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, Image as ImageIcon, MoreHorizontal, Type, FileText, Film, Search, Star, ChevronRight } from "lucide-solid";
|
||||
import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, MoreHorizontal, Type, FileText, Search, Star, ChevronRight } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd";
|
||||
@@ -12,6 +12,8 @@ import { TaskEditor } from "@/components/TaskEditor";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { NotesSidebar } from "@/components/NotesSidebar";
|
||||
import { toast } from "solid-sonner";
|
||||
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
||||
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
||||
|
||||
export const NotepadView: Component<{
|
||||
selectedNoteId: string | null;
|
||||
@@ -461,38 +463,12 @@ export const NotepadView: Component<{
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().uploadImage().run();
|
||||
if (instance) instance.chain().focus().uploadMedia().run();
|
||||
}}
|
||||
title="Upload Image"
|
||||
>
|
||||
<ImageIcon size={14} class="opacity-70" />
|
||||
<span>Image</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().uploadVideo().run();
|
||||
}}
|
||||
title="Upload Video"
|
||||
>
|
||||
<Film size={14} class="opacity-70" />
|
||||
<span>Video</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().uploadFile().run();
|
||||
}}
|
||||
title="Attach File"
|
||||
title="Upload"
|
||||
>
|
||||
<FileText size={14} class="opacity-70" />
|
||||
<span>File</span>
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,6 +536,14 @@ export const NotepadView: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={editorInstance()}>
|
||||
{(editor) => (
|
||||
<div class="mt-4 -mx-4 px-4 md:-mx-6 md:px-6 relative border-t border-border/40 pt-1">
|
||||
<TextBubbleMenu editor={editor()} />
|
||||
<TableBubbleMenu editor={editor()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}
|
||||
|
||||
Reference in New Issue
Block a user