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 Underline from "@tiptap/extension-underline"; import { X } from "lucide-solid"; import { cn } from "@/lib/utils"; import { SlashCommands } from "@/lib/slash-command"; import { suggestion } from "@/lib/slash-renderer"; import { MobileIndent } from "@/lib/mobile-indent"; interface TaskEditorProps { content?: string; onUpdate: (html: string) => void; onEditorReady?: (editor: any) => void; editable?: boolean; class?: string; } 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!, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, }), Placeholder.configure({ placeholder: ({ editor }) => { // Only show placeholder if first node is a paragraph and doc is empty/short if (editor.state.doc.childCount > 1 || (editor.state.doc.firstChild && editor.state.doc.firstChild.type.name !== 'paragraph')) { return ""; } return "Type '/' for commands or start writing..."; }, emptyEditorClass: "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0", }), TaskList.configure({ HTMLAttributes: { class: "not-prose pl-2", }, }), TaskItem.configure({ nested: true, HTMLAttributes: { class: "flex gap-2 items-start my-1", }, }), SlashCommands.configure({ suggestion, }), Image.configure({ HTMLAttributes: { class: "rounded-lg border border-border max-w-full h-auto", }, }), Underline, MobileIndent, ], // Use untrack so the editor doesn't re-initialize when props.content changes content: untrack(() => props.content) || "", editable: props.editable ?? true, onUpdate: ({ editor }) => { props.onUpdate(editor.getHTML()); }, editorProps: { attributes: { class: cn( "prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]", "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: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", "[&_li[data-type='taskItem']>label]:mr-3 [&_li[data-type='taskItem']>label]:mt-1 [&_li[data-type='taskItem']>label]:select-none", "[&_li[data-type='taskItem']>label>input]:w-5 [&_li[data-type='taskItem']>label>input]:h-5 [&_li[data-type='taskItem']>label>input]:cursor-pointer", "[&_li[data-type='taskItem']>div]:flex-1 [&_li[data-type='taskItem']>div]:min-h-[1.5rem]", props.class ), }, }, })); // Expose editor to parent createEffect(() => { const e = editor(); if (e && props.onEditorReady) { props.onEditorReady(e); } }); // Sync content updates from outside if needed (but only if NOT focused to avoid jumps) createEffect(() => { const content = props.content; const e = editor(); if (e && content !== undefined && !e.isFocused && content !== e.getHTML()) { e.commands.setContent(content, { emitUpdate: false }); } }); return ( <>
{ 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 } }} />
); };