forked from mirror/crumbs-original
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:
co-authored by
Claude Opus 4.6
parent
2913e19f7c
commit
3db759ab82
@@ -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"
|
||||
|
||||
Generated
+27
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user