feat: Replace note content textarea with Tiptap rich text editor supporting markdown and slash commands.
This commit is contained in:
11029
k-notes-frontend/package-lock.json
generated
Normal file
11029
k-notes-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
91
k-notes-frontend/src/components/editor/command-list.tsx
Normal file
91
k-notes-frontend/src/components/editor/command-list.tsx
Normal 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';
|
||||
114
k-notes-frontend/src/components/editor/editor.tsx
Normal file
114
k-notes-frontend/src/components/editor/editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
k-notes-frontend/src/components/editor/extensions.ts
Normal file
27
k-notes-frontend/src/components/editor/extensions.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
128
k-notes-frontend/src/components/editor/suggestions.tsx
Normal file
128
k-notes-frontend/src/components/editor/suggestions.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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,10 +98,8 @@ 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"
|
||||
}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user