From 8f8bd8dcacd1407ab57d3416585ddc285b97988b Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Tue, 17 Mar 2026 18:47:53 -0500 Subject: [PATCH] Combined upload slash commands --- src/components/SlashMenu.tsx | 27 ++------- src/components/TaskEditor.tsx | 8 +-- src/lib/extensions/file-attachment.ts | 2 +- src/lib/extensions/file-viewer.ts | 2 +- src/lib/extensions/image.ts | 2 +- src/lib/extensions/media-upload.ts | 79 +++++++++++++++++++++++++++ src/lib/extensions/video.ts | 2 +- 7 files changed, 90 insertions(+), 32 deletions(-) create mode 100644 src/lib/extensions/media-upload.ts diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 463e359..20a1066 100644 --- a/src/components/SlashMenu.tsx +++ b/src/components/SlashMenu.tsx @@ -1,5 +1,5 @@ import { type Component, createSignal, For, createEffect } from "solid-js"; -import { Type, Code, Quote, ImageIcon, Heading1, Heading2, Heading3, List, ListTodo, Film, FileText, AppWindow, Minus, Grid3X3, Trash2 } 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 { @@ -80,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: "Media / File", + 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(); }, }, { diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 3b712be..6178e80 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -30,11 +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 { MediaUpload } from "@/lib/extensions/media-upload"; interface TaskEditorProps { content?: string; @@ -214,11 +212,9 @@ export const TaskEditor: Component = (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) || "", diff --git a/src/lib/extensions/file-attachment.ts b/src/lib/extensions/file-attachment.ts index e726616..3e9fc09 100644 --- a/src/lib/extensions/file-attachment.ts +++ b/src/lib/extensions/file-attachment.ts @@ -182,7 +182,7 @@ export const FileAttachment = Node.create({ // Delete button overlay const overlay = document.createElement('div'); - overlay.classList.add('absolute', 'top-1.5', 'right-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'); + 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 = ''; diff --git a/src/lib/extensions/file-viewer.ts b/src/lib/extensions/file-viewer.ts index 3fc2b95..ab40e6e 100644 --- a/src/lib/extensions/file-viewer.ts +++ b/src/lib/extensions/file-viewer.ts @@ -95,7 +95,7 @@ export const FileViewer = Node.create({ // Delete button overlay const overlay = document.createElement('div'); - overlay.classList.add('absolute', 'top-2', 'right-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'); + 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 = ''; diff --git a/src/lib/extensions/image.ts b/src/lib/extensions/image.ts index 95ca800..861d026 100644 --- a/src/lib/extensions/image.ts +++ b/src/lib/extensions/image.ts @@ -159,7 +159,7 @@ export const CustomImage = Image.extend({ // Delete button overlay const overlay = document.createElement('div'); - overlay.classList.add('absolute', 'top-2', 'right-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'); + 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 = ''; diff --git a/src/lib/extensions/media-upload.ts b/src/lib/extensions/media-upload.ts new file mode 100644 index 0000000..774afc0 --- /dev/null +++ b/src/lib/extensions/media-upload.ts @@ -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 { + 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('

') + .run(); + + return "Media uploaded."; + }, + error: "Failed to upload media." + } + ); + } + }; + input.click(); + return true; + }, + }; + }, +}); diff --git a/src/lib/extensions/video.ts b/src/lib/extensions/video.ts index bd3fb4b..f37de38 100644 --- a/src/lib/extensions/video.ts +++ b/src/lib/extensions/video.ts @@ -213,7 +213,7 @@ export const Video = Node.create({ // Delete button overlay const overlay = document.createElement('div'); - overlay.classList.add('absolute', 'top-2', 'right-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'); + 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 = '';