diff --git a/bun.lock b/bun.lock index f2fbc83..660a4d4 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@kobalte/core": "^0.13.11", "@tailwindcss/vite": "^4.1.18", "@tiptap/core": "^3.18.0", + "@tiptap/extension-image": "^3.20.0", "@tiptap/extension-placeholder": "^3.18.0", "@tiptap/extension-task-item": "^3.18.0", "@tiptap/extension-task-list": "^3.18.0", @@ -467,6 +468,8 @@ "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-fEq7DwwQZ496RHNbMQypBVNqoWnhDEERbzWMBqlmfCfc/0FvJrHtsQkk3k4lgqMYqmBwym3Wp0SrRYiyKCPGTw=="], + "@tiptap/extension-image": ["@tiptap/extension-image@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g=="], + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1C4nB08psiRo0BPxAbpYq8peUOKnjQWtBCLPbE6B9ToTK3vmUk0AZTqLO11FvokuM1GF5l2Lg3sKrKFuC2hcjQ=="], "@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="], diff --git a/package.json b/package.json index 93e5757..e0f7136 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@kobalte/core": "^0.13.11", "@tailwindcss/vite": "^4.1.18", "@tiptap/core": "^3.18.0", + "@tiptap/extension-image": "^3.20.0", "@tiptap/extension-placeholder": "^3.18.0", "@tiptap/extension-task-item": "^3.18.0", "@tiptap/extension-task-list": "^3.18.0", diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 9a7fb39..0bbb983 100644 --- a/src/components/SlashMenu.tsx +++ b/src/components/SlashMenu.tsx @@ -2,9 +2,12 @@ import { type Component, createSignal, For, createEffect } from "solid-js"; import { Heading1, Heading2, Heading3, List, ListTodo, Type, - Code, Quote + Code, Quote, + Image as ImageIcon, Minus } from "lucide-solid"; -import { cn } from "@/lib/utils"; +import { cn, convertImageToWebpFile } from "@/lib/utils"; +import { activeTaskId, uploadTaskAttachment } from "@/store"; +import { toast } from "solid-sonner"; export interface CommandItem { title: string; @@ -79,6 +82,62 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] editor.chain().focus().deleteRange(range).toggleBlockquote().run(); }, }, + { + title: "Image", + description: "Upload an image.", + icon: ImageIcon, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).run(); + const taskId = activeTaskId(); + if (!taskId) { + toast.error("Cannot upload image outside of a task."); + 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); + return uploadTaskAttachment(taskId, 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('

') + .run(); + return "Image uploaded."; + }, + error: "Failed to upload image." + } + ); + } + }; + input.click(); + }, + }, + { + title: "Divider", + description: "Insert a horizontal rule.", + icon: Minus, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + }, + }, ].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase())); }; diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index d9d8293..54e20d8 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -1,9 +1,11 @@ -import { type Component, createEffect, untrack } from "solid-js"; +import { type Component, createEffect, untrack, createSignal, Show } from "solid-js"; import { createTiptapEditor } from "solid-tiptap"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; +import Image from "@tiptap/extension-image"; +import { X } from "lucide-solid"; import { cn } from "@/lib/utils"; import { SlashCommands } from "@/lib/slash-command"; @@ -20,6 +22,16 @@ interface TaskEditorProps { export const TaskEditor: Component = (props) => { let editorRef: HTMLDivElement | undefined; + const [fullscreenImage, setFullscreenImage] = createSignal(null); + const [zoomScale, setZoomScale] = createSignal(1); + const [zoomOffset, setZoomOffset] = createSignal({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = createSignal(false); + const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 }); + + const resetZoom = () => { + setZoomScale(1); + setZoomOffset({ x: 0, y: 0 }); + }; const editor = createTiptapEditor(() => ({ element: editorRef!, @@ -53,6 +65,11 @@ export const TaskEditor: Component = (props) => { SlashCommands.configure({ suggestion, }), + Image.configure({ + HTMLAttributes: { + class: "rounded-lg border border-border max-w-full h-auto", + }, + }), MobileIndent, ], // Use untrack so the editor doesn't re-initialize when props.content changes @@ -68,7 +85,7 @@ export const TaskEditor: Component = (props) => { "prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg", "prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1", "prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4", - "prose-img:rounded-lg prose-img:border prose-img:border-border", + "prose-img:rounded-lg prose-img:border prose-img:border-border prose-img:cursor-zoom-in", // Custom Task List Styling "[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0", "[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start", @@ -98,10 +115,93 @@ export const TaskEditor: Component = (props) => { }); return ( -
editor()?.chain().focus().run()} - /> + <> +
{ + const target = e.target as HTMLElement; + if (target.tagName.toLowerCase() === 'img') { + setFullscreenImage((target as HTMLImageElement).src); + } else { + editor()?.chain().focus().run(); + } + }} + /> + +
{ + if (e.target === e.currentTarget) { + setFullscreenImage(null); + resetZoom(); + } + }} + onWheel={(e) => { + e.preventDefault(); + const scaleSpeed = 0.05; + const delta = e.deltaY < 0 ? 1 : -1; + setZoomScale(s => Math.min(Math.max(1, s + delta * scaleSpeed), 5)); + }} + onPointerDown={(e) => { + if (zoomScale() > 1 && e.target.tagName.toLowerCase() === 'img') { + setIsDragging(true); + setDragStart({ x: e.clientX - zoomOffset().x, y: e.clientY - zoomOffset().y }); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + } + }} + onPointerMove={(e) => { + if (isDragging()) { + setZoomOffset({ + x: e.clientX - dragStart().x, + y: e.clientY - dragStart().y + }); + } + }} + onPointerUp={(e) => { + setIsDragging(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }} + onPointerCancel={(e) => { + setIsDragging(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }} + > + +
+ 1 ? "cursor-grab" : "cursor-zoom-in") + )} + style={{ + transform: `translate(${zoomOffset().x}px, ${zoomOffset().y}px) scale(${zoomScale()})`, + }} + draggable={false} + onClick={(e) => { + e.stopPropagation(); + if (zoomScale() === 1) { + setZoomScale(2); + } else if (!isDragging()) { + // Optional: Click to single-zoom-out or do nothing during pan + } + }} + /> +
+
+
+ ); }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 03aaa4b..ebc561b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,94 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality = 0.7): Promise => { + return new Promise((resolve) => { + const img = new Image(); + img.src = base64Str; + img.onload = () => { + let { width, height } = img; + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + if (height > maxWidth) { + width = Math.round((width * maxWidth) / height); + height = maxWidth; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(base64Str); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/jpeg", quality)); + }; + img.onerror = () => resolve(base64Str); + }); +}; + +export const convertImageToWebpFile = async (file: File): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + const maxWidth = 2560; // 2K resolution max for task images + + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + if (height > maxWidth) { + width = Math.round((width * maxWidth) / height); + height = maxWidth; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(file); // Fallback to original + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (!blob) { + resolve(file); // Fallback to original + return; + } + + // Replace original extension with .webp + const originalName = file.name; + const finalName = originalName.replace(/\.[^/.]+$/, "") + ".webp"; + + const newFile = new File([blob], finalName, { + type: "image/webp", + lastModified: Date.now(), + }); + resolve(newFile); + }, + "image/webp", + 0.7 // 70% Quality + ); + }; + img.onerror = () => resolve(file); // Fallback + img.src = e.target?.result as string; + }; + reader.onerror = () => resolve(file); // Fallback + reader.readAsDataURL(file); + }); +}; diff --git a/src/store/index.ts b/src/store/index.ts index 157fccd..6f5f412 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -41,6 +41,7 @@ export interface Task { }; size?: number; // 0-10, task complexity/size sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with + attachments?: string[]; // Array of filenames from PocketBase } export const checkRecurringTasks = () => { @@ -423,10 +424,34 @@ const mapRecordToTask = (r: any): Task => { updated: r.updated, recurrence: r.recurrence, size: (r.size === null || r.size === undefined) ? undefined : r.size, - sharedWith: r.sharedWith + sharedWith: r.sharedWith, + attachments: r.attachments || [] }; }; +export const uploadTaskAttachment = async (taskId: string, file: File): Promise => { + try { + const formData = new FormData(); + formData.append('attachments', file); + + const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData); + + // Return the URL for the newly uploaded file. PocketBase appends new files to the array. + // We need to find the specific filename that was just uploaded to get its URL. + // Since we don't know the exact generated filename, we can return the URL of the last file in the array. + if (record.attachments && record.attachments.length > 0) { + const latestFilename = record.attachments[record.attachments.length - 1]; + return pb.files.getURL(record, latestFilename); + } + + throw new Error("File uploaded but no attachment returned"); + } catch (err: any) { + console.error("Error uploading attachment:", err); + toast.error("Failed to upload image."); + throw err; + } +}; + const mapRecordToShareRule = (r: any): ShareRule => { return { id: r.id,