Significant tiptap updates

This commit is contained in:
2026-02-28 17:11:33 -06:00
parent 5bf00de940
commit 6de09411a1
8 changed files with 546 additions and 5 deletions
+188 -1
View File
@@ -6,28 +6,47 @@ 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 { X, Trash2, ArrowUpToLine, ArrowDownToLine, ArrowLeftToLine, ArrowRightToLine } 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 { BubbleMenu } from "@tiptap/extension-bubble-menu";
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;
let tableMenuRef: 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);
@@ -73,6 +92,81 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
}),
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",
},
}),
BubbleMenu.configure({
element: tableMenuRef!,
shouldShow: ({ editor }) => editor.isActive("table"),
// @ts-ignore - Solid-tiptap types might be outdated
tippyOptions: { placement: "bottom", duration: 100 },
}),
Dropcursor.configure({
color: "var(--primary)",
width: 2,
}),
],
// Use untrack so the editor doesn't re-initialize when props.content changes
content: untrack(() => props.content) || "",
@@ -88,6 +182,14 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
"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",
@@ -118,6 +220,32 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
}
});
// 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
@@ -146,6 +274,65 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
}
}}
/>
{/* Table Bubble Menu */}
<div
ref={tableMenuRef}
class="z-[9999] flex p-0.5 bg-background border border-border shadow-md rounded-md gap-0.5 invisible data-[state=visible]:visible"
>
<button type="button" class="p-1 px-1.5 hover:bg-muted rounded text-xs flex flex-col items-center justify-center opacity-80 hover:opacity-100 transition-opacity whitespace-nowrap text-muted-foreground hover:text-foreground" onClick={() => editor()?.chain().focus().addRowBefore().run()} title="Add Row Above"><ArrowUpToLine size={14} /> +Row</button>
<button type="button" class="p-1 px-1.5 hover:bg-muted rounded text-xs flex flex-col items-center justify-center opacity-80 hover:opacity-100 transition-opacity whitespace-nowrap text-muted-foreground hover:text-foreground" onClick={() => editor()?.chain().focus().addRowAfter().run()} title="Add Row Below"><ArrowDownToLine size={14} /> +Row</button>
<button type="button" class="p-1 px-1.5 hover:bg-muted rounded text-xs flex flex-col items-center justify-center opacity-60 hover:opacity-100 transition-opacity whitespace-nowrap text-muted-foreground hover:text-foreground" onClick={() => editor()?.chain().focus().deleteRow().run()} title="Delete Row"><Trash2 size={14} /> -Row</button>
<div class="w-px bg-border my-1 mx-0.5 shrink-0" />
<button type="button" class="p-1 px-1.5 hover:bg-muted rounded text-xs flex flex-col items-center justify-center opacity-80 hover:opacity-100 transition-opacity whitespace-nowrap text-muted-foreground hover:text-foreground" onClick={() => editor()?.chain().focus().addColumnBefore().run()} title="Add Col Left"><ArrowLeftToLine size={14} /> +Col</button>
<button type="button" class="p-1 px-1.5 hover:bg-muted rounded text-xs flex flex-col items-center justify-center opacity-80 hover:opacity-100 transition-opacity whitespace-nowrap text-muted-foreground hover:text-foreground" onClick={() => editor()?.chain().focus().addColumnAfter().run()} title="Add Col Right"><ArrowRightToLine size={14} /> +Col</button>
<button type="button" class="p-1 px-1.5 hover:bg-muted rounded text-xs flex flex-col items-center justify-center opacity-60 hover:opacity-100 transition-opacity whitespace-nowrap text-muted-foreground hover:text-foreground" onClick={() => editor()?.chain().focus().deleteColumn().run()} title="Delete Col"><Trash2 size={14} /> -Col</button>
</div>
{/* 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"