added image support and horizontal line support

This commit is contained in:
2026-02-20 15:23:14 -06:00
parent 9d8e80a2bf
commit e0cf9aa122
6 changed files with 289 additions and 10 deletions
+107 -7
View File
@@ -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>
</>
);
};