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 { CustomTaskItem } from "./CustomTaskItem"; import { CustomImage as Image } from "@/lib/extensions/image"; import Underline from "@tiptap/extension-underline"; import { X } from "lucide-solid"; import { cn } from "@/lib/utils"; import { store, activeTaskId, activeNoteId, uploadTaskAttachment, uploadNoteAttachment } from "@/store"; import { convertImageToWebpFile } from "@/lib/utils"; import { toast } from "solid-sonner"; import type { Editor } from "@tiptap/core"; import { SlashCommands } from "@/lib/slash-command"; import { suggestion } from "@/lib/slash-renderer"; import { MobileIndent } from "@/lib/mobile-indent"; // New Extensions import { Link } from "@tiptap/extension-link"; import { Highlight } from "@tiptap/extension-highlight"; import { TextAlign } from "@tiptap/extension-text-align"; import { Table } from "@tiptap/extension-table"; import { TableRow } from "@tiptap/extension-table-row"; import { TableCell } from "@tiptap/extension-table-cell"; import { TableHeader } from "@tiptap/extension-table-header"; import { Typography } from "@tiptap/extension-typography"; 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 { Video } from "@/lib/extensions/video"; import { FileAttachment } from "@/lib/extensions/file-attachment"; import { MediaUpload } from "@/lib/extensions/media-upload"; interface TaskEditorProps { content?: string; onUpdate: (html: string) => void; onEditorReady?: (editor: any) => void; editable?: boolean; onUserMention?: (userName: string) => void; onNoteOpen?: (noteId: string) => void; 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 [previewNote, setPreviewNote] = createSignal<{ noteId: string, x: number, y: number } | null>(null); const resetZoom = () => { setZoomScale(1); setZoomOffset({ x: 0, y: 0 }); }; const handleFileUploads = async (editor: Editor, files: File[]) => { const taskId = activeTaskId(); const noteId = activeNoteId(); if (!taskId && !noteId) { toast.error("Cannot upload files outside of a task or note."); return; } for (const file of files) { const isImage = file.type.startsWith('image/'); const isVideo = file.type.startsWith('video/'); toast.promise( (async () => { let fileToUpload = file; if (isImage) { fileToUpload = await convertImageToWebpFile(file); } if (taskId) { return uploadTaskAttachment(taskId, fileToUpload); } else { return uploadNoteAttachment(noteId!, fileToUpload); } })(), { loading: `Uploading ${file.name}...`, 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: file.size.toString() }).run(); } return `${file.name} uploaded.`; }, error: `Failed to upload ${file.name}.` } ); } }; 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", }, }), CustomTaskItem.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.configure({}), MobileIndent.configure({}), Link.configure({ autolink: true, openOnClick: false, HTMLAttributes: { class: "text-primary underline underline-offset-4 cursor-pointer hover:text-primary/80 transition-colors", }, }), Highlight.configure({ multicolor: true, }), TextAlign.configure({ types: ["heading", "paragraph"], }), Table.configure({ resizable: true, allowTableNodeSelection: true, }), TableRow, TableHeader, TableCell, Typography.configure({ openDoubleQuote: false, closeDoubleQuote: false, openSingleQuote: false, closeSingleQuote: false, }), Mention.configure({ HTMLAttributes: { class: "mention bg-primary/10 text-primary font-medium py-0.5 px-1 rounded-md border border-primary/20", }, suggestion: { ...userSuggestion, command: ({ editor, range, props: mentionProps }: any) => { editor .chain() .focus() .insertContentAt(range, [ { type: "mention", attrs: mentionProps, }, { type: "text", text: " ", }, ]) .run(); if (mentionProps.label) { props.onUserMention?.(mentionProps.label); } } }, }), Mention.extend({ name: "noteMention", }).configure({ suggestion: { ...noteSuggestion, char: "#", }, HTMLAttributes: { class: "note-mention bg-muted text-muted-foreground font-medium py-0.5 px-1 rounded-md border border-border italic cursor-pointer hover:bg-muted/80", }, }), Dropcursor.configure({ color: "var(--primary)", width: 2, }), FileViewer, Video, FileAttachment, MediaUpload, ], // 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: { handlePaste: (_view, event) => { const items = Array.from(event.clipboardData?.items || []); const files = items .map(item => item.getAsFile()) .filter((file): file is File => file !== null); if (files.length > 0) { event.preventDefault(); handleFileUploads(editor()!, files); return true; } return false; }, handleDrop: (_view, event, _slice, moved) => { if (!moved && event.dataTransfer?.files?.length) { event.preventDefault(); handleFileUploads(editor()!, Array.from(event.dataTransfer.files)); return true; } return false; }, 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", // New Extension styles "prose-a:text-primary prose-a:underline prose-a:underline-offset-4", "prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic", "[&_table]:border-collapse [&_table]:w-full [&_table]:my-4", "[&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:p-2 [&_th]:text-left [&_th]:font-bold", "[&_td]:border [&_td]:border-border [&_td]:p-2 [&_td]:align-top", "[&_mark]:bg-primary/20 dark:[&_mark]:bg-primary/40 [&_mark]:text-foreground dark:[&_mark]:text-primary-foreground [&_mark]:px-0.5 [&_mark]:rounded-sm", "[&_.mention]:mx-0.5 [&_.note-mention]:mx-0.5", // 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", "[&_.tiptap-task-content]:flex-1 [&_.tiptap-task-content]:min-w-0 [&_.tiptap-task-content]:min-h-[1.5rem]", "[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full", "[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block", "[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br "[&>.file-viewer-wrapper:first-child]:mt-0 [&>.file-viewer-wrapper:first-child]:border-t-0 [&>.file-viewer-wrapper:first-child]:border-x-0 [&>.file-viewer-wrapper:first-child]:rounded-none [&>.file-viewer-wrapper:first-child]:bg-transparent [&>.file-viewer-wrapper:first-child]:h-[80vh]", 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 }); } }); // Handle note mention clicks createEffect(() => { const e = editor(); if (!e) return; const handleClick = (event: MouseEvent) => { const target = event.target as HTMLElement; if (target.classList.contains('note-mention')) { const noteId = target.getAttribute('data-id'); if (noteId) { const rect = target.getBoundingClientRect(); setPreviewNote({ noteId, x: rect.left, y: rect.bottom + window.scrollY }); return; } } // Close preview if clicking elsewhere setPreviewNote(null); }; const element = e.view.dom; if (element) { element.addEventListener('click', handleClick); } }); // Handle full screen images as before return (
{ const e = editor(); if (!e) return; e.chain().insertContentAt(0, '

').focus('start').run(); }} title="Add empty line at the top" > Click to insert newline above
{ const target = e.target as HTMLElement; if (target.tagName.toLowerCase() === 'img') { setFullscreenImage((target as HTMLImageElement).src); } else if (e.target === editorRef) { // Focus end if clicking on padding below content editor()?.chain().focus().run(); } }} />
{/* Note Preview Popover */} n.id === previewNote()!.noteId)}> {(note) => { const pos = previewNote()!; return (
e.stopPropagation()} >

{note().title}

{note().content?.replace(/<[^>]*>?/gm, '') || "Empty note"}

); }}
{ 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 } }} />
); };