mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Three issues found in review, all around switching to a different note while the editor is already open (via a note-link or backlink chip): - TiptapEditor's onTransaction/onUpdate had no guard against writing state after the component is torn down. Removing a focused editor's DOM node fires a native blur, which ProseMirror turns into one last transaction synchronously during that same teardown pass, crashing with state_unsafe_mutation. Deferred the onTransaction write to a microtask (checked against a destroyed flag) so it lands after Svelte's current pass finishes; guarded onUpdate synchronously. - openEditor/openNoteById in NotesView.svelte awaited a network fetch before ever showing the editor, for every note open (not just link-driven navigation) — a regression for an offline-first app, and the source of a real race: two rapid note-link clicks left two fetches in flight with no ordering guarantee, so whichever resolved last won, not whichever was clicked last. Reverted to opening instantly from local/IndexedDB data (with a request-id guard against the residual race), and instead fetch backlinks lazily in NoteEditor.svelte only if not already present — mirroring the existing attachments-fetch pattern already in that file. Also applied the same post-teardown guard to that pre-existing attachments effect, since remounting on note-switch newly exposes it to the same class of race. - FormattingToolbar's closeDropdowns() cleared search results but never cancelled the pending debounce timer, so a stale query could repopulate results after the dropdown had already closed. Added the missing clearTimeout, plus a try/catch around the search fetch for consistency with the pattern used elsewhere in the app. Verified: pnpm check (0 errors), full unit suite (316/316), and the existing e2e suite including all note-linking scenarios (19/19) all pass against these changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
945 lines
31 KiB
Svelte
945 lines
31 KiB
Svelte
<script module lang="ts">
|
|
let bodyScrollLockCount = 0;
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { onMount, onDestroy } 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';
|
|
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, NoteBacklink } 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;
|
|
onOpenNote: (noteId: string) => void;
|
|
}
|
|
|
|
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: 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);
|
|
});
|
|
|
|
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
|
|
// component (rather than reusing the instance) whenever the user switches to a
|
|
// different note while the editor is open. That teardown skips the debounced
|
|
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
|
|
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
|
|
// could otherwise be silently dropped.
|
|
onDestroy(() => {
|
|
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
|
performSave();
|
|
}
|
|
});
|
|
|
|
// svelte-ignore state_referenced_locally
|
|
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
|
// svelte-ignore state_referenced_locally
|
|
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
|
|
|
|
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. 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 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).
|
|
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
|
|
// component on every note-switch (rather than reusing the instance), a fetch that
|
|
// resolves after teardown would otherwise write to $state on an already-destroyed
|
|
// instance racing the newly-mounted one.
|
|
$effect(() => {
|
|
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
|
let destroyed = false;
|
|
fetch(`/api/notes/${noteId}/attachments`)
|
|
.then((res) => res.ok ? res.json() : [])
|
|
.then((data: Attachment[]) => {
|
|
if (destroyed) return;
|
|
if (data.length > 0) attachmentsList = data;
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
destroyed = true;
|
|
};
|
|
}
|
|
});
|
|
|
|
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
|
|
// note.backlinks is only ever attached server-side by getNote(), which nothing
|
|
// else in the client calls when opening a note (the bulk notes store and
|
|
// IndexedDB never carry it), so this mirrors the attachments effect above to
|
|
// fill it in on open. Same post-teardown guard as above.
|
|
$effect(() => {
|
|
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
|
|
let destroyed = false;
|
|
fetch(`/api/notes/${noteId}`)
|
|
.then((res) => res.ok ? res.json() : null)
|
|
.then((data: Note | null) => {
|
|
if (destroyed) return;
|
|
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
destroyed = true;
|
|
};
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
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_150ms_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(--hover-wash)]/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(--hover-wash)]/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-base md: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++}
|
|
{onOpenNote}
|
|
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}
|
|
|
|
<!-- Backlinks -->
|
|
{#if backlinksList.length > 0}
|
|
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
|
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each backlinksList as backlink (backlink.id)}
|
|
<button
|
|
type="button"
|
|
onclick={() => onOpenNote(backlink.id)}
|
|
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
|
|
data-testid="backlink-chip"
|
|
>
|
|
{backlink.title || 'Untitled'}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Formatting toolbar -->
|
|
{#if !rawMarkdownMode && !checklistMode}
|
|
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
|
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
|
|
</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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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}
|