feat: icon-based formatting toolbar with dropdowns and link popover

Replace plain text toolbar labels with Lucide SVG icons. Group heading,
list, and alignment options into dropdown menus to reduce toolbar width.
Replace window.prompt link insertion with an inline popover (input, apply,
open, remove). Add underline and text-align TipTap extensions. Remove
task list from TipTap editor (broken styling). Fix task list bullet
rendering in note card preview. Increase editor min-height for markdown
mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-05 22:50:52 +01:00
co-authored by Claude Opus 4.6
parent 2913e19f7c
commit 3db759ab82
8 changed files with 471 additions and 73 deletions
+3
View File
@@ -28,12 +28,15 @@
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-task-item": "^3.20.0",
"@tiptap/extension-task-list": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"argon2": "^0.43.0",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"idb": "^8.0.0",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.0.0",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.0.0"
+27
View File
@@ -26,6 +26,12 @@ importers:
'@tiptap/extension-task-list':
specifier: ^3.20.0
version: 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))
'@tiptap/extension-text-align':
specifier: ^3.20.0
version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))
'@tiptap/extension-underline':
specifier: ^3.20.0
version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))
'@tiptap/pm':
specifier: ^3.20.0
version: 3.20.0
@@ -44,6 +50,9 @@ importers:
idb:
specifier: ^8.0.0
version: 8.0.3
lucide-svelte:
specifier: ^0.577.0
version: 0.577.0(svelte@5.53.7)
markdown-it:
specifier: ^14.0.0
version: 14.1.1
@@ -1584,6 +1593,11 @@ packages:
peerDependencies:
'@tiptap/extension-list': ^3.20.0
'@tiptap/extension-text-align@3.20.0':
resolution: {integrity: sha512-4s0r+bovtH6yeGDUD+Ui8j5WOV5koB5P6AuzOMqoLwaFGRSkKf64ly6DXjjmjIgnYCLZN/XO6llaQKVVdvad2g==}
peerDependencies:
'@tiptap/core': ^3.20.0
'@tiptap/extension-text@3.20.0':
resolution: {integrity: sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==}
peerDependencies:
@@ -2544,6 +2558,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-svelte@0.577.0:
resolution: {integrity: sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==}
peerDependencies:
svelte: ^3 || ^4 || ^5.0.0-next.42
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@@ -4675,6 +4694,10 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)
'@tiptap/extension-text-align@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))':
dependencies:
'@tiptap/core': 3.20.0(@tiptap/pm@3.20.0)
'@tiptap/extension-text@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))':
dependencies:
'@tiptap/core': 3.20.0(@tiptap/pm@3.20.0)
@@ -5669,6 +5692,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-svelte@0.577.0(svelte@5.53.7):
dependencies:
svelte: 5.53.7
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
+14 -11
View File
@@ -1,14 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#202124" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<title>Crumbs</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#202124" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
/>
<title>Crumbs</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+366 -40
View File
@@ -1,5 +1,29 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import {
Undo2,
Redo2,
Heading,
List,
ListOrdered,
TextQuote,
CodeXml,
Bold,
Italic,
Strikethrough,
Code,
Underline,
Link,
CornerDownLeft,
ExternalLink,
Trash2,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Minus,
ChevronDown
} from 'lucide-svelte';
interface Props {
editor: Editor | undefined;
@@ -7,56 +31,358 @@
let { editor }: Props = $props();
function toggleLink() {
if (!editor) return;
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
let openDropdown: string | null = $state(null);
let linkUrl: string = $state('');
let linkInput: HTMLInputElement | undefined = $state();
function toggleDropdown(name: string) {
if (openDropdown === name) {
openDropdown = null;
} else {
const url = window.prompt('Enter URL:');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
openDropdown = name;
if (name === 'link') {
linkUrl = editor?.getAttributes('link').href ?? '';
// Focus input after Svelte renders the popover
requestAnimationFrame(() => linkInput?.focus());
}
}
}
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') }
];
function closeDropdowns() {
openDropdown = null;
}
function applyLink() {
if (!editor) return;
const url = linkUrl.trim();
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
closeDropdowns();
}
function removeLink() {
if (!editor) return;
editor.chain().focus().unsetLink().run();
closeDropdowns();
}
function openLink() {
const href = editor?.getAttributes('link').href;
if (href) window.open(href, '_blank', 'noopener');
}
const iconSize = 18;
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `rounded p-1.5 text-gray-600 hover:bg-black/10 dark:text-gray-400 dark:hover:bg-white/10 ${active ? 'bg-black/10 dark:bg-white/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `flex items-center gap-0.5 rounded p-1.5 text-gray-600 hover:bg-black/10 dark:text-gray-400 dark:hover:bg-white/10 ${active ? 'bg-black/10 dark:bg-white/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-black/5 dark:text-gray-300 dark:hover:bg-white/10 ${active ? 'bg-black/5 dark:bg-white/10' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
if (!openDropdown) return;
const target = event.target as HTMLElement;
if (!target.closest('[data-dropdown]')) {
closeDropdowns();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && openDropdown) {
closeDropdowns();
}
}
</script>
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<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>
<!-- History -->
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!editor?.can().undo()}
class={btnClass(false, !editor?.can().undo())}
title="Undo (Ctrl+Z)"
data-testid="format-undo"
>
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!editor?.can().redo()}
class={btnClass(false, !editor?.can().redo())}
title="Redo (Ctrl+Shift+Z)"
data-testid="format-redo"
>
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Heading dropdown -->
<div class="relative" data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
title="Heading"
data-testid="format-heading"
>
<Heading size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-gray-400">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-gray-400">H2</span>
<span>Heading 2</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-gray-400">H3</span>
<span>Heading 3</span>
</button>
</div>
{/if}
{/each}
</div>
<!-- List dropdown -->
<div class="relative" data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
title="Lists"
data-testid="format-list"
>
<List size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
data-testid="format-bullet-list"
>
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
</div>
{/if}
</div>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(editor?.isActive('blockquote') ?? false)}
title="Blockquote"
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(editor?.isActive('codeBlock') ?? false)}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(editor?.isActive('code') ?? false)}
title="Inline code (Ctrl+E)"
data-testid="format-code"
>
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
<Underline size={iconSize} />
</button>
<!-- Link popover -->
<div class="relative" data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
class={btnClass(editor?.isActive('link') ?? false)}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
bind:this={linkInput}
bind:value={linkUrl}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-sm text-gray-700 outline-none placeholder:text-gray-400 dark:text-gray-300 dark:placeholder:text-gray-500"
data-testid="format-link-input"
/>
<button
type="submit"
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600 dark:hover:bg-white/10 dark:hover:text-gray-300"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<button
type="button"
onclick={openLink}
disabled={!editor?.getAttributes('link').href}
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600 disabled:opacity-30 dark:hover:bg-white/10 dark:hover:text-gray-300"
title="Open link"
data-testid="format-link-open"
>
<ExternalLink size={16} />
</button>
<button
type="button"
onclick={removeLink}
disabled={!editor?.isActive('link')}
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-red-500 disabled:opacity-30 dark:hover:bg-white/10 dark:hover:text-red-400"
title="Remove link"
data-testid="format-unlink"
>
<Trash2 size={16} />
</button>
</form>
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Align dropdown -->
<div class="relative" data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(editor?.isActive({ textAlign: 'center' }) || editor?.isActive({ textAlign: 'right' }) || editor?.isActive({ textAlign: 'justify' }) ? true : false)}
title="Text alignment"
data-testid="format-align"
>
<AlignLeft size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- Insert -->
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Horizontal rule"
data-testid="format-hr"
>
<Minus size={iconSize} />
</button>
</div>
+22
View File
@@ -165,3 +165,25 @@
</div>
{/if}
</article>
<style>
article :global(.prose li:has(.task-checkbox)) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
article :global(.prose li:has(.task-checkbox))::before {
display: none;
}
article :global(.prose li:has(.task-checkbox))::marker {
content: none;
}
article :global(.task-checkbox) {
margin-top: 0.15rem;
flex-shrink: 0;
}
</style>
+2 -2
View File
@@ -103,8 +103,8 @@
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"
class="min-h-[300px] 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="12"
data-testid="note-content-input"
></textarea>
{:else}
+5 -20
View File
@@ -2,9 +2,9 @@
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 Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Markdown } from 'tiptap-markdown';
@@ -25,9 +25,9 @@
element: element!,
extensions: [
StarterKit,
TaskList,
TaskItem.configure({ nested: true }),
Link.configure({ openOnClick: false }),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Markdown
],
@@ -56,7 +56,7 @@
<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"
class="tiptap-wrapper prose prose-sm dark:prose-invert min-h-[300px] max-w-none px-4 py-2 text-gray-800 dark:text-gray-200"
data-testid="tiptap-editor"
></div>
@@ -73,19 +73,4 @@
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>
+32
View File
@@ -101,6 +101,38 @@ test.describe('Rich text formatting', () => {
await expect(page.getByTestId('tiptap-editor').locator('strong')).toHaveText('bold');
});
test('Scenario: Toolbar dropdown closes when clicking outside', async ({ authenticatedPage: page }) => {
// Given the user has opened the heading dropdown
await page.getByTestId('new-note-btn').click();
await page.getByTestId('format-heading').click();
await expect(page.getByTestId('format-h1')).toBeVisible();
// When the user clicks inside the editor area
await page.getByTestId('tiptap-editor').click();
// Then the dropdown closes
await expect(page.getByTestId('format-h1')).not.toBeVisible();
});
test('Scenario: Link popover allows inserting a link inline', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with selected text
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'visit example');
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:14}).run()');
// When the user opens the link popover and enters a URL
await page.getByTestId('format-link').click();
await expect(page.getByTestId('format-link-input')).toBeVisible();
await page.getByTestId('format-link-input').fill('https://example.com');
await page.getByTestId('format-link-apply').click();
// Then the selected text becomes a link
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
const link = editor.locator('a');
await expect(link).toHaveText('example');
await expect(link).toHaveAttribute('href', 'https://example.com');
});
test('Scenario: Markdown toggle shows raw markdown content', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with bold content
await page.getByTestId('new-note-btn').click();