added image support and horizontal line support
This commit is contained in:
@@ -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('<p></p>')
|
||||
.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()));
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TaskEditorProps> = (props) => {
|
||||
let editorRef: HTMLDivElement | undefined;
|
||||
const [fullscreenImage, setFullscreenImage] = createSignal<string | null>(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<TaskEditorProps> = (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<TaskEditorProps> = (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<TaskEditorProps> = (props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editorRef}
|
||||
class="w-full cursor-text"
|
||||
onClick={() => editor()?.chain().focus().run()}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
ref={editorRef}
|
||||
class="w-full cursor-text"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName.toLowerCase() === 'img') {
|
||||
setFullscreenImage((target as HTMLImageElement).src);
|
||||
} else {
|
||||
editor()?.chain().focus().run();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={fullscreenImage()}>
|
||||
<div
|
||||
class="fixed inset-0 z-[9999] p-4 bg-background/90 backdrop-blur-sm flex items-center justify-center animate-in fade-in duration-200 overflow-hidden"
|
||||
onClick={(e) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 bg-background/50 hover:bg-background/80 rounded-full transition-colors z-[10000]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFullscreenImage(null);
|
||||
resetZoom();
|
||||
}}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div
|
||||
class="relative flex items-center justify-center w-full h-full pointer-events-none"
|
||||
>
|
||||
<img
|
||||
src={fullscreenImage()!}
|
||||
class={cn(
|
||||
"max-w-full max-h-[90vh] object-contain rounded-md shadow-2xl pointer-events-auto transition-transform duration-75",
|
||||
isDragging() ? "cursor-grabbing" : (zoomScale() > 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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user