Refine the masonry drag interaction (task 1869) so reordering reads as intentional instead of an accidental, feedbackless jump: - explicit grip handle (top-left, hover-revealed, board views only) gates dragging — a plain click or text-select no longer starts a drag - source card dims while dragging; the card under the pointer shows a brand ring + slight lift, so the drop position is clear before release - proper move cursor via dataTransfer effectAllowed/dropEffect - dragleave uses a relatedTarget guard so the target ring doesn't flicker over child elements - clear drag state on dragend (BoardView clears the tracked source even when a drag is cancelled off-target) - add a "grip" icon to the shared Icon set Native HTML5 DnD stays library-free; it remains a desktop/mouse affordance (touch reorder is the M5 Android client's domain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
257 lines
8.7 KiB
Vue
257 lines
8.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
|
import { useUiStore } from "../stores/ui";
|
|
import QuickAdd from "../components/QuickAdd.vue";
|
|
import NoteCard from "../components/NoteCard.vue";
|
|
import NoteEditor from "../components/NoteEditor.vue";
|
|
|
|
const notes = useNotesStore();
|
|
const route = useRoute();
|
|
const ui = useUiStore();
|
|
const router = useRouter();
|
|
|
|
const editing = ref<Note | null>(null);
|
|
const quickAdd = ref<InstanceType<typeof QuickAdd> | null>(null);
|
|
const loadError = ref("");
|
|
|
|
// The global `c` shortcut bumps composeTick; reopen the composer when we're
|
|
// already on the board (a fresh navigation autofocuses it via the prop).
|
|
watch(
|
|
() => ui.composeTick,
|
|
() => quickAdd.value?.open(),
|
|
);
|
|
|
|
// The command palette opens a note by navigating here with ?open=<id>.
|
|
watch(
|
|
() => route.query.open,
|
|
(v) => {
|
|
if (typeof v === "string" && v) void openFromQuery(v);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
async function openFromQuery(id: string) {
|
|
const found = notes.items.find((n) => n.id === id) ?? (await notes.fetchOne(id));
|
|
if (found) editing.value = found;
|
|
const q = { ...route.query };
|
|
delete q.open;
|
|
void router.replace({ query: q });
|
|
}
|
|
|
|
function viewForRoute(name: unknown): NoteView {
|
|
if (name === "archive") return "archived";
|
|
if (name === "trash") return "trash";
|
|
return "active"; // board + label views
|
|
}
|
|
|
|
const currentView = computed<NoteView>(() => viewForRoute(route.name));
|
|
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
|
|
|
|
// Quick-add + pinned/others split only on the main board (not archive/trash/label).
|
|
const isMainBoard = computed(() => route.name === "board");
|
|
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
|
|
const otherNotes = computed(() => notes.items.filter((n) => !n.pinned));
|
|
|
|
// --- Keyboard card navigation: j/k or arrows move a selection; Enter opens;
|
|
// e archive, # pin, x trash the focused card. ---
|
|
const orderedNotes = computed<Note[]>(() =>
|
|
isMainBoard.value ? [...pinnedNotes.value, ...otherNotes.value] : notes.items,
|
|
);
|
|
const focusedIndex = ref(-1);
|
|
const inTrash = computed(() => currentView.value === "trash");
|
|
|
|
function moveFocus(delta: number) {
|
|
const count = orderedNotes.value.length;
|
|
if (count === 0) return;
|
|
focusedIndex.value =
|
|
focusedIndex.value < 0 ? 0 : Math.min(Math.max(focusedIndex.value + delta, 0), count - 1);
|
|
}
|
|
|
|
function onBoardKey(e: KeyboardEvent) {
|
|
if (editing.value) return; // the editor modal owns the keyboard while open
|
|
const el = e.target as HTMLElement | null;
|
|
if (el && (["INPUT", "TEXTAREA", "BUTTON", "A"].includes(el.tagName) || el.isContentEditable)) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
const key = e.key;
|
|
if (key === "j" || key === "ArrowDown" || key === "ArrowRight") {
|
|
e.preventDefault();
|
|
moveFocus(1);
|
|
return;
|
|
}
|
|
if (key === "k" || key === "ArrowUp" || key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
moveFocus(-1);
|
|
return;
|
|
}
|
|
const note = orderedNotes.value[focusedIndex.value];
|
|
if (!note) return;
|
|
if (key === "Enter") {
|
|
e.preventDefault();
|
|
openEditor(note);
|
|
} else if (key === "x" && !inTrash.value) {
|
|
e.preventDefault();
|
|
void notes.trash(note.id);
|
|
} else if (key === "e" && !inTrash.value) {
|
|
e.preventDefault();
|
|
void notes.setArchived(note.id, !note.archived);
|
|
} else if (key === "#" && !inTrash.value) {
|
|
e.preventDefault();
|
|
void notes.setPinned(note.id, !note.pinned);
|
|
}
|
|
}
|
|
|
|
// Keep the selection valid as the list changes / the view switches.
|
|
watch(
|
|
() => orderedNotes.value.length,
|
|
(len) => {
|
|
if (focusedIndex.value >= len) focusedIndex.value = len - 1;
|
|
},
|
|
);
|
|
watch([currentView, currentLabel], () => (focusedIndex.value = -1));
|
|
|
|
const emptyState = computed(() => {
|
|
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
|
|
if (currentView.value === "archived")
|
|
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
|
|
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
|
|
return { title: "No notes yet", subtitle: "Capture your first thought in the box above." };
|
|
});
|
|
|
|
async function reload() {
|
|
loadError.value = "";
|
|
try {
|
|
await notes.load(currentView.value, currentLabel.value);
|
|
} catch (e) {
|
|
loadError.value = (e as { error?: string }).error ?? "Couldn't load your notes.";
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void reload();
|
|
window.addEventListener("keydown", onBoardKey);
|
|
});
|
|
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey));
|
|
watch([currentView, currentLabel], reload);
|
|
|
|
function openEditor(note: Note) {
|
|
editing.value = note;
|
|
}
|
|
function closeEditor() {
|
|
editing.value = null;
|
|
}
|
|
|
|
async function onNavigate(id: string) {
|
|
const found = notes.items.find((n) => n.id === id);
|
|
if (found) {
|
|
editing.value = found;
|
|
return;
|
|
}
|
|
const fetched = await notes.fetchOne(id);
|
|
if (fetched) editing.value = fetched;
|
|
}
|
|
|
|
const draggingId = ref<string | null>(null);
|
|
function onDragStart(note: Note) {
|
|
draggingId.value = note.id;
|
|
}
|
|
function onDragEnd() {
|
|
// Clear the tracked source even when a drag is cancelled (dropped on nothing).
|
|
draggingId.value = null;
|
|
}
|
|
async function onDrop(target: Note) {
|
|
const from = draggingId.value;
|
|
draggingId.value = null;
|
|
if (!from || from === target.id) return;
|
|
const order = notes.items.map((n) => n.id);
|
|
const fromIdx = order.indexOf(from);
|
|
const toIdx = order.indexOf(target.id);
|
|
if (fromIdx < 0 || toIdx < 0) return;
|
|
order.splice(fromIdx, 1);
|
|
order.splice(toIdx, 0, from);
|
|
await notes.reorder(order);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
|
<QuickAdd v-if="isMainBoard" ref="quickAdd" autofocus class="mb-8" />
|
|
|
|
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
|
|
|
<div v-else-if="loadError" class="py-24 text-center">
|
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load your notes</h2>
|
|
<p class="mt-1 text-sm text-neutral-400">{{ loadError }}</p>
|
|
<button
|
|
type="button"
|
|
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@click="reload"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else-if="notes.items.length === 0" class="py-24 text-center">
|
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ emptyState.title }}</h2>
|
|
<p class="mt-1 text-sm text-neutral-400">{{ emptyState.subtitle }}</p>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<template v-if="isMainBoard">
|
|
<section v-if="pinnedNotes.length">
|
|
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
|
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
|
<NoteCard
|
|
v-for="(n, i) in pinnedNotes"
|
|
:key="n.id"
|
|
:note="n"
|
|
:active="focusedIndex === i"
|
|
reorderable
|
|
@open="openEditor"
|
|
@dragstart="onDragStart"
|
|
@dragend="onDragEnd"
|
|
@drop="onDrop"
|
|
/>
|
|
</div>
|
|
</section>
|
|
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
|
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
|
Others
|
|
</h2>
|
|
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
|
<NoteCard
|
|
v-for="(n, i) in otherNotes"
|
|
:key="n.id"
|
|
:note="n"
|
|
:active="focusedIndex === pinnedNotes.length + i"
|
|
reorderable
|
|
@open="openEditor"
|
|
@dragstart="onDragStart"
|
|
@dragend="onDragEnd"
|
|
@drop="onDrop"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
|
<NoteCard
|
|
v-for="(n, i) in notes.items"
|
|
:key="n.id"
|
|
:note="n"
|
|
:active="focusedIndex === i"
|
|
reorderable
|
|
@open="openEditor"
|
|
@dragstart="onDragStart"
|
|
@dragend="onDragEnd"
|
|
@drop="onDrop"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<template v-if="editing">
|
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
|
</template>
|
|
</template>
|