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,