Compare commits

...

6 Commits

Author SHA1 Message Date
tcardoza 620071e19e combined upload buttons
CI / build (push) Has been skipped
CI / deploy (push) Successful in 2m1s
2026-03-17 18:52:06 -05:00
tcardoza 8f8bd8dcac Combined upload slash commands 2026-03-17 18:47:53 -05:00
tcardoza 94fce5652a image resize 2026-03-17 18:36:10 -05:00
tcardoza 5f7f3995f8 text formatting finished 2026-03-17 18:20:00 -05:00
tcardoza 35d968e2d8 Basic text formatting updates 2026-03-17 17:53:52 -05:00
tcardoza a096047a61 image/video/file delete button on mobile 2026-03-17 17:28:20 -05:00
11 changed files with 539 additions and 220 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "tasgrid",
"private": true,
"version": "1.1.0",
"version": "1.1.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 4001",
@@ -63,4 +63,4 @@
"vite": "^7.2.4",
"vite-plugin-solid": "^2.11.10"
}
}
}
+6 -130
View File
@@ -1,5 +1,5 @@
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";
export interface CommandItem {
@@ -20,33 +20,6 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
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",
description: "Big section heading.",
@@ -107,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: "Upload",
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();
},
},
{
@@ -158,87 +114,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
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",
description: "Delete the entire table.",
+17 -42
View File
@@ -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 { 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 { 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 { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "./ui/button";
@@ -18,6 +18,8 @@ import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { pb } from "@/lib/pocketbase";
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 })));
@@ -404,7 +406,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</Button>
</div>
{/* Image Upload Shortcut */}
{/* Unified Media Upload Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
@@ -413,48 +415,12 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
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>
{/* 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();
instance.chain().focus().uploadMedia().run();
}
}}
>
<FileText size={14} class="opacity-70" />
<span class="hidden sm:inline">File</span>
<span class="hidden sm:inline">Upload</span>
</Button>
</div>
</div>
@@ -493,10 +459,19 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</div>
<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>
{/* 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" />}>
<TaskEditor
content={props.task.content}
+2 -10
View File
@@ -30,12 +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 { TableBubbleMenu } from "./TableBubbleMenu";
import { MediaUpload } from "@/lib/extensions/media-upload";
interface TaskEditorProps {
content?: string;
@@ -215,11 +212,9 @@ export const TaskEditor: Component<TaskEditorProps> = (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) || "",
@@ -323,9 +318,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Handle full screen images as before
return (
<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 h-8 cursor-pointer group flex items-center justify-center shrink-0 transition-all"
+206
View File
@@ -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>
);
};
+1 -1
View File
@@ -182,7 +182,7 @@ export const FileAttachment = Node.create<FileAttachmentOptions>({
// Delete button overlay
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');
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>';
+1 -1
View File
@@ -95,7 +95,7 @@ export const FileViewer = Node.create<FileViewerOptions>({
// Delete button overlay
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');
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
View File
@@ -13,6 +13,14 @@ export const CustomImage = Image.extend({
src: {
default: null,
},
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
@@ -39,9 +47,16 @@ export const CustomImage = Image.extend({
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
// 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.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');
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.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
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');
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);
wrapper.appendChild(img);
wrapper.appendChild(resizer);
wrapper.appendChild(overlay);
return {
+79
View File
@@ -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
View File
@@ -27,6 +27,14 @@ export const Video = Node.create<VideoOptions>({
src: {
default: null,
},
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
@@ -89,8 +97,16 @@ export const Video = Node.create<VideoOptions>({
addNodeView() {
return ({ node, getPos, editor }) => {
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.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');
Object.entries(node.attrs).forEach(([key, value]) => {
@@ -107,10 +123,97 @@ export const Video = Node.create<VideoOptions>({
video.setAttribute('controls', 'true');
}
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
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');
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);
wrapper.appendChild(video);
wrapper.appendChild(resizer);
wrapper.appendChild(overlay);
return {
+14 -30
View File
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, createEffect, onCleanup, For,
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store";
import { pb } from "@/lib/pocketbase";
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 { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
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 { NotesSidebar } from "@/components/NotesSidebar";
import { toast } from "solid-sonner";
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
export const NotepadView: Component<{
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"
onClick={() => {
const instance = editorInstance();
if (instance) instance.chain().focus().uploadImage().run();
if (instance) instance.chain().focus().uploadMedia().run();
}}
title="Upload Image"
>
<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"
title="Upload"
>
<FileText size={14} class="opacity-70" />
<span>File</span>
<span>Upload</span>
</Button>
</div>
</div>
@@ -560,6 +536,14 @@ export const NotepadView: Component<{
</Show>
</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>
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}