From 3db759ab82fa544ad80acf0abc970498c217dd74 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Thu, 5 Mar 2026 22:50:52 +0100 Subject: [PATCH] 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 --- package.json | 3 + pnpm-lock.yaml | 27 ++ src/app.html | 25 +- src/lib/components/FormattingToolbar.svelte | 406 ++++++++++++++++++-- src/lib/components/NoteCard.svelte | 22 ++ src/lib/components/NoteEditor.svelte | 4 +- src/lib/components/TiptapEditor.svelte | 25 +- tests/e2e/formatting.spec.ts | 32 ++ 8 files changed, 471 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index 417241c..3b6ded4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c00e590..ef0a449 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app.html b/src/app.html index 1986966..377a233 100644 --- a/src/app.html +++ b/src/app.html @@ -1,14 +1,17 @@ - - - - - - Crumbs - %sveltekit.head% - - -
%sveltekit.body%
- + + + + + + Crumbs + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/src/lib/components/FormattingToolbar.svelte b/src/lib/components/FormattingToolbar.svelte index 2acd6b3..771136b 100644 --- a/src/lib/components/FormattingToolbar.svelte +++ b/src/lib/components/FormattingToolbar.svelte @@ -1,5 +1,29 @@ + +
- {#each buttons as btn} - {#if btn.separator} -
- {:else} - + + + + +
+ + +
+ + {#if openDropdown === 'heading'} +
+ + + +
{/if} - {/each} +
+ + +
+ + {#if openDropdown === 'list'} +
+ + +
+ {/if} +
+ + + + +
+ + + + + + + + + +
+ + {#if openDropdown === 'link'} +
+
{ e.preventDefault(); applyLink(); }} + > + + +
+ + +
+
+ {/if} +
+ +
+ + +
+ + {#if openDropdown === 'align'} +
+ + + + +
+ {/if} +
+ +
+ + +
diff --git a/src/lib/components/NoteCard.svelte b/src/lib/components/NoteCard.svelte index e95e235..c4cffa6 100644 --- a/src/lib/components/NoteCard.svelte +++ b/src/lib/components/NoteCard.svelte @@ -165,3 +165,25 @@ {/if} + + diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index 680925a..97f6c77 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -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" > {:else} diff --git a/src/lib/components/TiptapEditor.svelte b/src/lib/components/TiptapEditor.svelte index c5d0653..73c83c1 100644 --- a/src/lib/components/TiptapEditor.svelte +++ b/src/lib/components/TiptapEditor.svelte @@ -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 @@
@@ -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; - } diff --git a/tests/e2e/formatting.spec.ts b/tests/e2e/formatting.spec.ts index c025187..e72d234 100644 --- a/tests/e2e/formatting.spec.ts +++ b/tests/e2e/formatting.spec.ts @@ -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();