mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.28.3
|
||||
image: ghcr.io/bretzel-app/crumbs:0.29.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.28.4",
|
||||
"version": "0.30.1",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
|
||||
+11
-1
@@ -41,9 +41,14 @@
|
||||
--grid-dot: #e8dcc8;
|
||||
}
|
||||
|
||||
body {
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--primary);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.editor-scroll-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -68,6 +73,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);
|
||||
|
||||
Vendored
+3
-1
@@ -15,7 +15,9 @@ declare global {
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
interface PageState {
|
||||
noteEditorOpen?: boolean;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- interactive-widget=resizes-content: on Android the keyboard shrinks the layout viewport via
|
||||
the native inset path, so bottom-anchored UI (editor toolbar) lands exactly on the keyboard —
|
||||
Chromium's visualViewport.height can under-report by the nav-bar height. iOS ignores this
|
||||
attribute and relies on the visualViewport tracking in NoteEditor instead. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
@@ -44,24 +44,57 @@
|
||||
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();
|
||||
let ignorePositionInvalidationUntil = 0;
|
||||
|
||||
function toggleDropdown(name: string) {
|
||||
const dropdownGap = 4;
|
||||
const viewportMargin = 10;
|
||||
|
||||
function toggleDropdown(name: string, anchorEl: HTMLElement) {
|
||||
if (openDropdown === name) {
|
||||
openDropdown = null;
|
||||
closeDropdowns();
|
||||
} else {
|
||||
ignorePositionInvalidationUntil = performance.now() + 600;
|
||||
openDropdown = name;
|
||||
dropdownAnchorEl = anchorEl;
|
||||
if (name === 'link') {
|
||||
linkUrl = editor?.getAttributes('link').href ?? '';
|
||||
// Focus input after Svelte renders the popover
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -112,17 +145,32 @@
|
||||
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 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"
|
||||
onscroll={closeDropdownsAfterPositionChange}
|
||||
>
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo"
|
||||
@@ -131,7 +179,8 @@
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo"
|
||||
@@ -144,7 +193,8 @@
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
|
||||
class={btnClass(isActive('bold'))}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
@@ -152,7 +202,8 @@
|
||||
<Bold size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
|
||||
class={btnClass(isActive('italic'))}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
@@ -160,7 +211,8 @@
|
||||
<Italic size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
|
||||
class={btnClass(isActive('strike'))}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
@@ -168,7 +220,8 @@
|
||||
<Strikethrough size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
|
||||
class={btnClass(isActive('underline'))}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
@@ -179,9 +232,10 @@
|
||||
<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')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('heading', e)}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
@@ -189,40 +243,13 @@
|
||||
<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')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('list', e)}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
@@ -230,98 +257,26 @@
|
||||
<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')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('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')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('table', e)}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
@@ -329,68 +284,13 @@
|
||||
<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')}
|
||||
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"
|
||||
@@ -406,49 +306,14 @@
|
||||
{/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>
|
||||
|
||||
<!-- Block-level formatting -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
|
||||
class={btnClass(isActive('code'))}
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
@@ -456,7 +321,8 @@
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
|
||||
class={btnClass(isActive('blockquote'))}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
@@ -464,7 +330,8 @@
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
|
||||
class={btnClass(isActive('codeBlock'))}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
@@ -472,7 +339,8 @@
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
|
||||
class={btnClass()}
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
@@ -480,3 +348,244 @@
|
||||
<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}
|
||||
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(--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
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
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
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-link-remove"
|
||||
>
|
||||
<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={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>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script module lang="ts">
|
||||
let bodyScrollLockCount = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pushState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
@@ -28,6 +34,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 +167,68 @@
|
||||
// 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. Multiple editor instances can overlap during
|
||||
// the ghost-click grace period, so keep the class until the last instance unmounts.
|
||||
$effect(() => {
|
||||
bodyScrollLockCount += 1;
|
||||
document.body.classList.add('editor-scroll-locked');
|
||||
return () => {
|
||||
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
|
||||
if (bodyScrollLockCount === 0) {
|
||||
document.body.classList.remove('editor-scroll-locked');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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 +236,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
|
||||
@@ -350,8 +406,34 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
// Close on browser back — Android back button / iOS back swipe. The editor is
|
||||
// fullscreen on mobile with no backdrop to tap, so back must dismiss it instead of
|
||||
// leaving the page. On mount, push a shallow-routing history entry; navigating back
|
||||
// pops it and closes the editor. UI closes pop the entry themselves via
|
||||
// history.back() so no stale entry is left behind.
|
||||
let historyEntryPushed = false;
|
||||
|
||||
onMount(() => {
|
||||
// Reuse an existing entry (e.g. back-navigating to a note restored from the
|
||||
// URL hash) instead of stacking a second one.
|
||||
if (!page.state.noteEditorOpen) {
|
||||
pushState('', { noteEditorOpen: true });
|
||||
}
|
||||
historyEntryPushed = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (page.state.noteEditorOpen || !historyEntryPushed || closing) return;
|
||||
historyEntryPushed = false;
|
||||
saveAndClose();
|
||||
});
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
if (historyEntryPushed) {
|
||||
historyEntryPushed = false;
|
||||
history.back();
|
||||
}
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
@@ -440,7 +522,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 +532,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_150ms_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)} />
|
||||
@@ -481,7 +587,7 @@
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
@@ -498,7 +604,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 +618,14 @@
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<div class="shrink-0 touch-pan-x" 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 +692,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 +704,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 +713,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; }}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
@@ -27,16 +28,19 @@
|
||||
loadNotes(filter);
|
||||
});
|
||||
|
||||
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
|
||||
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
|
||||
// handled by SvelteKit.
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
replaceState(`#${note.id}`, {});
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
newNoteChecklist = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
replaceState(location.pathname, {});
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { searchQuery, searchResults } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
@@ -12,41 +13,64 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let searchController: AbortController | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (onClose) inputEl?.focus();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
searchController?.abort();
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) {
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSearching) {
|
||||
notes.subscribe((n) => (originalNotes = n))();
|
||||
isSearching = true;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const results = await res.json();
|
||||
notes.set(results);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (res.ok) {
|
||||
const results: Note[] = await res.json();
|
||||
if (searchController !== controller || query.trim() !== trimmedQuery) return;
|
||||
// Commit the query alongside its results so the view switches to the
|
||||
// (possibly empty) result set only once it's ready — this avoids
|
||||
// flashing an empty state over the current notes while the request is
|
||||
// in flight, and keeps searchQuery consistent with searchResults on
|
||||
// a failed request (neither updates).
|
||||
searchResults.set(results);
|
||||
searchQuery.set(trimmedQuery);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (searchController === controller) searchController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
query = '';
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
}
|
||||
|
||||
function close() {
|
||||
|
||||
@@ -27,6 +27,54 @@
|
||||
let element: HTMLDivElement | undefined = $state();
|
||||
let editor: Editor | undefined = $state();
|
||||
|
||||
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof Element) {
|
||||
const checkbox = target.matches('input[type="checkbox"]')
|
||||
? target
|
||||
: target.closest('label')?.querySelector('input[type="checkbox"]');
|
||||
return checkbox !== null && checkbox !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
function handleWrapperPointerDown(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch' && isTaskCheckboxTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWrapperClick(event: MouseEvent) {
|
||||
if (isTaskCheckboxTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor?.commands.focus();
|
||||
}
|
||||
|
||||
function handleWrapperChangeCapture(event: Event) {
|
||||
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
|
||||
if (event.target.type !== 'checkbox' || !editor) return;
|
||||
|
||||
const taskItem = event.target.closest('li[data-checked]');
|
||||
if (!taskItem) return;
|
||||
|
||||
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
|
||||
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
|
||||
if (taskItemNode?.type.name !== 'taskItem') return;
|
||||
|
||||
event.stopPropagation();
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
|
||||
...taskItemNode.attrs,
|
||||
checked: event.target.checked
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: element!,
|
||||
@@ -72,12 +120,14 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
class="tiptap-wrapper prose md:prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabindex="0"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
onpointerdown={handleWrapperPointerDown}
|
||||
onclick={handleWrapperClick}
|
||||
onchangecapture={handleWrapperChangeCapture}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -263,6 +263,46 @@ describe('updateNote', () => {
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
|
||||
it('should merge concurrent content edits using the snapshot created by the first update', () => {
|
||||
seedNote('n1', {
|
||||
title: 'Shopping',
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
shareNote('n1');
|
||||
|
||||
const ownerUpdate = updateNote(db, OWNER_ID, 'n1', {
|
||||
content: '- [ ] Milk\n- [x] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
expect(ownerUpdate!.version).toBe(2);
|
||||
|
||||
const collabUpdate = updateNote(db, COLLAB_ID, 'n1', {
|
||||
content: '- [x] Milk\n- [ ] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
|
||||
expect(collabUpdate!.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the incoming edit when a metadata-only bump left no base snapshot', () => {
|
||||
seedNote('n1', { title: 'Note', content: 'hello', version: 1 });
|
||||
|
||||
// A metadata-only change bumps the version but creates no snapshot.
|
||||
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
|
||||
// A later concurrent content edit based on the pre-bump version therefore has
|
||||
// no base to merge against — the incoming edit must be preserved, not dropped.
|
||||
const edited = updateNote(db, OWNER_ID, 'n1', { content: 'hello world', baseVersion: 1 });
|
||||
|
||||
expect(edited!.content).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
@@ -8,8 +8,8 @@ import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { fetchSharesForNotes } from './shares-service.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import { createSnapshot } from './versions-service.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import { createSnapshot, getBaseContent } from './versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface NoteRow {
|
||||
@@ -238,29 +238,18 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) {
|
||||
// 3-way merge to preserve concurrent edits from other users.
|
||||
// Only attempt merge when there's a concurrent edit: the client's
|
||||
// baseVersion is behind the current version, meaning someone else
|
||||
// saved between the client's load and this save.
|
||||
// Preserve concurrent edits at line/block granularity when the client
|
||||
// saved from an older note version.
|
||||
const hasConcurrentEdit = input.baseVersion !== undefined
|
||||
&& input.baseVersion < existing.version;
|
||||
|
||||
if (hasConcurrentEdit && input.content !== existing.content) {
|
||||
const baseSnapshot = db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion!)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
sharedUpdates.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(db, id, input.baseVersion),
|
||||
incomingContent: input.content,
|
||||
currentContent: existing.content
|
||||
})
|
||||
: input.content;
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
@@ -278,6 +267,22 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(sharedUpdates.content !== undefined && sharedUpdates.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSharedUpdates || isOwner) {
|
||||
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||
}
|
||||
@@ -324,23 +329,6 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
}
|
||||
}
|
||||
|
||||
// Create a version snapshot only when at least one content field actually changed
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(input.content !== undefined && input.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
}
|
||||
|
||||
return getNote(db, userId, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, notes, noteVersions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
@@ -44,6 +44,32 @@ beforeEach(() => {
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('getBaseContent', () => {
|
||||
it('returns null when the base version is unknown', () => {
|
||||
seedNote();
|
||||
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the snapshot content at or before the base version', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
|
||||
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
|
||||
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
|
||||
});
|
||||
|
||||
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
|
||||
seedNote();
|
||||
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
|
||||
// pruned). Using it as the merge base would treat unseen lines as deletions,
|
||||
// so getBaseContent must return null rather than fall back to it.
|
||||
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnapshot', () => {
|
||||
it('should store a snapshot for a note', () => {
|
||||
seedNote();
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteVersions, notes } from './db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, lte } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
|
||||
|
||||
const MAX_VERSIONS_PER_NOTE = 50;
|
||||
const CONTENT_PREVIEW_LENGTH = 80;
|
||||
|
||||
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
export type SnapshotDb = Db | Transaction;
|
||||
|
||||
/**
|
||||
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
|
||||
* at or before the client's base version. Returns null when the base version is
|
||||
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
|
||||
* snapshot as the base — a base newer than what the client actually saw makes the
|
||||
* merge treat other users' lines as deletions and silently drop them.
|
||||
*/
|
||||
export function getBaseContent(
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
baseVersion: number | undefined
|
||||
): string | null {
|
||||
if (baseVersion === undefined) return null;
|
||||
|
||||
const snapshot = db
|
||||
.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
return snapshot?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot of a note's current state.
|
||||
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||
*/
|
||||
export function createSnapshot(
|
||||
db: Db,
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
snapshot: {
|
||||
version: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotesByVersion } from './notes.js';
|
||||
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
@@ -97,3 +97,49 @@ describe('mergeNotesByVersion', () => {
|
||||
expect(result[1].content).toBe('updated on server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileSearchResults', () => {
|
||||
it('replaces a stale result with the canonical note', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical]);
|
||||
});
|
||||
|
||||
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
|
||||
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
|
||||
|
||||
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
|
||||
|
||||
expect(result).toEqual([serverHit]);
|
||||
});
|
||||
|
||||
it('drops a result that is trashed in the canonical store', () => {
|
||||
const trashed = makeNote({ id: 'n1', trashed: true });
|
||||
|
||||
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves result order while reconciling a mix of cases', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
|
||||
const trashed = makeNote({ id: 'n2', trashed: true });
|
||||
const absent = makeNote({ id: 'n3', title: 'Absent' });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical, trashed],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical, absent]);
|
||||
});
|
||||
|
||||
it('returns an empty array when there are no results', () => {
|
||||
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+36
-3
@@ -8,11 +8,41 @@ export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
export const notesLoaded = writable(false);
|
||||
export const searchQuery = writable<string>('');
|
||||
export const searchResults = writable<Note[]>([]);
|
||||
|
||||
/**
|
||||
* Reconcile search results against the canonical notes store so local edits and
|
||||
* trashing propagate into the active result set without re-querying the server.
|
||||
*
|
||||
* A result absent from the canonical store is kept as-is — it's a server hit not
|
||||
* yet loaded locally (e.g. created on another device), and dropping it would make
|
||||
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
|
||||
* pruned explicitly at their call sites; trashed notes are dropped here.
|
||||
*/
|
||||
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
|
||||
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
|
||||
return results.flatMap((result) => {
|
||||
const canonical = notesMap.get(result.id);
|
||||
if (!canonical) return [result];
|
||||
return canonical.trashed ? [] : [canonical];
|
||||
});
|
||||
}
|
||||
|
||||
// Keep searchResults in sync with edits/trashing in the notes store. These search
|
||||
// stores (like the others in this module) are client-only state; the subscription
|
||||
// lives for the app's lifetime and is intentionally never unsubscribed.
|
||||
notes.subscribe(($notes) => {
|
||||
searchResults.update(($results) => {
|
||||
if ($results.length === 0) return $results;
|
||||
return reconcileSearchResults($notes, $results);
|
||||
});
|
||||
});
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter],
|
||||
([$notes, $selectedTag, $filter]) => {
|
||||
let result = $notes;
|
||||
[notes, selectedTag, currentFilter, searchQuery, searchResults],
|
||||
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
|
||||
let result = $searchQuery.trim() ? $searchResults : $notes;
|
||||
if ($filter === 'all') {
|
||||
result = result.filter((n) => !n.trashed && !n.archived);
|
||||
} else if ($filter === 'archived') {
|
||||
@@ -218,6 +248,7 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
@@ -225,6 +256,7 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
@@ -263,6 +295,7 @@ export async function leaveNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from '../server/db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from '../server/db/schema.js';
|
||||
@@ -289,6 +289,64 @@ describe('processSyncPush — shared notes', () => {
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should create a base snapshot during sync so later queued edits can merge', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: t1,
|
||||
createdAt: t1,
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 1000,
|
||||
data: { content: '- [ ] Milk\n- [x] Bread' },
|
||||
baseVersion: 1
|
||||
}], OWNER_ID);
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 2000,
|
||||
data: { content: '- [x] Milk\n- [ ] Bread' },
|
||||
baseVersion: 1
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should retain at most 50 snapshots created by sync updates', async () => {
|
||||
const createdAt = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: createdAt,
|
||||
createdAt,
|
||||
content: 'version 1',
|
||||
version: 1
|
||||
});
|
||||
|
||||
for (let version = 2; version <= 52; version++) {
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: createdAt.getTime() + version,
|
||||
data: { content: `version ${version}` },
|
||||
baseVersion: version - 1
|
||||
}], OWNER_ID);
|
||||
}
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(50);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
// Create note without sharing with user 3
|
||||
|
||||
+30
-38
@@ -1,37 +1,12 @@
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Find the base content for 3-way merge from version snapshots.
|
||||
* Returns the content from the snapshot at or before the given version,
|
||||
* or the current note content if no snapshot is found.
|
||||
*/
|
||||
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
|
||||
if (baseVersion !== undefined) {
|
||||
const snapshot = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snapshot) return snapshot.content;
|
||||
}
|
||||
// Fallback: get the most recent snapshot before the current version
|
||||
const latest = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
return latest?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
@@ -69,16 +44,17 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
// Content: 3-way merge to preserve concurrent edits from other users
|
||||
// Content: merge only when the client saved from an older note version.
|
||||
if (change.data.content !== undefined && change.data.content !== note.content) {
|
||||
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
|
||||
if (baseContent !== null && baseContent !== note.content) {
|
||||
// 3-way merge: base, incoming (local), server current (remote)
|
||||
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
|
||||
} else {
|
||||
// No base or base matches server — take incoming content
|
||||
sharedData.content = change.data.content;
|
||||
}
|
||||
const hasConcurrentEdit = change.baseVersion !== undefined
|
||||
&& change.baseVersion < note.version;
|
||||
sharedData.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
|
||||
incomingContent: change.data.content,
|
||||
currentContent: note.content
|
||||
})
|
||||
: change.data.content;
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
@@ -92,6 +68,22 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(change.data.title !== undefined && change.data.title !== note.title) ||
|
||||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
|
||||
(change.data.color !== undefined && change.data.color !== note.color) ||
|
||||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(tx, change.noteId, {
|
||||
version: note.version,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
checklistMode: note.checklistMode,
|
||||
color: note.color
|
||||
});
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
|
||||
import { mergeContent, mergeContentUpdate } from './content-merge.js';
|
||||
|
||||
describe('mergeContent (3-way)', () => {
|
||||
it('should return remote when local is unchanged', () => {
|
||||
@@ -89,17 +89,56 @@ describe('mergeContent (3-way)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeContentTwoWay', () => {
|
||||
it('should return remote when both are identical', () => {
|
||||
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
|
||||
describe('mergeContentUpdate', () => {
|
||||
it('should merge an incoming update against current content when a base is available', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: '- [ ] Milk\n- [ ] Bread',
|
||||
incomingContent: '- [x] Milk\n- [ ] Bread',
|
||||
currentContent: '- [ ] Milk\n- [x] Bread'
|
||||
});
|
||||
|
||||
expect(result).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should attempt merge when contents differ', () => {
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
it('should keep the incoming edit when no base snapshot is available', () => {
|
||||
// Without a common ancestor a 3-way merge is impossible; dropping the
|
||||
// incoming edit would silently lose the user's changes.
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: '- [x] Milk',
|
||||
currentContent: '- [ ] Bread'
|
||||
});
|
||||
|
||||
const result = mergeContentTwoWay(local, remote);
|
||||
// Without a base, conflict resolution prefers remote
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
expect(result).toBe('- [x] Milk');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit equals it', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: 'same',
|
||||
currentContent: 'same'
|
||||
});
|
||||
|
||||
expect(result).toBe('same');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit matches the base (no local change)', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'base',
|
||||
currentContent: 'server changed'
|
||||
});
|
||||
|
||||
expect(result).toBe('server changed');
|
||||
});
|
||||
|
||||
it('should take the incoming edit when the server is unchanged from the base', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'incoming change',
|
||||
currentContent: 'base'
|
||||
});
|
||||
|
||||
expect(result).toBe('incoming change');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,28 @@ export function mergeContent(base: string, local: string, remote: string): strin
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-way merge fallback when no base is available.
|
||||
* Uses an empty string as the base, so all lines from both sides appear
|
||||
* as additions. For conflicts (same region), prefers remote.
|
||||
* Merge an incoming content snapshot into the current server content.
|
||||
*
|
||||
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
|
||||
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
|
||||
* change that created no snapshot — a 3-way merge is impossible, so keep the
|
||||
* incoming edit rather than silently discarding the user's changes.
|
||||
*/
|
||||
export function mergeContentTwoWay(local: string, remote: string): string {
|
||||
if (local === remote) return remote;
|
||||
return mergeContent('', local, remote);
|
||||
export function mergeContentUpdate(input: {
|
||||
baseContent: string | null;
|
||||
incomingContent: string;
|
||||
currentContent: string;
|
||||
}): string {
|
||||
const { baseContent, incomingContent, currentContent } = input;
|
||||
|
||||
if (incomingContent === currentContent) return currentContent;
|
||||
if (baseContent === incomingContent) return currentContent;
|
||||
if (baseContent === currentContent) return incomingContent;
|
||||
|
||||
if (baseContent === null) {
|
||||
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
|
||||
return incomingContent;
|
||||
}
|
||||
|
||||
return mergeContent(baseContent, incomingContent, currentContent);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { devices, type Page } from '@playwright/test';
|
||||
|
||||
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
|
||||
async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
@@ -13,6 +13,29 @@ async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `checked` attribute of the first task item from the live ProseMirror
|
||||
* document. This reflects the editor's document state, not just the native
|
||||
* checkbox widget — so it catches a widget/document desync where the DOM
|
||||
* checkbox toggles but the transaction never updates the node.
|
||||
*/
|
||||
async function firstTaskItemChecked(page: Page): Promise<boolean> {
|
||||
return page.getByTestId('tiptap-editor').evaluate((el) => {
|
||||
const editor = (el as any).__tiptapEditor;
|
||||
if (!editor) throw new Error('TipTap editor not found on element');
|
||||
let checked: boolean | undefined;
|
||||
editor.state.doc.descendants((node: any) => {
|
||||
if (checked === undefined && node.type.name === 'taskItem') {
|
||||
checked = Boolean(node.attrs.checked);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (checked === undefined) throw new Error('No task item found in document');
|
||||
return checked;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle markdown mode via the overflow menu. */
|
||||
async function toggleMarkdownMode(page: Page) {
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
@@ -109,3 +132,52 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('Hello **bold** world');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rich text formatting on mobile', () => {
|
||||
const pixel7 = devices['Pixel 7'];
|
||||
test.use({
|
||||
viewport: pixel7.viewport,
|
||||
userAgent: pixel7.userAgent,
|
||||
deviceScaleFactor: pixel7.deviceScaleFactor,
|
||||
isMobile: pixel7.isMobile,
|
||||
hasTouch: pixel7.hasTouch
|
||||
});
|
||||
|
||||
test('Scenario: Tapping a task-list checkbox does not focus the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given a regular text note contains a task list in the rich-text editor
|
||||
await page.getByTestId('new-note-fab').click();
|
||||
await page.getByTestId('mobile-overflow-menu-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await page.getByTestId('note-content-input').fill('- [ ] Mobile task');
|
||||
await page.getByTestId('mobile-overflow-menu-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
|
||||
const richTextEditor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
const checkbox = richTextEditor.locator('input[type="checkbox"]');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await expect(richTextEditor).not.toBeFocused();
|
||||
await richTextEditor.evaluate((element) => {
|
||||
element.dataset.focusCount = '0';
|
||||
element.addEventListener('focus', () => {
|
||||
element.dataset.focusCount = String(Number(element.dataset.focusCount) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// When the checkbox is tapped, the change propagates to the editor's document
|
||||
// without focus ever entering the editor (which would open the mobile keyboard)
|
||||
await checkbox.tap();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
expect(await firstTaskItemChecked(page)).toBe(true);
|
||||
await expect(richTextEditor).not.toBeFocused();
|
||||
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
|
||||
|
||||
// And unchecking propagates to the document too, still without focusing the editor
|
||||
await checkbox.tap();
|
||||
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
expect(await firstTaskItemChecked(page)).toBe(false);
|
||||
await expect(richTextEditor).not.toBeFocused();
|
||||
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +104,11 @@ export async function createNote(page: Page, title: string, content?: string) {
|
||||
await editor.pressSequentially(content);
|
||||
}
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
// Wait for the editor to fully unmount, not just start closing. The close is
|
||||
// animated and dismisses via an async history.back() (see NoteEditor); returning
|
||||
// before it settles lets a following page.goto race that navigation and abort it
|
||||
// (net::ERR_ABORTED). The outer overlay is removed only when the close completes.
|
||||
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,40 @@ test.describe('Notes CRUD', () => {
|
||||
await expect(page.getByText('Updated Title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Browser back closes the open editor instead of leaving the page', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Back Closes Me" exists
|
||||
await createNote(page, 'Back Closes Me', 'Some content');
|
||||
|
||||
// Given the note is open in the editor
|
||||
await noteCard(page, 'Back Closes Me').click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
|
||||
// When the user navigates back
|
||||
await page.goBack();
|
||||
|
||||
// Then the editor is closed and the notes list is still shown
|
||||
await expect(page.getByTestId('note-editor')).not.toBeVisible();
|
||||
await expect(noteCard(page, 'Back Closes Me')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Closing the editor leaves browser history clean', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Clean History" exists
|
||||
await createNote(page, 'Clean History');
|
||||
|
||||
// Given the user opened and closed the note
|
||||
await noteCard(page, 'Clean History').click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByTestId('note-editor')).not.toBeVisible();
|
||||
|
||||
// When the user navigates back
|
||||
await page.goBack();
|
||||
|
||||
// Then the editor does not reopen and the note's URL is gone from history
|
||||
await expect(page.getByTestId('note-editor')).not.toBeVisible();
|
||||
expect(page.url()).not.toContain('#');
|
||||
});
|
||||
|
||||
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Delete Me" exists
|
||||
await createNote(page, 'Delete Me');
|
||||
|
||||
@@ -31,4 +31,84 @@ test.describe('Search', () => {
|
||||
// Then no notes are displayed
|
||||
await expect(page.getByTestId('note-card')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Background sync preserves active search results', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Sync Search Target 3812925');
|
||||
await createNote(page, 'Sync Search Other 3812925');
|
||||
|
||||
await page.getByTestId('search-input').fill('Sync Search Target 3812925');
|
||||
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
|
||||
|
||||
const notesReloaded = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/notes?filter=all') && response.ok()
|
||||
);
|
||||
await page.getByTestId('sync-indicator').click();
|
||||
await notesReloaded;
|
||||
|
||||
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: A stale response cannot replace newer search results', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Race Slow 3812925');
|
||||
await createNote(page, 'Race Fast 3812925');
|
||||
|
||||
const slowResponse = await page.request.get('/api/search?q=Race%20Slow%203812925');
|
||||
const fastResponse = await page.request.get('/api/search?q=Race%20Fast%203812925');
|
||||
const slowResults = await slowResponse.json();
|
||||
const fastResults = await fastResponse.json();
|
||||
|
||||
await page.route('**/api/search?*', async (route) => {
|
||||
const requestQuery = new URL(route.request().url()).searchParams.get('q');
|
||||
if (requestQuery === 'Race Slow 3812925') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await route.fulfill({ json: slowResults });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: fastResults });
|
||||
});
|
||||
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
const slowRequestStarted = page.waitForRequest(
|
||||
(request) => new URL(request.url()).searchParams.get('q') === 'Race Slow 3812925'
|
||||
);
|
||||
await searchInput.fill('Race Slow 3812925');
|
||||
await slowRequestStarted;
|
||||
await searchInput.fill('Race Fast 3812925');
|
||||
|
||||
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Current notes stay visible while a search request is in flight', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist
|
||||
await createNote(page, 'Flash Alpha 771903');
|
||||
await createNote(page, 'Flash Beta 771903');
|
||||
|
||||
// And the search request is held open before it can respond
|
||||
let releaseSearch: () => void = () => {};
|
||||
const searchGate = new Promise<void>((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
});
|
||||
await page.route('**/api/search?*', async (route) => {
|
||||
await searchGate;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// When the user starts a search
|
||||
await page.getByTestId('search-input').fill('Flash Alpha 771903');
|
||||
|
||||
// Then the current notes stay on screen instead of flashing an empty state
|
||||
await expect(page.getByText('Flash Beta 771903')).toBeVisible();
|
||||
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
|
||||
|
||||
// And once the results arrive, only the match remains
|
||||
releaseSearch();
|
||||
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
|
||||
await expect(page.getByText('Flash Beta 771903')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.28.4</span>
|
||||
<span class="footer-version">v0.30.1</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user