feat: Replace note content textarea with Tiptap rich text editor supporting markdown and slash commands.

This commit is contained in:
2025-12-23 12:23:28 +01:00
parent 7977ccf612
commit 8396d7f5d3
10 changed files with 11415 additions and 10 deletions

11029
k-notes-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,12 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@tiptap/extension-placeholder": "^3.14.0",
"@tiptap/extension-task-item": "^3.14.0",
"@tiptap/extension-task-list": "^3.14.0",
"@tiptap/react": "^3.14.0",
"@tiptap/starter-kit": "^3.14.0",
"@tiptap/suggestion": "^3.14.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -59,11 +65,14 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"vaul": "^1.1.2",
"zod": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^25.0.3",
"@types/react": "^19.2.5",
@@ -74,6 +83,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"remark-gfm": "^4.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",

View File

@@ -0,0 +1,91 @@
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { cn } from '@/lib/utils';
import { Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Type, Quote, Code } from 'lucide-react';
export interface CommandItemProps {
title: string;
icon: React.ReactNode;
command: (editor: any) => void;
}
interface CommandListProps {
items: CommandItemProps[];
command: (item: CommandItemProps) => void;
editor: any; // TipTap editor instance
}
export const CommandList = forwardRef((props: CommandListProps, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
props.command(item);
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<div className="flex flex-col gap-1 p-1">
{props.items.map((item, index) => (
<button
key={index}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
index === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)}
onClick={() => selectItem(index)}
>
<div className="mr-2 flex h-4 w-4 items-center justify-center">
{item.icon}
</div>
<span>{item.title}</span>
</button>
))}
{props.items.length === 0 && (
<div className="p-2 text-sm text-muted-foreground">No results</div>
)}
</div>
</div>
);
});
CommandList.displayName = 'CommandList';

View File

@@ -0,0 +1,114 @@
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { Markdown } from 'tiptap-markdown';
import { SlashCommand } from './extensions';
import { getSuggestionItems, renderItems } from './suggestions';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { cn } from '@/lib/utils';
import { useEffect } from 'react';
interface EditorProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
}
export function Editor({ value, onChange, placeholder, className }: EditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder: placeholder || 'Type / for commands...',
}),
Markdown,
TaskList,
TaskItem.configure({
nested: true,
}),
SlashCommand.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
}),
],
content: value,
editorProps: {
attributes: {
class: cn(
"min-h-[100px] max-h-[400px] overflow-y-auto w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 prose dark:prose-invert max-w-none",
className
),
},
},
onUpdate: ({ editor }) => {
const markdown = (editor.storage as any).markdown.getMarkdown();
onChange?.(markdown);
},
});
// Sync content if value changes externally (and editor is not focused? care to avoid loops)
useEffect(() => {
if (editor && value !== undefined && value !== (editor.storage as any).markdown.getMarkdown()) {
// Only set content if it's different to avoid cursor jumping
// A simple check might not be enough but good for now for initial loading
if (editor.getText() === "" && value !== "") {
editor.commands.setContent(value);
}
}
}, [value, editor]);
return (
<div className="relative w-full">
<EditorContent editor={editor} />
{/*
TipTap styles for placeholder
*/}
<style>{`
.tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Basic Task list styles */
ul[data-type="taskList"] {
list-style: none;
padding: 0;
}
ul[data-type="taskList"] li {
display: flex;
align-items: flex-start; /* Align checkbox with top of text */
}
ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
margin-top: 0.15rem; /* Optical alignment */
}
ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
export const SlashCommand = Extension.create({
name: 'slashCommand',
addOptions() {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,128 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { CommandList } from './command-list';
import { Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Type, Quote, Code } from 'lucide-react';
export const getSuggestionItems = ({ query }: { query: string }) => {
return [
{
title: 'Text',
icon: <Type size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setParagraph().run();
},
},
{
title: 'Heading 1',
icon: <Heading1 size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run();
},
},
{
title: 'Heading 2',
icon: <Heading2 size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run();
},
},
{
title: 'Heading 3',
icon: <Heading3 size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run();
},
},
{
title: 'Ordered List',
icon: <ListOrdered size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: 'Bullet List',
icon: <List size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: 'Task List',
icon: <CheckSquare size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: 'Blockquote',
icon: <Quote size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
}
},
{
title: 'Code Block',
icon: <Code size={18} />,
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
}
}
].filter((item) => item.title.toLowerCase().startsWith(query.toLowerCase()));
};
export const renderItems = () => {
let component: any;
let popup: any;
return {
onStart: (props: any) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props: any) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
};

View File

@@ -11,6 +11,7 @@ import { NoteForm } from "./note-form";
import ReactMarkdown from "react-markdown";
import { getNoteColor } from "@/lib/constants";
import clsx from "clsx";
import remarkGfm from "remark-gfm";
import { VersionHistoryDialog } from "./version-history-dialog";
import { NoteViewDialog } from "./note-view-dialog";
import { Checkbox } from "@/components/ui/checkbox";
@@ -116,7 +117,7 @@ export function NoteCard({ note }: NoteCardProps) {
</CardHeader>
<CardContent className="pb-2">
<div className="text-sm prose dark:prose-invert prose-sm max-w-none line-clamp-4">
<ReactMarkdown>{note.content}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{note.content}</ReactMarkdown>
</div>
</CardContent>
<CardFooter className="flex flex-col items-start gap-2 pt-2">

View File

@@ -4,9 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Editor } from "@/components/editor/editor";
const noteSchema = z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
@@ -61,7 +62,11 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea placeholder="Note content..." className="min-h-[100px] font-mono" {...field} />
<Editor
placeholder="Note content... Type / for commands"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -93,13 +98,11 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa
<div
key={color.name}
onClick={() => field.onChange(color.name)}
className={`w-8 h-8 rounded-full cursor-pointer border-2 transition-all ${
color.value.split(" ")[0] // Take background class
} ${
field.value === color.name
className={`w-8 h-8 rounded-full cursor-pointer border-2 transition-all ${color.value.split(" ")[0] // Take background class
} ${field.value === color.name
? "border-primary scale-110"
: "border-transparent hover:scale-105"
}`}
}`}
title={color.label}
/>
))}

View File

@@ -7,6 +7,7 @@ import { type Note } from "@/hooks/use-notes";
import { Edit, Calendar, Pin } from "lucide-react";
import { getNoteColor } from "@/lib/constants";
import clsx from "clsx";
import remarkGfm from "remark-gfm";
interface NoteViewDialogProps {
@@ -39,7 +40,7 @@ export function NoteViewDialog({ note, open, onOpenChange, onEdit }: NoteViewDia
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
<div className="prose dark:prose-invert max-w-none text-base leading-relaxed break-words pb-6">
<ReactMarkdown>{note.content}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{note.content}</ReactMarkdown>
</div>
</div>

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));