forked from mirror/crumbs-original
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>
844 lines
27 KiB
Svelte
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}
|