Compare commits
6 Commits
8e85d7c835
...
620071e19e
| Author | SHA1 | Date | |
|---|---|---|---|
| 620071e19e | |||
| 8f8bd8dcac | |||
| 94fce5652a | |||
| 5f7f3995f8 | |||
| 35d968e2d8 | |||
| a096047a61 |
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tasgrid",
|
"name": "tasgrid",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 4001",
|
"dev": "vite --host 0.0.0.0 --port 4001",
|
||||||
@@ -63,4 +63,4 @@
|
|||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vite-plugin-solid": "^2.11.10"
|
"vite-plugin-solid": "^2.11.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface CommandItem {
|
export interface CommandItem {
|
||||||
@@ -20,33 +20,6 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
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",
|
title: "Heading 1",
|
||||||
description: "Big section heading.",
|
description: "Big section heading.",
|
||||||
@@ -107,29 +80,12 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Image",
|
title: "Upload",
|
||||||
description: "Upload an image.",
|
description: "Upload an image, video, or document.",
|
||||||
icon: ImageIcon,
|
aliases: ["image", "video", "file", "upload", "pdf", "movie"],
|
||||||
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"],
|
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
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();
|
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",
|
title: "Delete Table",
|
||||||
description: "Delete the entire 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 { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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 { StatusCircle } from "./StatusCircle";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -18,6 +18,8 @@ import { useTheme } from "./ThemeProvider";
|
|||||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { getRecurrenceProgress } from "@/lib/recurrence";
|
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 })));
|
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Upload Shortcut */}
|
{/* Unified Media Upload Shortcut */}
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -413,48 +415,12 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const instance = editorInstance();
|
const instance = editorInstance();
|
||||||
if (instance) {
|
if (instance) {
|
||||||
instance.chain().focus().uploadImage().run();
|
instance.chain().focus().uploadMedia().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();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={14} class="opacity-70" />
|
<FileText size={14} class="opacity-70" />
|
||||||
<span class="hidden sm:inline">File</span>
|
<span class="hidden sm:inline">Upload</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,10 +459,19 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-px w-full bg-border mt-5" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* 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" />}>
|
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||||
<TaskEditor
|
<TaskEditor
|
||||||
content={props.task.content}
|
content={props.task.content}
|
||||||
|
|||||||
@@ -30,12 +30,9 @@ import { Dropcursor } from "@tiptap/extension-dropcursor";
|
|||||||
import { Mention } from "@tiptap/extension-mention";
|
import { Mention } from "@tiptap/extension-mention";
|
||||||
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
||||||
import { FileViewer } from "@/lib/extensions/file-viewer";
|
import { FileViewer } from "@/lib/extensions/file-viewer";
|
||||||
import { ImageUpload } from "@/lib/extensions/image-upload";
|
|
||||||
import { Video } from "@/lib/extensions/video";
|
import { Video } from "@/lib/extensions/video";
|
||||||
import { VideoUpload } from "@/lib/extensions/video-upload";
|
|
||||||
import { FileAttachment } from "@/lib/extensions/file-attachment";
|
import { FileAttachment } from "@/lib/extensions/file-attachment";
|
||||||
import { FileUpload } from "@/lib/extensions/file-upload";
|
import { MediaUpload } from "@/lib/extensions/media-upload";
|
||||||
import { TableBubbleMenu } from "./TableBubbleMenu";
|
|
||||||
|
|
||||||
interface TaskEditorProps {
|
interface TaskEditorProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -215,11 +212,9 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
width: 2,
|
width: 2,
|
||||||
}),
|
}),
|
||||||
FileViewer,
|
FileViewer,
|
||||||
ImageUpload,
|
|
||||||
Video,
|
Video,
|
||||||
VideoUpload,
|
|
||||||
FileAttachment,
|
FileAttachment,
|
||||||
FileUpload,
|
MediaUpload,
|
||||||
],
|
],
|
||||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||||
content: untrack(() => props.content) || "",
|
content: untrack(() => props.content) || "",
|
||||||
@@ -323,9 +318,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
// Handle full screen images as before
|
// Handle full screen images as before
|
||||||
return (
|
return (
|
||||||
<div class="w-full h-full flex flex-col">
|
<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 flex-1 flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="w-full h-8 cursor-pointer group flex items-center justify-center shrink-0 transition-all"
|
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
|
// Delete button overlay
|
||||||
const overlay = document.createElement('div');
|
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');
|
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>';
|
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
|
// Delete button overlay
|
||||||
const overlay = document.createElement('div');
|
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');
|
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>';
|
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: {
|
src: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
width: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: element => element.getAttribute('width'),
|
||||||
|
renderHTML: attributes => {
|
||||||
|
if (!attributes.width) return {};
|
||||||
|
return { width: attributes.width };
|
||||||
|
},
|
||||||
|
},
|
||||||
filename: {
|
filename: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: element => element.getAttribute('data-filename'),
|
parseHTML: element => element.getAttribute('data-filename'),
|
||||||
@@ -39,9 +47,16 @@ export const CustomImage = Image.extend({
|
|||||||
return ({ node, getPos, editor }) => {
|
return ({ node, getPos, editor }) => {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
// Give it position: relative and flex/inline-block to wrap the image tightly
|
// 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.display = 'block'; // Block level to not mess up flow
|
||||||
wrapper.style.marginInline = 'auto'; // Center if desired, or let it 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');
|
const img = document.createElement('img');
|
||||||
Object.entries(node.attrs).forEach(([key, value]) => {
|
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.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
|
// Delete button overlay
|
||||||
const overlay = document.createElement('div');
|
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');
|
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>';
|
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);
|
overlay.appendChild(deleteBtn);
|
||||||
wrapper.appendChild(img);
|
wrapper.appendChild(img);
|
||||||
|
wrapper.appendChild(resizer);
|
||||||
wrapper.appendChild(overlay);
|
wrapper.appendChild(overlay);
|
||||||
|
|
||||||
return {
|
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: {
|
src: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
width: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: element => element.getAttribute('width'),
|
||||||
|
renderHTML: attributes => {
|
||||||
|
if (!attributes.width) return {};
|
||||||
|
return { width: attributes.width };
|
||||||
|
},
|
||||||
|
},
|
||||||
filename: {
|
filename: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: element => element.getAttribute('data-filename'),
|
parseHTML: element => element.getAttribute('data-filename'),
|
||||||
@@ -89,8 +97,16 @@ export const Video = Node.create<VideoOptions>({
|
|||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ({ node, getPos, editor }) => {
|
return ({ node, getPos, editor }) => {
|
||||||
const wrapper = document.createElement('div');
|
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.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');
|
const video = document.createElement('video');
|
||||||
Object.entries(node.attrs).forEach(([key, value]) => {
|
Object.entries(node.attrs).forEach(([key, value]) => {
|
||||||
@@ -107,10 +123,97 @@ export const Video = Node.create<VideoOptions>({
|
|||||||
video.setAttribute('controls', 'true');
|
video.setAttribute('controls', 'true');
|
||||||
}
|
}
|
||||||
video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden');
|
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
|
// Delete button overlay
|
||||||
const overlay = document.createElement('div');
|
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');
|
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>';
|
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);
|
overlay.appendChild(deleteBtn);
|
||||||
wrapper.appendChild(video);
|
wrapper.appendChild(video);
|
||||||
|
wrapper.appendChild(resizer);
|
||||||
wrapper.appendChild(overlay);
|
wrapper.appendChild(overlay);
|
||||||
|
|
||||||
return {
|
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 { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { type Note } from "@/store";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd";
|
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 { TaskCard } from "@/components/TaskCard";
|
||||||
import { NotesSidebar } from "@/components/NotesSidebar";
|
import { NotesSidebar } from "@/components/NotesSidebar";
|
||||||
import { toast } from "solid-sonner";
|
import { toast } from "solid-sonner";
|
||||||
|
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
||||||
|
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
||||||
|
|
||||||
export const NotepadView: Component<{
|
export const NotepadView: Component<{
|
||||||
selectedNoteId: string | null;
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
const instance = editorInstance();
|
const instance = editorInstance();
|
||||||
if (instance) instance.chain().focus().uploadImage().run();
|
if (instance) instance.chain().focus().uploadMedia().run();
|
||||||
}}
|
}}
|
||||||
title="Upload Image"
|
title="Upload"
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<FileText size={14} class="opacity-70" />
|
<FileText size={14} class="opacity-70" />
|
||||||
<span>File</span>
|
<span>Upload</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,6 +536,14 @@ export const NotepadView: Component<{
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}
|
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}
|
||||||
|
|||||||
Reference in New Issue
Block a user