Upload image button
This commit is contained in:
@@ -9,9 +9,7 @@ import {
|
|||||||
AlignLeft, AlignCenter, AlignRight,
|
AlignLeft, AlignCenter, AlignRight,
|
||||||
Trash2, AppWindow
|
Trash2, AppWindow
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
|
|
||||||
import { toast } from "solid-sonner";
|
|
||||||
|
|
||||||
export interface CommandItem {
|
export interface CommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -122,54 +120,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
description: "Upload an image.",
|
description: "Upload an image.",
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).uploadImage().run();
|
||||||
|
|
||||||
const taskId = activeTaskId();
|
|
||||||
const noteId = activeNoteId();
|
|
||||||
|
|
||||||
if (!taskId && !noteId) {
|
|
||||||
toast.error("Cannot upload image outside of a task or note.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "file";
|
|
||||||
input.accept = "image/*";
|
|
||||||
input.onchange = async () => {
|
|
||||||
if (input.files?.length) {
|
|
||||||
const originalFile = input.files[0];
|
|
||||||
toast.promise(
|
|
||||||
(async () => {
|
|
||||||
const webpFile = await convertImageToWebpFile(originalFile);
|
|
||||||
if (taskId) {
|
|
||||||
return uploadTaskAttachment(taskId, webpFile);
|
|
||||||
} else {
|
|
||||||
return uploadNoteAttachment(noteId!, webpFile);
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
{
|
|
||||||
loading: "Processing & uploading image...",
|
|
||||||
success: (url) => {
|
|
||||||
// Insert image, then run enter to add a newline (paragraph) below it.
|
|
||||||
// Without this, the image stays selected and the next keypress deletes it.
|
|
||||||
editor.chain()
|
|
||||||
.focus()
|
|
||||||
.setImage({ src: url })
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Manually force a new paragraph at the end so the user can keep typing
|
|
||||||
editor.chain()
|
|
||||||
.focus('end')
|
|
||||||
.insertContent('<p></p>')
|
|
||||||
.run();
|
|
||||||
return "Image uploaded.";
|
|
||||||
},
|
|
||||||
error: "Failed to upload image."
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|||||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag } from "@/store";
|
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag } 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 } from "lucide-solid";
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon } 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";
|
||||||
@@ -363,7 +363,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
|
|
||||||
{/* Favorite */}
|
{/* Favorite */}
|
||||||
<div class="flex items-center gap-1 group shrink-0">
|
<div class="flex items-center gap-1 group shrink-0">
|
||||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Favorite</span>
|
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline"></span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -398,6 +398,24 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
<span class="hidden sm:inline">Commands</span>
|
<span class="hidden sm:inline">Commands</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Image 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().uploadImage().run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImageIcon size={14} class="opacity-70" />
|
||||||
|
<span class="hidden sm:inline">Image</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata Row (Dates/Timestamps) */}
|
{/* Metadata Row (Dates/Timestamps) */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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";
|
||||||
|
|
||||||
interface TaskEditorProps {
|
interface TaskEditorProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -161,6 +162,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
width: 2,
|
width: 2,
|
||||||
}),
|
}),
|
||||||
FileViewer,
|
FileViewer,
|
||||||
|
ImageUpload,
|
||||||
],
|
],
|
||||||
// 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) || "",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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> {
|
||||||
|
imageUpload: {
|
||||||
|
uploadImage: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageUpload = Extension.create({
|
||||||
|
name: 'imageUpload',
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
uploadImage: () => ({ editor }) => {
|
||||||
|
const taskId = activeTaskId();
|
||||||
|
const noteId = activeNoteId();
|
||||||
|
|
||||||
|
if (!taskId && !noteId) {
|
||||||
|
toast.error("Cannot upload image outside of a task or note.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
const originalFile = input.files[0];
|
||||||
|
toast.promise(
|
||||||
|
(async () => {
|
||||||
|
const webpFile = await convertImageToWebpFile(originalFile);
|
||||||
|
if (taskId) {
|
||||||
|
return uploadTaskAttachment(taskId, webpFile);
|
||||||
|
} else {
|
||||||
|
return uploadNoteAttachment(noteId!, webpFile);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
{
|
||||||
|
loading: "Processing & uploading image...",
|
||||||
|
success: (url) => {
|
||||||
|
// Insert image
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({ src: url })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Manually force a new paragraph at the end so the user can keep typing
|
||||||
|
editor.chain()
|
||||||
|
.focus('end')
|
||||||
|
.insertContent('<p></p>')
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return "Image uploaded.";
|
||||||
|
},
|
||||||
|
error: "Failed to upload image."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 { Trash2, Lock, Unlock, Search, Link, X, ChevronRight, ChevronDown, FileText, Plus, Star, MoreHorizontal } from "lucide-solid";
|
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight, ChevronDown, FileText, Plus, Star, MoreHorizontal, Type, Image as ImageIcon } 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";
|
||||||
@@ -22,6 +22,7 @@ export const NotepadView: Component<{
|
|||||||
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
||||||
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(false);
|
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(false);
|
||||||
const [isDragging, setIsDragging] = createSignal(false);
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||||
|
|
||||||
const currentUserId = pb.authStore.model?.id;
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
|
||||||
@@ -432,9 +433,38 @@ export const NotepadView: Component<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Editor Commands Row */}
|
||||||
|
<div class="flex items-center gap-2 mt-1 px-1">
|
||||||
|
<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().insertContent("/").run();
|
||||||
|
}}
|
||||||
|
title="Text Commands"
|
||||||
|
>
|
||||||
|
<Type size={14} class="opacity-70" />
|
||||||
|
<span>Commands</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().uploadImage().run();
|
||||||
|
}}
|
||||||
|
title="Upload Image"
|
||||||
|
>
|
||||||
|
<ImageIcon size={14} class="opacity-70" />
|
||||||
|
<span>Image</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||||
{/* Mobile Tabs Dropdown Moved to Footer */}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -490,6 +520,7 @@ export const NotepadView: Component<{
|
|||||||
<TaskEditor
|
<TaskEditor
|
||||||
content={note().content}
|
content={note().content}
|
||||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||||
|
onEditorReady={setEditorInstance}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
class={hasTopViewer() ? "[&>*:not(.file-viewer-wrapper:first-child)]:px-4 md:[&>*:not(.file-viewer-wrapper:first-child)]:px-6 [&>*:not(.file-viewer-wrapper:first-child)]:max-w-3xl [&>*:not(.file-viewer-wrapper:first-child)]:mx-auto pb-20" : ""}
|
class={hasTopViewer() ? "[&>*:not(.file-viewer-wrapper:first-child)]:px-4 md:[&>*:not(.file-viewer-wrapper:first-child)]:px-6 [&>*:not(.file-viewer-wrapper:first-child)]:max-w-3xl [&>*:not(.file-viewer-wrapper:first-child)]:mx-auto pb-20" : ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user