Files
crumbs/src/lib/components/NoteEditor.svelte
T
Romain LE DISEZandGoogle Gemini 2181a9d144 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>
2026-07-10 16:19:19 +02:00

844 lines
27 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
import { notes } from '$lib/stores/notes.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
import Type from 'lucide-svelte/icons/type';
import FileCode from 'lucide-svelte/icons/file-code';
import FileText from 'lucide-svelte/icons/file-text';
import UserPlus from 'lucide-svelte/icons/user-plus';
import Users from 'lucide-svelte/icons/users';
import Globe from 'lucide-svelte/icons/globe';
import History from 'lucide-svelte/icons/history';
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
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';
interface Props {
note: Note | null;
isNew?: boolean;
initialChecklistMode?: boolean;
onClose: () => void;
}
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
let title = $state(note?.title ?? '');
// svelte-ignore state_referenced_locally
let content = $state(note?.content ?? '');
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
// Mutable note identity — allows transitioning from new → saved without closing
// svelte-ignore state_referenced_locally
let noteId = $state<string | null>(note?.id ?? null);
// svelte-ignore state_referenced_locally
let currentlyNew = $state(isNew);
// Auto-save: track last-saved state to detect real changes
let lastSavedTitle = note?.title ?? '';
let lastSavedContent = note?.content ?? '';
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
let isSaving = false;
let savingPromise: Promise<void> | null = null;
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
// from the original. Track whether auto-save should be active yet.
let autoSaveReady = isNew;
function hasUnsavedChanges(): boolean {
return (
title !== lastSavedTitle ||
content !== lastSavedContent ||
color !== lastSavedColor ||
checklistMode !== lastSavedChecklistMode
);
}
async function performSave() {
if (isSaving) return;
// Snapshot current fields to avoid races with user edits during await
const snap = { title, content, color, checklistMode };
if (!snap.title.trim() && !snap.content.trim()) return;
if (
snap.title === lastSavedTitle &&
snap.content === lastSavedContent &&
snap.color === lastSavedColor &&
snap.checklistMode === lastSavedChecklistMode
) return;
isSaving = true;
try {
if (currentlyNew) {
const created = await createNote(snap);
if (created) {
noteId = created.id;
currentlyNew = false;
lastSavedTitle = snap.title;
lastSavedContent = snap.content;
lastSavedColor = snap.color;
lastSavedChecklistMode = snap.checklistMode;
}
} else if (noteId) {
const updated = await updateNote(noteId, snap);
if (updated) {
lastSavedTitle = snap.title;
lastSavedContent = snap.content;
lastSavedColor = snap.color;
lastSavedChecklistMode = snap.checklistMode;
}
}
} finally {
isSaving = false;
}
}
// Debounced auto-save: triggers 2s after the user stops editing
$effect(() => {
const _t = title;
const _c = content;
const _col = color;
const _cm = checklistMode;
if (!autoSaveReady) {
// Absorb TiptapEditor's initial content normalization as the baseline
lastSavedContent = _c;
autoSaveReady = true;
return;
}
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
if (!_t.trim() && !_c.trim()) return;
autoSaveTimer = setTimeout(() => {
savingPromise = performSave();
}, 2000);
return () => clearTimeout(autoSaveTimer);
});
// Safety net: save on tab close / navigation
$effect(() => {
function handleBeforeUnload() {
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
performSave();
}
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
});
// 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);
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 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
$effect(() => {
if (!showOverflowMenu) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (overflowAnchorEl?.contains(target) || overflowMenuEl?.contains(target)) return;
showOverflowMenu = false;
}
// Use setTimeout to avoid the current click from closing the menu
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
});
const hasDoneItems = $derived(
checklistMode && content.includes('[x]')
);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
// svelte-ignore state_referenced_locally
let currentShareToken = $state<string | undefined>(note?.shareToken);
const isOwner = $derived(note?.isOwner !== false);
const isShared = $derived(collaboratorsList.length > 0);
const hasPublicLink = $derived(!!currentShareToken);
// Use displayName from first user or 'You' for owner
const ownerName = $derived(isOwner ? 'You' : 'Owner');
function handleCollaboratorsUpdate(updated: Collaborator[]) {
collaboratorsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, collaborators: updated, isShared: updated.length > 0 }
: n
)
);
}
}
function handleShareUpdate(token: string | null) {
currentShareToken = token ?? undefined;
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, shareToken: token ?? undefined }
: n
)
);
}
}
async function toggleShareDialog() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showShareDialog = !showShareDialog;
}
async function toggleHistory() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showHistory = !showHistory;
}
async function handleHistoryRestored() {
if (!noteId) return;
// Reload the note content from the server after a restore
try {
const res = await fetch(`/api/notes/${noteId}`);
if (res.ok) {
const updated = await res.json();
title = updated.title ?? '';
content = updated.content ?? '';
color = updated.color ?? 'default';
checklistMode = updated.checklistMode ?? false;
// Bump editor tick to force TiptapEditor to re-render
editorTick++;
// Update the store too
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
);
}
} catch {
// failed silently
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
}
});
function handleAttachmentUpload(attachment: Attachment) {
const updated = [...attachmentsList, attachment];
attachmentsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
if (!noteId) return;
try {
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ featured })
});
if (res.ok) {
const updated = attachmentsList.map((a) =>
a.id === attachmentId ? { ...a, featured } : a
);
attachmentsList = updated;
// Keep the notes store in sync so the card reflects featured state
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
} catch (err) {
console.error('Failed to toggle featured:', err);
}
}
async function handleAttachmentRemove(attachmentId: string) {
if (!noteId) return;
try {
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
attachmentsList = updated;
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
}
let bgStyle = $state('');
$effect(() => {
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
});
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
// pass through to NoteCards underneath (ghost click).
let pointerdownOnOverlay = false;
let closing = $state(false);
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayPointerup(e: PointerEvent) {
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
pointerdownOnOverlay = false;
}
function dismissOverlay() {
closing = true;
setTimeout(() => onClose(), 400);
}
async function saveAndClose() {
clearTimeout(autoSaveTimer);
// Wait for any in-flight auto-save to finish
if (savingPromise) await savingPromise;
if (!title.trim() && !content.trim()) {
dismissOverlay();
return;
}
// Final save if auto-save hasn't caught up yet
if (hasUnsavedChanges()) {
await performSave();
}
dismissOverlay();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
clearTimeout(autoSaveTimer);
if (savingPromise) await savingPromise;
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
if (!title.trim()) title = 'Untitled';
await performSave();
return noteId;
}
async function toggleImageUpload() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showImageUpload = !showImageUpload;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
saveAndClose();
}
}
function handleColorSelect(c: NoteColor) {
color = c;
showColorPicker = false;
}
function toggleMarkdownMode() {
rawMarkdownMode = !rawMarkdownMode;
if (rawMarkdownMode) {
requestAnimationFrame(() => textareaEl?.focus());
}
}
async function handleArchive() {
showOverflowMenu = false;
if (noteId) {
await archiveNote(noteId);
dismissOverlay();
}
}
async function handleTrash() {
showOverflowMenu = false;
if (noteId) {
await trashNote(noteId);
dismissOverlay();
}
}
function deleteCheckedItems() {
const items = parseChecklist(content).filter((i) => !i.checked);
content = serializeChecklist(items);
showOverflowMenu = false;
}
function uncheckAllItems() {
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
content = serializeChecklist(items);
showOverflowMenu = false;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
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}
data-testid="note-editor-overlay"
>
{#if !closing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
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"
>
<!-- 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="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)} />
</div>
{:else if rawMarkdownMode}
<textarea
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)]"
rows="12"
data-testid="note-content-input"
></textarea>
{:else}
<TiptapEditor
{content}
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
placeholder="Add a crumb..."
/>
{/if}
</div>
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
onUpload={handleAttachmentUpload}
onRemove={handleAttachmentRemove}
onToggleFeatured={handleToggleFeatured}
showDropZone={showImageUpload}
/>
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div class="shrink-0 touch-none" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
{/if}
<!-- Toolbar (Desktop only) -->
<div
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">
<!-- Color picker toggle -->
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
</button>
{#if showColorPicker}
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
<ColorPicker selected={color} onSelect={handleColorSelect} />
</div>
{/if}
</div>
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Share button -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"Share"}
data-testid="share-toggle"
>
{#if hasPublicLink}
<Globe class="h-5 w-5 text-[var(--primary)]" />
{:else if isShared}
<Users class="h-5 w-5 text-[var(--primary)]" />
{:else}
<UserPlus class="h-5 w-5" />
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
<Users class="h-5 w-5" />
</span>
{/if}
<!-- Archive button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
<Archive class="h-5 w-5" />
</button>
{/if}
<!-- Overflow menu trigger -->
<button
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"
>
<EllipsisVertical class="h-5 w-5" />
</button>
</div>
<button
onclick={saveAndClose}
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
</button>
</div>
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#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={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; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="checklist-toggle"
>
{#if checklistMode}
<Type class="h-4 w-4" />
Switch to text
{:else}
<SquareCheck class="h-4 w-4" />
Checklist mode
{/if}
</button>
<!-- Markdown mode toggle (not available in checklist mode) -->
{#if !checklistMode}
<button
onclick={() => { toggleMarkdownMode(); 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"
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
<FileText class="h-4 w-4" />
Rich text mode
{:else}
<FileCode class="h-4 w-4" />
Markdown mode
{/if}
</button>
{/if}
<!-- History -->
{#if !currentlyNew}
<button
onclick={() => { toggleHistory(); 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"
data-testid="history-toggle"
>
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
Version history
</button>
{/if}
<!-- Trash (only for saved notes) -->
{#if !currentlyNew}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
<button
onclick={handleTrash}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
data-testid="trash-note-btn"
>
<Trash2 class="h-4 w-4" />
Move to trash
</button>
{/if}
<!-- Checklist-specific actions -->
{#if checklistMode}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
{#if hasDoneItems}
<button
onclick={deleteCheckedItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="delete-checked-btn"
>
<Trash2 class="h-4 w-4" />
Delete checked
</button>
{/if}
<button
onclick={uncheckAllItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
Uncheck all
</button>
{/if}
</div>
{/if}
{/if}
</div>
{#if showShareDialog && noteId}
<ShareDialog
noteId={noteId}
collaborators={collaboratorsList}
{ownerName}
shareToken={currentShareToken}
onClose={() => (showShareDialog = false)}
onUpdate={handleCollaboratorsUpdate}
onShareUpdate={handleShareUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}