feat: replace textarea with TipTap rich text editor

- Add TipTap WYSIWYG editor (headless @tiptap/core) with StarterKit,
  TaskList, TaskItem, Link, Placeholder, and tiptap-markdown extensions
- Always-visible formatting toolbar with undo/redo, H1-H3, bold, italic,
  strikethrough, inline code, code block, blockquote, bullet/ordered/task
  lists, horizontal rule, and link
- Raw markdown toggle for power users
- Render rich text (HTML) on note cards instead of stripped plain text
- Update all e2e tests for TipTap contenteditable editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-05 22:24:33 +01:00
co-authored by Claude Opus 4.6
parent 19c2c7435a
commit 6a2e54497f
12 changed files with 954 additions and 54 deletions
+1
View File
@@ -1 +1,2 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
interface Props {
editor: Editor | undefined;
}
let { editor }: Props = $props();
function toggleLink() {
if (!editor) return;
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
const url = window.prompt('Enter URL:');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}
}
const buttons = [
{ action: () => editor?.chain().focus().undo().run(), label: '\u21A9', title: 'Undo (Ctrl+Z)', testId: 'format-undo', disabled: () => !editor?.can().undo() },
{ action: () => editor?.chain().focus().redo().run(), label: '\u21AA', title: 'Redo (Ctrl+Shift+Z)', testId: 'format-redo', disabled: () => !editor?.can().redo() },
{ separator: true },
{ action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(), label: 'H1', title: 'Heading 1', testId: 'format-h1', isActive: () => editor?.isActive('heading', { level: 1 }) },
{ action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(), label: 'H2', title: 'Heading 2', testId: 'format-h2', isActive: () => editor?.isActive('heading', { level: 2 }) },
{ action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run(), label: 'H3', title: 'Heading 3', testId: 'format-h3', isActive: () => editor?.isActive('heading', { level: 3 }) },
{ action: () => editor?.chain().focus().toggleBold().run(), label: 'B', title: 'Bold (Ctrl+B)', class: 'font-bold', testId: 'format-bold', isActive: () => editor?.isActive('bold') },
{ action: () => editor?.chain().focus().toggleItalic().run(), label: 'I', title: 'Italic (Ctrl+I)', class: 'italic', testId: 'format-italic', isActive: () => editor?.isActive('italic') },
{ action: () => editor?.chain().focus().toggleStrike().run(), label: 'S', title: 'Strikethrough (Ctrl+Shift+X)', class: 'line-through', testId: 'format-strikethrough', isActive: () => editor?.isActive('strike') },
{ action: () => editor?.chain().focus().toggleCode().run(), label: '<>', title: 'Inline code (Ctrl+E)', testId: 'format-code', isActive: () => editor?.isActive('code') },
{ action: () => editor?.chain().focus().toggleCodeBlock().run(), label: '{ }', title: 'Code block', testId: 'format-code-block', isActive: () => editor?.isActive('codeBlock') },
{ action: () => editor?.chain().focus().toggleBlockquote().run(), label: '\u201C', title: 'Blockquote', testId: 'format-blockquote', isActive: () => editor?.isActive('blockquote') },
{ action: () => editor?.chain().focus().toggleBulletList().run(), label: '\u2022', title: 'Bullet list (Ctrl+Shift+8)', testId: 'format-bullet-list', isActive: () => editor?.isActive('bulletList') },
{ action: () => editor?.chain().focus().toggleOrderedList().run(), label: '1.', title: 'Ordered list (Ctrl+Shift+7)', testId: 'format-ordered-list', isActive: () => editor?.isActive('orderedList') },
{ action: () => editor?.chain().focus().toggleTaskList().run(), label: '\u2611', title: 'Task list', testId: 'format-task-list', isActive: () => editor?.isActive('taskList') },
{ action: () => editor?.chain().focus().setHorizontalRule().run(), label: '\u2015', title: 'Horizontal rule', testId: 'format-hr' },
{ action: toggleLink, label: '\uD83D\uDD17', title: 'Insert link (Ctrl+K)', testId: 'format-link', isActive: () => editor?.isActive('link') }
];
</script>
<div
class="flex flex-wrap items-center gap-0.5 border-b border-gray-200/50 px-2 py-1 dark:border-gray-700/50"
data-testid="formatting-toolbar"
>
{#each buttons as btn}
{#if btn.separator}
<div class="mx-1 h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
{:else}
<button
onclick={btn.action}
disabled={btn.disabled?.()}
class="rounded px-2 py-1 text-xs text-gray-600 hover:bg-black/10 dark:text-gray-400 dark:hover:bg-white/10 {btn.class || ''} {btn.isActive?.() ? 'bg-black/10 dark:bg-white/10' : ''} {btn.disabled?.() ? 'opacity-30' : ''}"
title={btn.title}
data-testid={btn.testId}
>
{btn.label}
</button>
{/if}
{/each}
</div>
+6 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import { stripMarkdown } from '$lib/utils/markdown.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
@@ -25,7 +25,7 @@
let cardStyle = $state('');
const preview = $derived(stripMarkdown(note.content).slice(0, 200));
const renderedContent = $derived(renderMarkdown(note.content));
const checklistItems = $derived<ChecklistItem[]>(
note.checklistMode
@@ -70,8 +70,10 @@
<li class="text-xs text-gray-400">+{checklistItems.length - 8} more</li>
{/if}
</ul>
{:else if preview}
<p class="line-clamp-6 text-sm text-gray-700 dark:text-gray-300">{preview}</p>
{:else if note.content}
<div class="prose prose-sm dark:prose-invert line-clamp-6 max-w-none text-sm text-gray-700 dark:text-gray-300" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
{#if note.tags && note.tags.length > 0}
+48 -34
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor } from '$lib/types/index.js';
interface Props {
@@ -13,14 +15,20 @@
onClose: () => void;
}
let { note, isNew = false, onClose }: Props = $props();
const { note, isNew = false, onClose }: Props = $props();
let title = $state(note?.title || '');
let content = $state(note?.content || '');
let color = $state<NoteColor>(note?.color || 'default');
let checklistMode = $state(note?.checklistMode || false);
let showPreview = $state(false);
// svelte-ignore state_referenced_locally
let title = $state(note?.title ?? '');
// svelte-ignore state_referenced_locally
let content = $state(note?.content ?? '');
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? 'default');
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let rawMarkdownMode = $state(false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let bgStyle = $state('');
$effect(() => {
@@ -28,8 +36,6 @@
bgStyle = `background-color: ${$effectiveTheme === 'dark' ? colors.dark : colors.light}`;
});
const renderedContent = $derived(renderMarkdown(content));
async function save() {
if (!title.trim() && !content.trim()) {
onClose();
@@ -54,6 +60,13 @@
color = c;
showColorPicker = false;
}
function toggleMarkdownMode() {
rawMarkdownMode = !rawMarkdownMode;
if (rawMarkdownMode) {
requestAnimationFrame(() => textareaEl?.focus());
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -85,21 +98,27 @@
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if showPreview}
<div
class="prose prose-sm dark:prose-invert min-h-[100px] max-w-none px-4 py-2"
data-testid="note-preview"
>
{@html renderedContent}
</div>
{:else}
{:else if rawMarkdownMode}
<textarea
bind:this={textareaEl}
placeholder="Take a note..."
bind:value={content}
class="min-h-[100px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
rows="6"
data-testid="note-content-input"
></textarea>
{:else}
<TiptapEditor
{content}
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
placeholder="Take a note..."
/>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} />
{/if}
<!-- Toolbar -->
@@ -124,23 +143,6 @@
{/if}
</div>
<!-- Preview toggle -->
<button
onclick={() => (showPreview = !showPreview)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
title={showPreview ? 'Edit' : 'Preview'}
data-testid="preview-toggle"
>
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if showPreview}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
{/if}
</svg>
</button>
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
@@ -152,6 +154,18 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10 {rawMarkdownMode ? 'text-amber-600' : ''}"
title="Markdown mode"
data-testid="markdown-toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</button>
</div>
<button
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { Markdown } from 'tiptap-markdown';
interface Props {
content: string;
onUpdate: (markdown: string) => void;
onEditor?: (editor: Editor) => void;
placeholder?: string;
}
let { content, onUpdate, onEditor, placeholder = 'Take a note...' }: Props = $props();
let element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
onMount(() => {
editor = new Editor({
element: element!,
extensions: [
StarterKit,
TaskList,
TaskItem.configure({ nested: true }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
Markdown
],
content,
onUpdate: ({ editor: e }) => {
onUpdate(e.storage.markdown.getMarkdown());
},
onTransaction: () => {
// Trigger Svelte reactivity for active state checks
editor = editor;
}
});
// Expose editor on DOM element for e2e testing
if (element) {
(element as any).__tiptapEditor = editor;
}
onEditor?.(editor);
return () => {
editor?.destroy();
};
});
</script>
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm dark:prose-invert min-h-[100px] max-w-none px-4 py-2 text-gray-800 dark:text-gray-200"
data-testid="tiptap-editor"
></div>
<style>
.tiptap-wrapper :global(.tiptap) {
outline: none;
min-height: 80px;
}
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #9ca3af;
pointer-events: none;
height: 0;
}
.tiptap-wrapper :global(.tiptap ul[data-type='taskList']) {
list-style: none;
padding: 0;
}
.tiptap-wrapper :global(.tiptap ul[data-type='taskList'] li) {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.tiptap-wrapper :global(.tiptap ul[data-type='taskList'] li label) {
margin-top: 0.25rem;
}
</style>