M7: unify compose + edit into one NoteEditor (DRY)
The 'Take a note' composer (QuickAdd) and the note editor were two components with duplicated field logic and unequal capabilities — composing lacked [[ links, labels, images, reminders. Merge them into a single NoteEditor with two frames: inline on the board (compose), modal on a card (edit). Same fields, styling, [[ autocomplete and toolbar in both (task 1920). Compose is now a draft that persists on first real action: on commit-with-content, or on the first label/image/reminder/checklist use (ensureDraft) — so no empty-note litter. Rich controls light up once there's content; note-lifecycle actions (pin/archive/trash, links/backlinks) stay in the modal. notes.create() now returns the created note (for the draft id). QuickAdd.vue deleted; BoardView renders <NoteEditor inline>. Pure frontend — no backend/DB/migration. Sets up the editor<->card morph (task 1914). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
||||||
@@ -11,67 +11,246 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
|||||||
import type { Note, NoteLabel } from "../stores/notes";
|
import type { Note, NoteLabel } from "../stores/notes";
|
||||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
const props = defineProps<{ note: Note }>();
|
// One editor for BOTH composing and editing. `inline` renders the board composer
|
||||||
|
// frame (collapsible, in-flow); the default renders the modal editor. `note` = the
|
||||||
|
// note being edited, or null to compose a new one. In compose mode there is no note
|
||||||
|
// id until a draft is persisted — on commit-with-content, or on the first rich action
|
||||||
|
// (label / image / reminder / checklist) — so we never litter empty notes.
|
||||||
|
const props = withDefaults(defineProps<{ note?: Note | null; inline?: boolean; autofocus?: boolean }>(), {
|
||||||
|
note: null,
|
||||||
|
inline: false,
|
||||||
|
autofocus: false,
|
||||||
|
});
|
||||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
const titles = useTitlesStore();
|
const titles = useTitlesStore();
|
||||||
|
|
||||||
// Read the note reactively from the store so checklist item add/toggle/delete
|
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||||
// (which reconcile a fresh note object) reflect live while the editor is open.
|
const title = ref(props.note?.title ?? "");
|
||||||
const liveNote = computed(() => notes.items.find((n) => n.id === props.note.id) ?? props.note);
|
const body = ref(props.note?.body ?? "");
|
||||||
|
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||||||
const title = ref(props.note.title ?? "");
|
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||||
const body = ref(props.note.body);
|
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
|
||||||
const color = ref<NoteColor>(props.note.color);
|
const expanded = ref(!props.inline); // modal is always open; inline starts collapsed
|
||||||
const labelList = ref<NoteLabel[]>([...props.note.labels]);
|
const saving = ref(false);
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploadError = ref("");
|
||||||
|
const backlinks = ref<{ id: string; title: string }[]>([]);
|
||||||
|
|
||||||
|
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||||
|
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
||||||
|
title: props.note?.title ?? null,
|
||||||
|
body: props.note?.body ?? "",
|
||||||
|
color: (props.note?.color ?? "default") as NoteColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCreate = computed(() => noteId.value === null);
|
||||||
|
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
|
||||||
|
// Rich features need a saved note; in compose they light up once there's content.
|
||||||
|
const richEnabled = computed(() => !isCreate.value || hasContent.value);
|
||||||
|
|
||||||
|
// A synthetic note for compose mode (before anything is persisted), so the shared
|
||||||
|
// template can read attachments/items/kind/remind_at uniformly.
|
||||||
|
const draftNote = computed<Note>(() => ({
|
||||||
|
id: "",
|
||||||
|
title: title.value.trim() || null,
|
||||||
|
display_title: "",
|
||||||
|
body: body.value,
|
||||||
|
color: color.value,
|
||||||
|
kind: createKind.value,
|
||||||
|
position: 0,
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
|
trashed: false,
|
||||||
|
remind_at: null,
|
||||||
|
labels: labelList.value,
|
||||||
|
items: [],
|
||||||
|
attachments: [],
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
}));
|
||||||
|
const liveNote = computed<Note>(() =>
|
||||||
|
noteId.value
|
||||||
|
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
|
||||||
|
: draftNote.value,
|
||||||
|
);
|
||||||
|
// Only edit-mode list notes render the interactive checklist; compose-list types
|
||||||
|
// lines into the textarea (they become items on create).
|
||||||
|
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
|
||||||
|
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
|
||||||
|
const bodyPlaceholder = computed(() =>
|
||||||
|
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep local state in sync when the edited note changes (modal reused for another note).
|
||||||
watch(
|
watch(
|
||||||
() => props.note,
|
() => props.note,
|
||||||
(n) => {
|
(n) => {
|
||||||
title.value = n.title ?? "";
|
noteId.value = n?.id ?? null;
|
||||||
body.value = n.body;
|
title.value = n?.title ?? "";
|
||||||
color.value = n.color;
|
body.value = n?.body ?? "";
|
||||||
labelList.value = [...n.labels];
|
color.value = (n?.color ?? "default") as NoteColor;
|
||||||
|
labelList.value = n ? [...n.labels] : [];
|
||||||
|
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const backlinks = ref<{ id: string; title: string }[]>([]);
|
// ---- persistence ----
|
||||||
|
async function createFromFields(): Promise<void> {
|
||||||
|
let created: Note;
|
||||||
|
if (createKind.value === "list") {
|
||||||
|
const items = body.value
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
|
||||||
|
body.value = ""; // the lines moved into checklist items
|
||||||
|
} else {
|
||||||
|
created = await notes.create({ title: title.value, body: body.value, color: color.value });
|
||||||
|
}
|
||||||
|
noteId.value = created.id;
|
||||||
|
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBacklinks() {
|
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
|
||||||
|
// null when there's nothing to create yet (empty compose — callers just no-op).
|
||||||
|
async function ensureDraft(): Promise<string | null> {
|
||||||
|
if (noteId.value) return noteId.value;
|
||||||
|
if (!hasContent.value) return null;
|
||||||
|
await createFromFields();
|
||||||
|
return noteId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist current text fields: create in compose, patch in edit.
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
if (saving.value) return;
|
||||||
|
if (isCreate.value) {
|
||||||
|
if (!hasContent.value) return;
|
||||||
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(
|
await createFromFields();
|
||||||
`/api/notes/${props.note.id}/backlinks`,
|
} finally {
|
||||||
);
|
saving.value = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = baseline.value;
|
||||||
|
const nextBody = showChecklist.value ? b.body : body.value;
|
||||||
|
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
|
||||||
|
if (!changed) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
|
||||||
|
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCompose(): void {
|
||||||
|
noteId.value = null;
|
||||||
|
title.value = "";
|
||||||
|
body.value = "";
|
||||||
|
color.value = "default";
|
||||||
|
labelList.value = [];
|
||||||
|
createKind.value = "text";
|
||||||
|
baseline.value = { title: null, body: "", color: "default" };
|
||||||
|
linkMenu.value = false;
|
||||||
|
uploadError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- inline (compose) frame ----
|
||||||
|
async function open(): Promise<void> {
|
||||||
|
expanded.value = true;
|
||||||
|
await nextTick();
|
||||||
|
bodyInput.value?.focus();
|
||||||
|
autoGrow();
|
||||||
|
}
|
||||||
|
async function commitInline(): Promise<void> {
|
||||||
|
await flush();
|
||||||
|
resetCompose();
|
||||||
|
expanded.value = false;
|
||||||
|
}
|
||||||
|
async function commitAndContinue(): Promise<void> {
|
||||||
|
if (isCreate.value && !hasContent.value) return;
|
||||||
|
await flush();
|
||||||
|
resetCompose();
|
||||||
|
await nextTick();
|
||||||
|
bodyInput.value?.focus();
|
||||||
|
autoGrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- modal (edit) frame ----
|
||||||
|
async function close(): Promise<void> {
|
||||||
|
await flush();
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
function onEsc(): void {
|
||||||
|
if (props.inline) void commitInline();
|
||||||
|
else void close();
|
||||||
|
}
|
||||||
|
function onMetaEnter(): void {
|
||||||
|
if (!props.inline) void close();
|
||||||
|
}
|
||||||
|
function onBackdropMousedown(): void {
|
||||||
|
if (!props.inline) void close();
|
||||||
|
}
|
||||||
|
function onDocMousedown(e: MouseEvent): void {
|
||||||
|
if (props.inline && expanded.value && root.value && !root.value.contains(e.target as Node)) {
|
||||||
|
void commitInline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow the composer textarea to fit its content (inline only; modal uses fixed rows).
|
||||||
|
function autoGrow(): void {
|
||||||
|
if (!props.inline) return;
|
||||||
|
const el = bodyInput.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBacklinks(): Promise<void> {
|
||||||
|
if (!noteId.value) {
|
||||||
|
backlinks.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(`/api/notes/${noteId.value}/backlinks`);
|
||||||
backlinks.value = res.backlinks;
|
backlinks.value = res.backlinks;
|
||||||
} catch {
|
} catch {
|
||||||
backlinks.value = [];
|
backlinks.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
watch(() => noteId.value, loadBacklinks);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
void titles.load();
|
void titles.load();
|
||||||
void loadBacklinks();
|
void loadBacklinks();
|
||||||
|
if (props.inline) {
|
||||||
|
document.addEventListener("mousedown", onDocMousedown);
|
||||||
|
if (props.autofocus) void open();
|
||||||
|
} else {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
bodyInput.value?.focus();
|
bodyInput.value?.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
|
||||||
|
|
||||||
watch(
|
// ---- outgoing links (edit mode) ----
|
||||||
() => props.note.id,
|
|
||||||
() => void loadBacklinks(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const outgoingLinks = computed(() => {
|
const outgoingLinks = computed(() => {
|
||||||
const re = /\[\[([^[\]]+)\]\]/g;
|
const re = /\[\[([^[\]]+)\]\]/g;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: { title: string; id: string | null }[] = [];
|
const out: { title: string; id: string | null }[] = [];
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = re.exec(body.value)) !== null) {
|
while ((match = re.exec(body.value)) !== null) {
|
||||||
const title = match[1].trim();
|
const t = match[1].trim();
|
||||||
const key = title.toLowerCase();
|
const key = t.toLowerCase();
|
||||||
if (title && !seen.has(key)) {
|
if (t && !seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
out.push({ title, id: titles.resolve(title)?.id ?? null });
|
out.push({ title: t, id: titles.resolve(t)?.id ?? null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -87,15 +266,11 @@ async function openLink(link: { title: string; id: string | null }) {
|
|||||||
emit("navigate", created.id);
|
emit("navigate", created.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- [[ link autocomplete in the body textarea ---
|
// ---- [[ link autocomplete in the body textarea ----
|
||||||
const linkMenu = ref(false);
|
const linkMenu = ref(false);
|
||||||
const linkQuery = ref("");
|
const linkQuery = ref("");
|
||||||
const linkStart = ref(-1); // index of the `[[` that opened the current token
|
const linkStart = ref(-1);
|
||||||
const linkSelected = ref(0);
|
const linkSelected = ref(0);
|
||||||
|
|
||||||
// [[ autocomplete searches note NAME *and* body via the server (link-search), so you
|
|
||||||
// can link by recalling any phrase — not just the exact name. Debounced so we don't
|
|
||||||
// fire a request on every keystroke.
|
|
||||||
const linkMatches = ref<TitleEntry[]>([]);
|
const linkMatches = ref<TitleEntry[]>([]);
|
||||||
let linkTimer: ReturnType<typeof setTimeout> | undefined;
|
let linkTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
@@ -104,11 +279,8 @@ function refreshLinkMatches() {
|
|||||||
const q = linkQuery.value.trim();
|
const q = linkQuery.value.trim();
|
||||||
linkTimer = setTimeout(async () => {
|
linkTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ results: TitleEntry[] }>(
|
const res = await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`);
|
||||||
`/api/notes/link-search?q=${encodeURIComponent(q)}`,
|
linkMatches.value = res.results.filter((r) => r.id !== noteId.value).slice(0, 8);
|
||||||
);
|
|
||||||
// Never suggest linking a note to itself.
|
|
||||||
linkMatches.value = res.results.filter((r) => r.id !== props.note.id).slice(0, 8);
|
|
||||||
} catch {
|
} catch {
|
||||||
linkMatches.value = [];
|
linkMatches.value = [];
|
||||||
}
|
}
|
||||||
@@ -116,9 +288,8 @@ function refreshLinkMatches() {
|
|||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On every edit, check whether the caret sits inside an unclosed `[[…` token
|
|
||||||
// and, if so, open the suggestion menu with the partial title as the query.
|
|
||||||
function onBodyInput() {
|
function onBodyInput() {
|
||||||
|
if (props.inline) autoGrow();
|
||||||
const el = bodyInput.value;
|
const el = bodyInput.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const caret = el.selectionStart ?? 0;
|
const caret = el.selectionStart ?? 0;
|
||||||
@@ -140,12 +311,12 @@ function onBodyInput() {
|
|||||||
refreshLinkMatches();
|
refreshLinkMatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertLink(title: string) {
|
function insertLink(t: string) {
|
||||||
const el = bodyInput.value;
|
const el = bodyInput.value;
|
||||||
const caret = el?.selectionStart ?? body.value.length;
|
const caret = el?.selectionStart ?? body.value.length;
|
||||||
const before = body.value.slice(0, linkStart.value);
|
const before = body.value.slice(0, linkStart.value);
|
||||||
const after = body.value.slice(caret);
|
const after = body.value.slice(caret);
|
||||||
const insertion = `[[${title}]]`;
|
const insertion = `[[${t}]]`;
|
||||||
body.value = before + insertion + after;
|
body.value = before + insertion + after;
|
||||||
linkMenu.value = false;
|
linkMenu.value = false;
|
||||||
const pos = before.length + insertion.length;
|
const pos = before.length + insertion.length;
|
||||||
@@ -156,6 +327,12 @@ function insertLink(title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBodyKeydown(e: KeyboardEvent) {
|
function onBodyKeydown(e: KeyboardEvent) {
|
||||||
|
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||||
|
if (props.inline && e.key === "Enter" && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void commitAndContinue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!linkMenu.value || linkMatches.value.length === 0) return;
|
if (!linkMenu.value || linkMatches.value.length === 0) return;
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -170,20 +347,32 @@ function onBodyKeydown(e: KeyboardEvent) {
|
|||||||
insertLink(m.title);
|
insertLink(m.title);
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
// Close only the menu — don't let it bubble to the dialog's Esc-to-close.
|
// Close only the menu — don't let Esc bubble to the frame's close/commit.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
linkMenu.value = false;
|
linkMenu.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
function onTitleEnter(e: KeyboardEvent) {
|
||||||
|
if (!props.inline) return;
|
||||||
function onReminderChange(e: Event) {
|
e.preventDefault();
|
||||||
void notes.setReminder(props.note.id, fromLocalInput((e.target as HTMLInputElement).value));
|
if (e.shiftKey) void commitAndContinue();
|
||||||
|
else bodyInput.value?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- reminder ----
|
||||||
|
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
||||||
|
async function onReminderChange(e: Event) {
|
||||||
|
const id = await ensureDraft();
|
||||||
|
if (!id) return;
|
||||||
|
await notes.setReminder(id, fromLocalInput((e.target as HTMLInputElement).value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- labels ----
|
||||||
async function onLabelsChange(next: NoteLabel[]) {
|
async function onLabelsChange(next: NoteLabel[]) {
|
||||||
|
const id = await ensureDraft();
|
||||||
|
if (!id) return;
|
||||||
// Tag-sourced labels are governed by the note body, not the picker — always keep
|
// Tag-sourced labels are governed by the note body, not the picker — always keep
|
||||||
// them so a picker save can't strip a label the #tag still mandates.
|
// them so a picker save can't strip a label the #tag still mandates.
|
||||||
const tagLabels = labelList.value.filter((lb) => lb.via_tag);
|
const tagLabels = labelList.value.filter((lb) => lb.via_tag);
|
||||||
@@ -191,60 +380,63 @@ async function onLabelsChange(next: NoteLabel[]) {
|
|||||||
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
|
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
|
||||||
labelList.value = merged;
|
labelList.value = merged;
|
||||||
await notes.setLabels(
|
await notes.setLabels(
|
||||||
props.note.id,
|
id,
|
||||||
merged.map((lb) => lb.id),
|
merged.map((lb) => lb.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeLabel(id: string) {
|
async function removeLabel(id: string) {
|
||||||
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
|
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
|
||||||
}
|
}
|
||||||
|
function labelChip(c: string): string {
|
||||||
function labelChip(color: string): string {
|
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||||
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
|
||||||
async function toggleKind() {
|
async function toggleKind() {
|
||||||
|
if (isCreate.value) {
|
||||||
|
createKind.value = createKind.value === "list" ? "text" : "list";
|
||||||
|
bodyInput.value?.focus();
|
||||||
|
void nextTick(autoGrow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = noteId.value as string;
|
||||||
if (liveNote.value.kind === "list") {
|
if (liveNote.value.kind === "list") {
|
||||||
await notes.setKind(props.note.id, "text");
|
await notes.setKind(id, "text");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Convert existing body lines into checklist items, then switch to a list.
|
|
||||||
const lines = body.value
|
const lines = body.value
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s) => s.length > 0);
|
.filter((s) => s.length > 0);
|
||||||
for (const line of lines) await notes.addItem(props.note.id, line);
|
for (const line of lines) await notes.addItem(id, line);
|
||||||
if (lines.length > 0) {
|
if (lines.length > 0) {
|
||||||
body.value = "";
|
body.value = "";
|
||||||
await notes.saveEdit(props.note.id, { title: title.value, body: "", color: color.value });
|
await notes.saveEdit(id, { title: title.value, body: "", color: color.value });
|
||||||
|
baseline.value = { title: title.value.trim() || null, body: "", color: color.value };
|
||||||
}
|
}
|
||||||
await notes.setKind(props.note.id, "list");
|
await notes.setKind(id, "list");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
// ---- attachments ----
|
||||||
const uploadError = ref("");
|
|
||||||
|
|
||||||
function pickImage() {
|
function pickImage() {
|
||||||
fileInput.value?.click();
|
fileInput.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(file: File) {
|
async function uploadFile(file: File) {
|
||||||
|
const id = await ensureDraft();
|
||||||
|
if (!id) return;
|
||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
try {
|
try {
|
||||||
await notes.uploadAttachment(props.note.id, file);
|
await notes.uploadAttachment(id, file);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uploadError.value = (e as { error?: string }).error ?? "Could not upload image.";
|
uploadError.value = (e as { error?: string }).error ?? "Could not upload image.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFileChange(e: Event) {
|
async function onFileChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (file) await uploadFile(file);
|
if (file) await uploadFile(file);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPaste(e: ClipboardEvent) {
|
async function onPaste(e: ClipboardEvent) {
|
||||||
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
|
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
|
||||||
const file = item?.getAsFile();
|
const file = item?.getAsFile();
|
||||||
@@ -254,35 +446,52 @@ async function onPaste(e: ClipboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function close() {
|
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
|
||||||
const changed =
|
|
||||||
(title.value.trim() || null) !== (props.note.title ?? null) ||
|
|
||||||
body.value !== props.note.body ||
|
|
||||||
color.value !== props.note.color;
|
|
||||||
if (changed) {
|
|
||||||
await notes.saveEdit(props.note.id, { title: title.value, body: body.value, color: color.value });
|
|
||||||
}
|
|
||||||
emit("close");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function act(fn: () => Promise<void>) {
|
async function act(fn: () => Promise<void>) {
|
||||||
await fn();
|
await fn();
|
||||||
emit("close");
|
emit("close");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
ref="root"
|
||||||
@mousedown.self="close"
|
:class="
|
||||||
|
inline
|
||||||
|
? 'mx-auto w-full max-w-xl'
|
||||||
|
: 'fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]'
|
||||||
|
"
|
||||||
|
@mousedown.self="onBackdropMousedown"
|
||||||
>
|
>
|
||||||
|
<!-- Collapsed composer (inline, board) -->
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
v-if="inline && !expanded"
|
||||||
role="dialog"
|
class="rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
aria-modal="true"
|
>
|
||||||
@keydown.esc="close"
|
<button
|
||||||
@keydown.enter.meta.prevent="close"
|
type="button"
|
||||||
@keydown.enter.ctrl.prevent="close"
|
class="w-full rounded-xl px-4 py-3 text-left text-sm text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400"
|
||||||
|
@click="open"
|
||||||
|
>
|
||||||
|
Take a note…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared editor card: compose (expanded, inline) OR edit (modal) -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:class="
|
||||||
|
inline
|
||||||
|
? 'w-full rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900'
|
||||||
|
: 'w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900'
|
||||||
|
"
|
||||||
|
:role="inline ? undefined : 'dialog'"
|
||||||
|
:aria-modal="inline ? undefined : 'true'"
|
||||||
|
@keydown.esc="onEsc"
|
||||||
|
@keydown.enter.meta.prevent="onMetaEnter"
|
||||||
|
@keydown.enter.ctrl.prevent="onMetaEnter"
|
||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2 p-4">
|
<div class="flex flex-col gap-2 p-4">
|
||||||
@@ -293,26 +502,33 @@ async function act(fn: () => Promise<void>) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
|
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
|
||||||
aria-label="Remove image"
|
aria-label="Remove image"
|
||||||
@click="notes.deleteAttachment(note.id, att.id)"
|
@click="notes.deleteAttachment(liveNote.id, att.id)"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
|
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title (optional)"
|
placeholder="Title (optional)"
|
||||||
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
||||||
|
@keydown.enter="onTitleEnter"
|
||||||
/>
|
/>
|
||||||
<div v-if="liveNote.kind === 'text'" class="relative">
|
|
||||||
|
<div v-if="!showChecklist" class="relative">
|
||||||
<textarea
|
<textarea
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
rows="8"
|
:rows="inline ? undefined : 8"
|
||||||
placeholder="Take a note… ([[ to link a note)"
|
:placeholder="bodyPlaceholder"
|
||||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
:class="
|
||||||
|
inline
|
||||||
|
? 'max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
|
||||||
|
: 'w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
|
||||||
|
"
|
||||||
@input="onBodyInput"
|
@input="onBodyInput"
|
||||||
@keydown="onBodyKeydown"
|
@keydown="onBodyKeydown"
|
||||||
/>
|
/>
|
||||||
@@ -359,26 +575,26 @@ async function act(fn: () => Promise<void>) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 pt-1">
|
<div v-if="richEnabled" class="flex items-center gap-2 pt-1">
|
||||||
<Icon name="bell" class="text-neutral-400" />
|
<Icon name="bell" class="text-neutral-400" />
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
:value="reminderLocal"
|
:value="reminderLocal"
|
||||||
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
|
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:[color-scheme:dark]"
|
||||||
@change="onReminderChange"
|
@change="onReminderChange"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="liveNote.remind_at"
|
v-if="liveNote.remind_at"
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
|
class="text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||||
@click="notes.setReminder(note.id, null)"
|
@click="notes.setReminder(liveNote.id, null)"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="outgoingLinks.length || backlinks.length"
|
v-if="!inline && !isCreate && (outgoingLinks.length || backlinks.length)"
|
||||||
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
||||||
>
|
>
|
||||||
<div v-if="outgoingLinks.length">
|
<div v-if="outgoingLinks.length">
|
||||||
@@ -422,7 +638,7 @@ async function act(fn: () => Promise<void>) {
|
|||||||
<ColorPicker v-model="color" />
|
<ColorPicker v-model="color" />
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
v-if="!note.trashed"
|
v-if="richEnabled && !liveNote.trashed"
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
title="Add image"
|
title="Add image"
|
||||||
@@ -439,57 +655,68 @@ async function act(fn: () => Promise<void>) {
|
|||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="!note.trashed"
|
v-if="!liveNote.trashed"
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
:class="liveNote.kind === 'list' ? 'text-brand-700 dark:text-brand' : ''"
|
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
|
||||||
:title="liveNote.kind === 'list' ? 'Convert to text note' : 'Convert to checklist'"
|
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
|
||||||
|
:aria-pressed="isListMode"
|
||||||
@click="toggleKind"
|
@click="toggleKind"
|
||||||
>
|
>
|
||||||
<Icon name="checkbox" />
|
<Icon name="checkbox" />
|
||||||
</button>
|
</button>
|
||||||
<LabelPicker v-if="!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" />
|
<LabelPicker
|
||||||
<template v-if="!note.trashed">
|
v-if="richEnabled && !liveNote.trashed"
|
||||||
|
:model-value="labelList"
|
||||||
|
@update:model-value="onLabelsChange"
|
||||||
|
/>
|
||||||
|
<template v-if="!inline && !isCreate && !liveNote.trashed">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
:class="liveNote.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
||||||
:title="note.pinned ? 'Unpin' : 'Pin'"
|
:title="liveNote.pinned ? 'Unpin' : 'Pin'"
|
||||||
@click="act(() => notes.setPinned(note.id, !note.pinned))"
|
@click="act(() => notes.setPinned(liveNote.id, !liveNote.pinned))"
|
||||||
>
|
>
|
||||||
<Icon name="pin" />
|
<Icon name="pin" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
:title="note.archived ? 'Unarchive' : 'Archive'"
|
:title="liveNote.archived ? 'Unarchive' : 'Archive'"
|
||||||
@click="act(() => notes.setArchived(note.id, !note.archived))"
|
@click="act(() => notes.setArchived(liveNote.id, !liveNote.archived))"
|
||||||
>
|
>
|
||||||
<Icon name="archive" />
|
<Icon name="archive" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="icon-btn" title="Move to trash" @click="act(() => notes.trash(note.id))">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
title="Move to trash"
|
||||||
|
@click="act(() => notes.trash(liveNote.id))"
|
||||||
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="!inline && !isCreate && liveNote.trashed">
|
||||||
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(note.id))">
|
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(liveNote.id))">
|
||||||
<Icon name="restore" />
|
<Icon name="restore" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
title="Delete forever"
|
title="Delete forever"
|
||||||
@click="act(() => notes.deleteForever(note.id))"
|
@click="act(() => notes.deleteForever(liveNote.id))"
|
||||||
>
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-200 dark:hover:bg-neutral-800"
|
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
@click="close"
|
:disabled="saving"
|
||||||
|
@click="inline ? commitInline() : close()"
|
||||||
>
|
>
|
||||||
Close
|
{{ inline ? "Done" : "Close" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onBeforeUnmount, onMounted, nextTick, ref } from "vue";
|
|
||||||
import { useNotesStore } from "../stores/notes";
|
|
||||||
import ColorPicker from "./ColorPicker.vue";
|
|
||||||
import Icon from "./Icon.vue";
|
|
||||||
import type { NoteColor } from "../notes/colors";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ autofocus?: boolean }>(), { autofocus: false });
|
|
||||||
|
|
||||||
const notes = useNotesStore();
|
|
||||||
|
|
||||||
const expanded = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const title = ref("");
|
|
||||||
const body = ref("");
|
|
||||||
const color = ref<NoteColor>("default");
|
|
||||||
// 'text' = freeform note; 'list' = checklist (each body line becomes an item).
|
|
||||||
const mode = ref<"text" | "list">("text");
|
|
||||||
const root = ref<HTMLElement | null>(null);
|
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
function toggleMode() {
|
|
||||||
mode.value = mode.value === "list" ? "text" : "list";
|
|
||||||
bodyInput.value?.focus();
|
|
||||||
void nextTick(autoGrow);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function open() {
|
|
||||||
expanded.value = true;
|
|
||||||
await nextTick();
|
|
||||||
bodyInput.value?.focus();
|
|
||||||
autoGrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasContent(): boolean {
|
|
||||||
return title.value.trim() !== "" || body.value.trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFields() {
|
|
||||||
title.value = "";
|
|
||||||
body.value = "";
|
|
||||||
color.value = "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grow the composer textarea to fit its content (up to a capped max height).
|
|
||||||
function autoGrow() {
|
|
||||||
const el = bodyInput.value;
|
|
||||||
if (!el) return;
|
|
||||||
el.style.height = "auto";
|
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persist(): Promise<void> {
|
|
||||||
if (!hasContent()) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (mode.value === "list") {
|
|
||||||
// Each non-empty body line becomes a checklist item.
|
|
||||||
const items = body.value
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
|
|
||||||
} else {
|
|
||||||
await notes.create({ title: title.value, body: body.value, color: color.value });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the composer, saving anything typed.
|
|
||||||
async function commit() {
|
|
||||||
await persist();
|
|
||||||
clearFields();
|
|
||||||
expanded.value = false;
|
|
||||||
mode.value = "text"; // next capture starts as a plain note
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shift+Enter: save the current note and immediately start a fresh one, staying
|
|
||||||
// open + focused for rapid-fire capture.
|
|
||||||
async function commitAndContinue() {
|
|
||||||
if (!hasContent()) return;
|
|
||||||
await persist();
|
|
||||||
clearFields();
|
|
||||||
await nextTick();
|
|
||||||
bodyInput.value?.focus();
|
|
||||||
autoGrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDocumentMousedown(e: MouseEvent) {
|
|
||||||
if (expanded.value && root.value && !root.value.contains(e.target as Node)) {
|
|
||||||
void commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("mousedown", onDocumentMousedown);
|
|
||||||
// Landing on the board drops the cursor straight into the note input.
|
|
||||||
if (props.autofocus) void open();
|
|
||||||
});
|
|
||||||
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentMousedown));
|
|
||||||
|
|
||||||
// Let the board (via the global `c` shortcut) reopen + focus the composer.
|
|
||||||
defineExpose({ open });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="root" class="mx-auto w-full max-w-xl">
|
|
||||||
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900">
|
|
||||||
<button
|
|
||||||
v-if="!expanded"
|
|
||||||
type="button"
|
|
||||||
class="w-full rounded-xl px-4 py-3 text-left text-sm text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400"
|
|
||||||
@click="open"
|
|
||||||
>
|
|
||||||
Take a note…
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-2 p-3">
|
|
||||||
<input
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Title (optional)"
|
|
||||||
class="w-full bg-transparent px-1 text-sm font-semibold outline-none placeholder:text-neutral-400"
|
|
||||||
@keydown.enter.shift.prevent="commitAndContinue"
|
|
||||||
@keydown.enter.exact.prevent="bodyInput?.focus()"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
ref="bodyInput"
|
|
||||||
v-model="body"
|
|
||||||
:placeholder="
|
|
||||||
mode === 'list'
|
|
||||||
? 'One item per line… (Shift+Enter saves)'
|
|
||||||
: 'Take a note… (Shift+Enter saves & starts a new one)'
|
|
||||||
"
|
|
||||||
class="max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent px-1 text-sm outline-none placeholder:text-neutral-400"
|
|
||||||
@input="autoGrow"
|
|
||||||
@keydown.enter.shift.prevent="commitAndContinue"
|
|
||||||
@keydown.esc="commit"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center justify-between gap-2 pt-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<ColorPicker v-model="color" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="icon-btn"
|
|
||||||
:class="mode === 'list' ? 'text-brand-700 dark:text-brand' : ''"
|
|
||||||
:title="mode === 'list' ? 'Switch to a note' : 'Make a checklist'"
|
|
||||||
:aria-label="mode === 'list' ? 'Switch to a note' : 'Make a checklist'"
|
|
||||||
:aria-pressed="mode === 'list'"
|
|
||||||
@click="toggleMode"
|
|
||||||
>
|
|
||||||
<Icon name="checkbox" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="commit"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -104,8 +104,10 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
kind?: NoteKind;
|
kind?: NoteKind;
|
||||||
items?: string[];
|
items?: string[];
|
||||||
}): Promise<void> {
|
}): Promise<Note> {
|
||||||
reconcile(await api.post<Note>("/api/notes", input));
|
const note = await api.post<Note>("/api/notes", input);
|
||||||
|
reconcile(note);
|
||||||
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutate(
|
async function mutate(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
import QuickAdd from "../components/QuickAdd.vue";
|
|
||||||
import NoteCard from "../components/NoteCard.vue";
|
import NoteCard from "../components/NoteCard.vue";
|
||||||
import NoteEditor from "../components/NoteEditor.vue";
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
@@ -13,14 +12,14 @@ const ui = useUiStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const editing = ref<Note | null>(null);
|
const editing = ref<Note | null>(null);
|
||||||
const quickAdd = ref<InstanceType<typeof QuickAdd> | null>(null);
|
const composer = ref<InstanceType<typeof NoteEditor> | null>(null);
|
||||||
const loadError = ref("");
|
const loadError = ref("");
|
||||||
|
|
||||||
// The global `c` shortcut bumps composeTick; reopen the composer when we're
|
// The global `c` shortcut bumps composeTick; reopen the composer when we're
|
||||||
// already on the board (a fresh navigation autofocuses it via the prop).
|
// already on the board (a fresh navigation autofocuses it via the prop).
|
||||||
watch(
|
watch(
|
||||||
() => ui.composeTick,
|
() => ui.composeTick,
|
||||||
() => quickAdd.value?.open(),
|
() => composer.value?.open(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The command palette opens a note by navigating here with ?open=<id>.
|
// The command palette opens a note by navigating here with ?open=<id>.
|
||||||
@@ -175,7 +174,7 @@ async function onDrop(target: Note) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||||
<QuickAdd v-if="isMainBoard" ref="quickAdd" autofocus class="mb-8" />
|
<NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-8" />
|
||||||
|
|
||||||
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user