forked from mirror/crumbs-original
Closes #90. Code blocks and inline code inherited the typography plugin's defaults, which shrank them to ~86% of an already-monospace body and painted them --bg-surface — the same colour as the default note card — so a snippet was only recognisable by being smaller. Long lines also grew a horizontal scrollbar inside card previews. - New --code-bg surface (one step below the page in light mode, page-level ink in dark) with a subtle border, and code keeps the surrounding text size. Decorative backtick pseudo-elements dropped now that the chip carries the signal. - Card previews clip wide code instead of scrolling; editor and share page keep horizontal scroll. - Syntax highlighting via one shared lowlight instance feeding both the TipTap editor (extension-code-block-lowlight) and the markdown-it renderer, so a fence looks identical in the editor, on cards and on the share page. Only highlight.js "common" grammars are bundled. - No auto-detection: highlight.js misreads note-sized snippets too often (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the language comes from the fence info string or a toolbar picker shown while the cursor is in a code block. - Six --hl-* token colours per theme; a unit test parses app.css and asserts AA contrast against --code-bg and mutual distinguishability. - Sanitizer now allows only language-* / hljs-* classes on code and span. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
680 lines
22 KiB
Svelte
680 lines
22 KiB
Svelte
<script lang="ts">
|
|
import type { Editor } from '@tiptap/core';
|
|
import { CODE_LANGUAGES } from '$lib/utils/highlight.js';
|
|
import {
|
|
Undo2,
|
|
Redo2,
|
|
Heading,
|
|
List,
|
|
ListOrdered,
|
|
ListChecks,
|
|
TextQuote,
|
|
CodeXml,
|
|
Bold,
|
|
Italic,
|
|
Strikethrough,
|
|
Code,
|
|
Underline,
|
|
Link,
|
|
CornerDownLeft,
|
|
ExternalLink,
|
|
Trash2,
|
|
AlignLeft,
|
|
AlignCenter,
|
|
AlignRight,
|
|
AlignJustify,
|
|
Minus,
|
|
ChevronDown,
|
|
Table2,
|
|
BetweenHorizontalEnd,
|
|
BetweenVerticalEnd,
|
|
RemoveFormatting
|
|
} from 'lucide-svelte';
|
|
|
|
interface Props {
|
|
tick?: number;
|
|
editor: Editor | undefined;
|
|
currentNoteId?: string | null;
|
|
}
|
|
|
|
let { editor, tick, currentNoteId = null }: Props = $props();
|
|
|
|
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
|
|
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
|
|
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
|
|
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
|
|
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
|
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
|
|
|
|
// The fence info string may carry an alias (```js) or a language the picker
|
|
// doesn't list; surface it as-is rather than silently showing "Plain text".
|
|
function codeBlockLanguage(): string {
|
|
const lang = getAttrs('codeBlock').language;
|
|
return typeof lang === 'string' ? lang : '';
|
|
}
|
|
|
|
function setCodeBlockLanguage(event: Event) {
|
|
const value = (event.currentTarget as HTMLSelectElement).value;
|
|
editor?.chain().focus().updateAttributes('codeBlock', { language: value || null }).run();
|
|
}
|
|
|
|
let openDropdown: string | null = $state(null);
|
|
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
|
let linkUrl: string = $state('');
|
|
let linkInput: HTMLInputElement | undefined = $state();
|
|
let ignorePositionInvalidationUntil = 0;
|
|
|
|
let noteSearchResults: { id: string; title: string }[] = $state([]);
|
|
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
async function searchNotesForLink(query: string) {
|
|
const requestId = ++noteSearchRequestId;
|
|
if (!query.trim()) {
|
|
noteSearchResults = [];
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
|
if (requestId !== noteSearchRequestId) return;
|
|
if (!res.ok) return;
|
|
const results: { id: string; title: string }[] = await res.json();
|
|
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
|
|
} catch {
|
|
// Search failed silently
|
|
}
|
|
}
|
|
|
|
let noteSearchRequestId = 0;
|
|
|
|
function handleLinkInput() {
|
|
clearTimeout(noteSearchTimer);
|
|
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
|
|
}
|
|
|
|
function insertNoteLink(noteId: string) {
|
|
if (!editor) return;
|
|
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
|
|
noteSearchResults = [];
|
|
closeDropdowns();
|
|
}
|
|
|
|
const dropdownGap = 4;
|
|
const viewportMargin = 10;
|
|
|
|
function toggleDropdown(name: string, anchorEl: HTMLElement) {
|
|
if (openDropdown === name) {
|
|
closeDropdowns();
|
|
} else {
|
|
ignorePositionInvalidationUntil = performance.now() + 600;
|
|
openDropdown = name;
|
|
dropdownAnchorEl = anchorEl;
|
|
if (name === 'link') {
|
|
linkUrl = editor?.getAttributes('link').href ?? '';
|
|
requestAnimationFrame(() => linkInput?.focus());
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleDropdownClick(name: string, event: MouseEvent) {
|
|
toggleDropdown(name, event.currentTarget as HTMLElement);
|
|
}
|
|
|
|
function preventToolbarMouseFocus(event: MouseEvent) {
|
|
if (event.button === 0) event.preventDefault();
|
|
}
|
|
|
|
function handleCommandClick(command: () => void) {
|
|
command();
|
|
}
|
|
|
|
function closeDropdowns() {
|
|
openDropdown = null;
|
|
dropdownAnchorEl = null;
|
|
clearTimeout(noteSearchTimer);
|
|
noteSearchResults = [];
|
|
}
|
|
|
|
function closeDropdownsAfterPositionChange() {
|
|
if (!openDropdown || performance.now() < ignorePositionInvalidationUntil) return;
|
|
closeDropdowns();
|
|
}
|
|
|
|
function getDropdownStyle(rect: DOMRect, width: number): string {
|
|
const left = Math.max(viewportMargin, Math.min(rect.left, window.innerWidth - width - viewportMargin));
|
|
// CSS `bottom` on position:fixed and getBoundingClientRect() both use layout-viewport
|
|
// coordinates, so the reference height must be innerHeight — visualViewport.height is
|
|
// smaller while the keyboard is open and would push the dropdown behind the keyboard.
|
|
const bottom = window.innerHeight - rect.top + dropdownGap;
|
|
return `bottom: ${bottom}px; left: ${left}px;`;
|
|
}
|
|
|
|
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 `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
|
}
|
|
|
|
function dropdownBtnClass(active: boolean = false): string {
|
|
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
|
|
}
|
|
|
|
function dropdownItemClass(active: boolean = false): string {
|
|
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!openDropdown || !window.visualViewport) return;
|
|
|
|
window.visualViewport.addEventListener('resize', closeDropdownsAfterPositionChange);
|
|
window.visualViewport.addEventListener('scroll', closeDropdownsAfterPositionChange);
|
|
return () => {
|
|
window.visualViewport?.removeEventListener('resize', closeDropdownsAfterPositionChange);
|
|
window.visualViewport?.removeEventListener('scroll', closeDropdownsAfterPositionChange);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
|
<svelte:window onscroll={closeDropdownsAfterPositionChange} onresize={closeDropdownsAfterPositionChange} />
|
|
|
|
<div
|
|
class="flex overflow-x-auto md:flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1 scrollbar-hide"
|
|
style="scrollbar-width: none; -ms-overflow-style: none;"
|
|
data-testid="formatting-toolbar"
|
|
onscroll={closeDropdownsAfterPositionChange}
|
|
>
|
|
<!-- Undo/Redo -->
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
|
|
disabled={!canUndo()}
|
|
class={btnClass(false, !canUndo())}
|
|
title="Undo"
|
|
data-testid="format-undo"
|
|
>
|
|
<Undo2 size={iconSize} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
|
|
disabled={!canRedo()}
|
|
class={btnClass(false, !canRedo())}
|
|
title="Redo"
|
|
data-testid="format-redo"
|
|
>
|
|
<Redo2 size={iconSize} />
|
|
</button>
|
|
|
|
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
|
|
|
<!-- Inline -->
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
|
|
class={btnClass(isActive('bold'))}
|
|
title="Bold (Ctrl+B)"
|
|
data-testid="format-bold"
|
|
>
|
|
<Bold size={iconSize} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
|
|
class={btnClass(isActive('italic'))}
|
|
title="Italic (Ctrl+I)"
|
|
data-testid="format-italic"
|
|
>
|
|
<Italic size={iconSize} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
|
|
class={btnClass(isActive('strike'))}
|
|
title="Strikethrough (Ctrl+Shift+X)"
|
|
data-testid="format-strikethrough"
|
|
>
|
|
<Strikethrough size={iconSize} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
|
|
class={btnClass(isActive('underline'))}
|
|
title="Underline (Ctrl+U)"
|
|
data-testid="format-underline"
|
|
>
|
|
<Underline size={iconSize} />
|
|
</button>
|
|
|
|
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
|
|
|
<!-- Heading dropdown -->
|
|
<div data-dropdown="heading">
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={(e) => handleDropdownClick('heading', e)}
|
|
class={dropdownBtnClass(isActive('heading'))}
|
|
title="Heading"
|
|
data-testid="format-heading"
|
|
>
|
|
<Heading size={iconSize} />
|
|
<ChevronDown size={chevronSize} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- List dropdown -->
|
|
<div data-dropdown="list">
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={(e) => handleDropdownClick('list', e)}
|
|
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
|
title="Lists"
|
|
data-testid="format-list"
|
|
>
|
|
<List size={iconSize} />
|
|
<ChevronDown size={chevronSize} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Link popover -->
|
|
<div data-dropdown="link">
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={(e) => handleDropdownClick('link', e)}
|
|
class={btnClass(isActive('link'))}
|
|
title="Insert link (Ctrl+K)"
|
|
data-testid="format-link"
|
|
>
|
|
<Link size={iconSize} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Table dropdown -->
|
|
<div data-dropdown="table">
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={(e) => handleDropdownClick('table', e)}
|
|
class={dropdownBtnClass(isActive('table'))}
|
|
title="Table"
|
|
data-testid="format-table"
|
|
>
|
|
<Table2 size={iconSize} />
|
|
<ChevronDown size={chevronSize} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Alignment dropdown -->
|
|
<div data-dropdown="align">
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={(e) => handleDropdownClick('align', e)}
|
|
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
|
title="Text alignment"
|
|
data-testid="format-align"
|
|
>
|
|
{#if isActive({ textAlign: 'center' })}
|
|
<AlignCenter size={iconSize} />
|
|
{:else if isActive({ textAlign: 'right' })}
|
|
<AlignRight size={iconSize} />
|
|
{:else if isActive({ textAlign: 'justify' })}
|
|
<AlignJustify size={iconSize} />
|
|
{:else}
|
|
<AlignLeft size={iconSize} />
|
|
{/if}
|
|
<ChevronDown size={chevronSize} />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
|
|
|
<!-- Block-level formatting -->
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
|
|
class={btnClass(isActive('code'))}
|
|
title="Inline code"
|
|
data-testid="format-code"
|
|
>
|
|
<Code size={iconSize} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
|
|
class={btnClass(isActive('blockquote'))}
|
|
title="Blockquote"
|
|
data-testid="format-blockquote"
|
|
>
|
|
<TextQuote size={iconSize} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
|
|
class={btnClass(isActive('codeBlock'))}
|
|
title="Code block"
|
|
data-testid="format-code-block"
|
|
>
|
|
<CodeXml size={iconSize} />
|
|
</button>
|
|
{#if isActive('codeBlock')}
|
|
{@const currentLanguage = codeBlockLanguage()}
|
|
<select
|
|
value={currentLanguage}
|
|
onchange={setCodeBlockLanguage}
|
|
class="shrink-0 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-1 text-xs text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
|
title="Code block language"
|
|
aria-label="Code block language"
|
|
data-testid="format-code-language"
|
|
>
|
|
<option value="">Plain text</option>
|
|
{#if currentLanguage && !CODE_LANGUAGES.some((l) => l.id === currentLanguage)}
|
|
<option value={currentLanguage}>{currentLanguage}</option>
|
|
{/if}
|
|
{#each CODE_LANGUAGES as lang (lang.id)}
|
|
<option value={lang.id}>{lang.label}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
|
|
class={btnClass()}
|
|
title="Divider"
|
|
data-testid="format-hr"
|
|
>
|
|
<Minus size={iconSize} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Render dropdown menus in a fixed portal so they aren't clipped by overflow-x-auto -->
|
|
{#if openDropdown && dropdownAnchorEl}
|
|
{@const rect = dropdownAnchorEl.getBoundingClientRect()}
|
|
|
|
{#if openDropdown === 'heading'}
|
|
<div
|
|
class="fixed z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
|
style={getDropdownStyle(rect, 170)}
|
|
data-dropdown="true"
|
|
>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
|
data-testid="format-h1"
|
|
>
|
|
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
|
<span class="font-semibold">Heading 1</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
|
data-testid="format-h2"
|
|
>
|
|
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
|
<span>Heading 2</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
|
data-testid="format-h3"
|
|
>
|
|
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
|
<span>Heading 3</span>
|
|
</button>
|
|
</div>
|
|
{:else if openDropdown === 'list'}
|
|
<div
|
|
class="fixed z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
|
style={getDropdownStyle(rect, 190)}
|
|
data-dropdown="true"
|
|
>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive('bulletList'))}
|
|
data-testid="format-bullet-list"
|
|
>
|
|
<List size={iconSize} />
|
|
<span>Bullet list</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive('orderedList'))}
|
|
data-testid="format-ordered-list"
|
|
>
|
|
<ListOrdered size={iconSize} />
|
|
<span>Ordered list</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive('taskList'))}
|
|
data-testid="format-task-list"
|
|
>
|
|
<ListChecks size={iconSize} />
|
|
<span>Task list</span>
|
|
</button>
|
|
</div>
|
|
{:else if openDropdown === 'link'}
|
|
<div
|
|
class="fixed z-50 mb-1 min-w-[200px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5"
|
|
style={getDropdownStyle(rect, 220)}
|
|
data-dropdown="true"
|
|
>
|
|
<form
|
|
class="flex items-center gap-1"
|
|
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
|
>
|
|
<input
|
|
bind:this={linkInput}
|
|
bind:value={linkUrl}
|
|
oninput={handleLinkInput}
|
|
type="url"
|
|
placeholder="Paste a link..."
|
|
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
|
data-testid="format-link-input"
|
|
/>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
type="submit"
|
|
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)]"
|
|
title="Apply link"
|
|
data-testid="format-link-apply"
|
|
>
|
|
<CornerDownLeft size={16} />
|
|
</button>
|
|
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
type="button"
|
|
onclick={openLink}
|
|
disabled={!getAttrs('link').href}
|
|
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
|
title="Open link"
|
|
data-testid="format-link-open"
|
|
>
|
|
<ExternalLink size={16} />
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
type="button"
|
|
onclick={removeLink}
|
|
disabled={!isActive('link')}
|
|
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
|
|
title="Remove link"
|
|
data-testid="format-link-remove"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</form>
|
|
{#if noteSearchResults.length > 0}
|
|
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
|
|
{#each noteSearchResults as result (result.id)}
|
|
<button
|
|
type="button"
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => insertNoteLink(result.id)}
|
|
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
|
data-testid="format-link-note-result"
|
|
>
|
|
{result.title || 'Untitled'}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if openDropdown === 'table'}
|
|
<div
|
|
class="fixed z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
|
style={getDropdownStyle(rect, 210)}
|
|
data-dropdown="true"
|
|
>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
|
class={dropdownItemClass()}
|
|
data-testid="format-insert-table"
|
|
>
|
|
<Table2 size={iconSize} />
|
|
<span>Insert table</span>
|
|
</button>
|
|
{#if isActive('table')}
|
|
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass()}
|
|
data-testid="format-add-row"
|
|
>
|
|
<BetweenHorizontalEnd size={iconSize} />
|
|
<span>Add row</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass()}
|
|
data-testid="format-add-column"
|
|
>
|
|
<BetweenVerticalEnd size={iconSize} />
|
|
<span>Add column</span>
|
|
</button>
|
|
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass()}
|
|
data-testid="format-delete-row"
|
|
>
|
|
<Minus size={iconSize} />
|
|
<span>Delete row</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
|
class={dropdownItemClass()}
|
|
data-testid="format-delete-column"
|
|
>
|
|
<Minus size={iconSize} />
|
|
<span>Delete column</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
|
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
|
data-testid="format-delete-table"
|
|
>
|
|
<RemoveFormatting size={iconSize} />
|
|
<span>Delete table</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else if openDropdown === 'align'}
|
|
<div
|
|
class="fixed z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
|
style={getDropdownStyle(rect, 180)}
|
|
data-dropdown="true"
|
|
>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
|
data-testid="format-align-left"
|
|
>
|
|
<AlignLeft size={iconSize} />
|
|
<span>Left</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
|
data-testid="format-align-center"
|
|
>
|
|
<AlignCenter size={iconSize} />
|
|
<span>Center</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
|
data-testid="format-align-right"
|
|
>
|
|
<AlignRight size={iconSize} />
|
|
<span>Right</span>
|
|
</button>
|
|
<button
|
|
onmousedown={preventToolbarMouseFocus}
|
|
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
|
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
|
data-testid="format-align-justify"
|
|
>
|
|
<AlignJustify size={iconSize} />
|
|
<span>Justify</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<style>
|
|
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
</style>
|