391 lines
18 KiB
TypeScript
391 lines
18 KiB
TypeScript
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 Image from "@tiptap/extension-image";
|
|
import Underline from "@tiptap/extension-underline";
|
|
import { X } from "lucide-solid";
|
|
import { cn } from "@/lib/utils";
|
|
import { store } from "@/store";
|
|
|
|
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";
|
|
|
|
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<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 [previewNote, setPreviewNote] = createSignal<{ noteId: string, x: number, y: number } | null>(null);
|
|
|
|
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",
|
|
},
|
|
}),
|
|
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,
|
|
}),
|
|
],
|
|
// 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",
|
|
// 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
|
|
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 (
|
|
<div class="relative w-full h-full flex flex-col">
|
|
<div
|
|
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
|
|
onClick={() => {
|
|
const e = editor();
|
|
if (!e) return;
|
|
e.chain().insertContentAt(0, '<p></p>').focus('start').run();
|
|
}}
|
|
title="Add empty line at the top"
|
|
>
|
|
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity mt-1">
|
|
Click to insert newline above
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={editorRef}
|
|
class="w-full flex-1 cursor-text"
|
|
onClick={(e) => {
|
|
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 */}
|
|
<Show when={previewNote() && store.notes.find(n => n.id === previewNote()!.noteId)}>
|
|
{(note) => {
|
|
const pos = previewNote()!;
|
|
return (
|
|
<div
|
|
class="fixed z-[250] max-w-[280px] w-full bg-popover border border-border shadow-xl rounded-lg p-3 animate-in fade-in zoom-in-95 duration-200 pointer-events-auto"
|
|
style={{ left: `${Math.min(pos.x, window.innerWidth - 300)}px`, top: `${pos.y + 4}px` }}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
<h4 class="font-bold text-sm mb-1 truncate text-foreground">{note().title}</h4>
|
|
<p class="text-xs text-muted-foreground line-clamp-3 mb-3 break-words whitespace-pre-wrap">
|
|
{note().content?.replace(/<[^>]*>?/gm, '') || "Empty note"}
|
|
</p>
|
|
<div class="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2.5 py-1.5 rounded bg-muted hover:bg-muted/80 text-foreground font-medium transition-colors"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setPreviewNote(null);
|
|
}}
|
|
>
|
|
Close
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2.5 py-1.5 rounded bg-primary hover:bg-primary/90 text-primary-foreground font-medium transition-colors shadow-sm"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (props.onNoteOpen) {
|
|
props.onNoteOpen(note().id);
|
|
}
|
|
setPreviewNote(null);
|
|
}}
|
|
>
|
|
Open Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}}
|
|
</Show>
|
|
|
|
<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>
|
|
</div>
|
|
);
|
|
};
|