mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
feat(ui): improve mobile editor layout
Make editor full-screen natively on mobile with back button. Add visualViewport tracking to keep bottom toolbars above the software keyboard. disable pull-to-refresh for native PWA feel prevent auto-zoom on input focus in mobile Safari prevent background scrolling when editor is open prevent touch scroll on editor toolbars to stop background scrolling hide visual gap at bottom of editor when iOS keyboard opens move note actions to top-right menu on mobile devices wrap background colors in mobile menu to reduce width make formatting toolbar scrollable on mobile and extract dropdowns remove empty else block causing svelte compilation error add missing data-dropdown attributes to fix click interception in toolbar portals keep mobile keyboard open when using toolbar menus and prevent right-edge overflow Co-authored-by: Google Gemini <gemini@google.com>
This commit is contained in:
co-authored by
Google Gemini
parent
57cbab3cec
commit
ab8b273400
+7
-1
@@ -41,9 +41,10 @@
|
||||
--grid-dot: #e8dcc8;
|
||||
}
|
||||
|
||||
body {
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--primary);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -68,6 +69,11 @@ body::before {
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
@@ -44,24 +44,31 @@
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
||||
let linkUrl: string = $state('');
|
||||
let linkInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
function toggleDropdown(name: string) {
|
||||
function toggleDropdown(name: string, e: MouseEvent) {
|
||||
if (openDropdown === name) {
|
||||
openDropdown = null;
|
||||
closeDropdowns();
|
||||
editor?.commands.focus();
|
||||
} else {
|
||||
openDropdown = name;
|
||||
dropdownAnchorEl = e.currentTarget as HTMLElement;
|
||||
if (name === 'link') {
|
||||
linkUrl = editor?.getAttributes('link').href ?? '';
|
||||
// Focus input after Svelte renders the popover
|
||||
requestAnimationFrame(() => linkInput?.focus());
|
||||
} else {
|
||||
// Keep keyboard open on mobile
|
||||
editor?.commands.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdowns() {
|
||||
openDropdown = null;
|
||||
dropdownAnchorEl = null;
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
@@ -117,9 +124,15 @@
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
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"
|
||||
>
|
||||
<style>
|
||||
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
@@ -179,9 +192,9 @@
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<div data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
onclick={(e) => toggleDropdown('heading', e)}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
@@ -189,40 +202,12 @@
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
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
|
||||
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
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div class="relative" data-dropdown="list">
|
||||
<div data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
onclick={(e) => toggleDropdown('list', e)}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
@@ -230,98 +215,24 @@
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
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
|
||||
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
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div class="relative" data-dropdown="link">
|
||||
<div data-dropdown="link">
|
||||
<button
|
||||
onclick={() => toggleDropdown('link')}
|
||||
onclick={(e) => toggleDropdown('link', e)}
|
||||
class={btnClass(isActive('link'))}
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
<Link size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<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-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="format-link-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/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
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Table dropdown -->
|
||||
<div class="relative" data-dropdown="table">
|
||||
<div data-dropdown="table">
|
||||
<button
|
||||
onclick={() => toggleDropdown('table')}
|
||||
onclick={(e) => toggleDropdown('table', e)}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
@@ -329,68 +240,12 @@
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'table'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-insert"
|
||||
>
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-row"
|
||||
>
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-col"
|
||||
>
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-row"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-col"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-table-delete"
|
||||
>
|
||||
<RemoveFormatting size={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Alignment dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<div data-dropdown="align">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
onclick={(e) => toggleDropdown('align', e)}
|
||||
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
@@ -406,42 +261,6 @@
|
||||
{/if}
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
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
|
||||
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
|
||||
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
|
||||
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}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
@@ -480,3 +299,199 @@
|
||||
<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="bottom: {window.innerHeight - rect.top + 4}px; left: {Math.max(10, Math.min(rect.left, window.innerWidth - 170))}px;"
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
<span class="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
<span>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||
>
|
||||
<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="bottom: {window.innerHeight - rect.top + 4}px; left: {Math.max(10, Math.min(rect.left, window.innerWidth - 190))}px;"
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
>
|
||||
<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="bottom: {window.innerHeight - rect.top + 4}px; left: {Math.max(10, Math.min(rect.left, window.innerWidth - 220))}px;"
|
||||
data-dropdown="true"
|
||||
>
|
||||
<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-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
|
||||
title="Apply link"
|
||||
>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</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="bottom: {window.innerHeight - rect.top + 4}px; left: {Math.max(10, Math.min(rect.left, window.innerWidth - 210))}px;"
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
>
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
>
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
>
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
>
|
||||
<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="bottom: {window.innerHeight - rect.top + 4}px; left: {Math.max(10, Math.min(rect.left, window.innerWidth - 180))}px;"
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
>
|
||||
<AlignJustify size={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ListX from 'lucide-svelte/icons/list-x';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
|
||||
@@ -160,19 +161,64 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
let viewportHeight = $state('100dvh');
|
||||
let viewportTop = $state('0px');
|
||||
|
||||
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
|
||||
// can produce a synthetic click that lands on editor buttons rendered at the same
|
||||
// coordinates. Suppress pointer events on toolbar controls until mount completes.
|
||||
let toolbarInteractive = $state(false);
|
||||
onMount(() => {
|
||||
function updateViewport() {
|
||||
if (window.visualViewport) {
|
||||
viewportHeight = `${window.visualViewport.height}px`;
|
||||
viewportTop = `${window.visualViewport.offsetTop}px`;
|
||||
} else {
|
||||
viewportHeight = `${window.innerHeight}px`;
|
||||
viewportTop = '0px';
|
||||
}
|
||||
}
|
||||
|
||||
updateViewport();
|
||||
const handleVisualViewportChange = () => {
|
||||
requestAnimationFrame(updateViewport);
|
||||
};
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleVisualViewportChange);
|
||||
window.visualViewport.addEventListener('scroll', handleVisualViewportChange);
|
||||
} else {
|
||||
window.addEventListener('resize', handleVisualViewportChange);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleVisualViewportChange);
|
||||
window.visualViewport.removeEventListener('scroll', handleVisualViewportChange);
|
||||
} else {
|
||||
window.removeEventListener('resize', handleVisualViewportChange);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Lock body scroll while editor is open
|
||||
$effect(() => {
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
});
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
let showOverflowMenu = $state(false);
|
||||
let overflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let desktopOverflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let mobileOverflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowAnchorEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowMenuEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Close overflow menu when clicking outside
|
||||
@@ -180,7 +226,7 @@
|
||||
if (!showOverflowMenu) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
|
||||
if (overflowAnchorEl?.contains(target) || overflowMenuEl?.contains(target)) return;
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
// Use setTimeout to avoid the current click from closing the menu
|
||||
@@ -440,7 +486,8 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 {closing ? '' : 'flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||
class="fixed left-0 w-full z-40 {closing ? '' : 'flex md:items-start justify-center md:overflow-y-auto md:bg-black/50 md:pt-20 md:pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||
style="top: {viewportTop}; height: {viewportHeight};"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onkeydown={handleKeydown}
|
||||
@@ -449,29 +496,52 @@
|
||||
{#if !closing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
class="relative flex h-full w-full flex-col md:overflow-hidden border-0 md:h-auto md:max-w-xl md:mx-4 lg:max-w-2xl md:rounded-sm md:border md:border-[var(--border)] md:shadow-[var(--card-shadow)] animate-[slide-up_250ms_ease-out] md:animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
data-testid="note-editor"
|
||||
>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<!-- Gap filler to hide iOS Safari keyboard animation/viewport lag -->
|
||||
<div class="absolute top-full left-0 w-full h-[50vh] md:hidden" style={bgStyle}></div>
|
||||
<!-- Header (Mobile: Back + Title, Desktop: just Title) -->
|
||||
<div class="flex items-center gap-2 border-b md:border-b-0 border-[var(--border-subtle)] px-2 py-2 md:px-4 md:pt-4 md:pb-0 shrink-0 touch-none">
|
||||
<!-- Mobile Back Button -->
|
||||
<button
|
||||
onclick={saveAndClose}
|
||||
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--border)]/10 flex items-center shrink-0"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft class="h-6 w-6" />
|
||||
</button>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="flex-1 min-w-0 bg-transparent px-2 md:px-0 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Mobile Overflow Trigger -->
|
||||
<button
|
||||
bind:this={mobileOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
|
||||
class="md:hidden rounded-sm p-2 hover:bg-[var(--border)]/10 text-[var(--text)] shrink-0"
|
||||
data-testid="mobile-overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
@@ -498,7 +568,7 @@
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
attachments={attachmentsList}
|
||||
@@ -512,14 +582,14 @@
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<div class="shrink-0 touch-none" style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<!-- Toolbar (Desktop only) -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
|
||||
class="hidden md:flex shrink-0 items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2 touch-none"
|
||||
style={toolbarInteractive ? '' : 'pointer-events: none'}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -586,8 +656,8 @@
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={overflowBtnEl}
|
||||
onclick={() => (showOverflowMenu = !showOverflowMenu)}
|
||||
bind:this={desktopOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
use:tooltip={"More"}
|
||||
data-testid="overflow-menu-btn"
|
||||
@@ -598,7 +668,7 @@
|
||||
|
||||
<button
|
||||
onclick={saveAndClose}
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
Close
|
||||
@@ -607,14 +677,67 @@
|
||||
</div>
|
||||
|
||||
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||
{#if showOverflowMenu && overflowBtnEl}
|
||||
{@const rect = overflowBtnEl.getBoundingClientRect()}
|
||||
{#if showOverflowMenu && overflowAnchorEl}
|
||||
{@const rect = overflowAnchorEl.getBoundingClientRect()}
|
||||
{@const isMobileTrigger = overflowAnchorEl === mobileOverflowBtnEl}
|
||||
<div
|
||||
bind:this={overflowMenuEl}
|
||||
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
|
||||
style="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
|
||||
style={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
|
||||
data-testid="overflow-menu"
|
||||
>
|
||||
<!-- Mobile-only actions -->
|
||||
{#if isMobileTrigger}
|
||||
<!-- Color picker -->
|
||||
<div class="px-3 py-2 w-56 whitespace-normal">
|
||||
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
|
||||
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<button
|
||||
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
>
|
||||
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
Attachments
|
||||
</button>
|
||||
|
||||
<!-- Share -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-4 w-4 text-[var(--primary)]" />
|
||||
{:else if isShared}
|
||||
<Users class="h-4 w-4 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-4 w-4" />
|
||||
{/if}
|
||||
Share
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<div class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)]">
|
||||
<Users class="h-4 w-4" />
|
||||
Shared note
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Archive -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
>
|
||||
<Archive class="h-4 w-4" />
|
||||
Archive
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{/if}
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
|
||||
|
||||
Reference in New Issue
Block a user