forked from mirror/crumbs-original
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:
co-authored by
Claude Opus 4.6
parent
19c2c7435a
commit
6a2e54497f
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user