Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -10,6 +10,14 @@ import { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import {
|
||||
afterEnter,
|
||||
type EditorBlock,
|
||||
joinBlocks,
|
||||
plusTask,
|
||||
splitBlocks,
|
||||
withoutIndex,
|
||||
} from "../notes/blocks";
|
||||
|
||||
// One modal editor for BOTH composing and editing — a single surface (the board's
|
||||
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
|
||||
@@ -24,7 +32,14 @@ const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void
|
||||
const notes = useNotesStore();
|
||||
|
||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||
const body = ref(props.note?.body ?? props.initialBody);
|
||||
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
|
||||
// and a widget cannot live inside a <textarea>. The note is still one markdown body —
|
||||
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
|
||||
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
|
||||
const body = computed(() => joinBlocks(blocks.value));
|
||||
function setBody(text: string): void {
|
||||
blocks.value = splitBlocks(text);
|
||||
}
|
||||
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||||
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
|
||||
@@ -32,7 +47,34 @@ const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||
// on when the note already carries items, and when someone asks for one.
|
||||
const saving = ref(false);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
// One element per block, keyed by the block's id — the only thing about a block that
|
||||
// survives one being inserted above it. Focus is asked for by id and honoured after
|
||||
// the render that created the field, since an element that does not exist yet cannot
|
||||
// take it.
|
||||
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
|
||||
function setBlockEl(id: number, el: unknown): void {
|
||||
if (el) blockEls.set(id, el as HTMLTextAreaElement);
|
||||
else blockEls.delete(id);
|
||||
}
|
||||
async function focusBlock(id: number | null): Promise<void> {
|
||||
if (id === null) return;
|
||||
await nextTick();
|
||||
const el = blockEls.get(id);
|
||||
el?.focus();
|
||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||||
}
|
||||
|
||||
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
|
||||
* which is wrong the moment a line wraps. */
|
||||
function grow(el: HTMLTextAreaElement): void {
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
function growAll(): void {
|
||||
for (const el of blockEls.values()) {
|
||||
if (el instanceof HTMLTextAreaElement) grow(el);
|
||||
}
|
||||
}
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadError = ref("");
|
||||
|
||||
@@ -80,7 +122,7 @@ watch(
|
||||
() => props.note,
|
||||
(n) => {
|
||||
noteId.value = n?.id ?? null;
|
||||
body.value = n?.body ?? "";
|
||||
setBody(n?.body ?? "");
|
||||
color.value = (n?.color ?? "default") as NoteColor;
|
||||
labelList.value = n ? [...n.labels] : [];
|
||||
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||
@@ -131,7 +173,7 @@ async function flush(): Promise<void> {
|
||||
|
||||
function resetCompose(): void {
|
||||
noteId.value = null;
|
||||
body.value = "";
|
||||
setBody("");
|
||||
color.value = "default";
|
||||
labelList.value = [];
|
||||
baseline.value = { body: "", color: "default" };
|
||||
@@ -145,7 +187,9 @@ async function commitAndContinue(): Promise<void> {
|
||||
await flush();
|
||||
resetCompose();
|
||||
await nextTick();
|
||||
bodyInput.value?.focus();
|
||||
growAll();
|
||||
const first = blocks.value[0];
|
||||
await focusBlock(first ? first.id : null);
|
||||
}
|
||||
// ---- open/close animation (M7) ----
|
||||
//
|
||||
@@ -215,53 +259,66 @@ function onBackdropMousedown(): void {
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const el = bodyInput.value;
|
||||
el?.focus();
|
||||
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
|
||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||||
growAll();
|
||||
// The LAST block, with the caret after its text: opening a note means continuing it,
|
||||
// and type-to-compose seeds text that should be typed straight on from.
|
||||
const last = blocks.value[blocks.value.length - 1];
|
||||
await focusBlock(last ? last.id : null);
|
||||
});
|
||||
|
||||
// The same grammar as notes/markdown.ts, narrowed to one line so the pieces can be
|
||||
// put back. See that file for why three implementations of it exist.
|
||||
const TASK_LINE_RE = /^(\s*)([-*]) +\[([ xX])\](?: +(.*))?$/;
|
||||
/** Editing one block: replace its text, leave every other block alone. */
|
||||
function setText(index: number, text: string): void {
|
||||
const out = [...blocks.value];
|
||||
out[index] = { ...out[index], text };
|
||||
blocks.value = out;
|
||||
}
|
||||
|
||||
function onBodyKeydown(e: KeyboardEvent) {
|
||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||
function setChecked(index: number, checked: boolean): void {
|
||||
const out = [...blocks.value];
|
||||
out[index] = { ...out[index], checked };
|
||||
blocks.value = out;
|
||||
}
|
||||
|
||||
function onProseInput(index: number, e: Event): void {
|
||||
const el = e.target as HTMLTextAreaElement;
|
||||
setText(index, el.value);
|
||||
grow(el);
|
||||
}
|
||||
|
||||
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
|
||||
function onProseKeydown(e: KeyboardEvent): void {
|
||||
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void commitAndContinue();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on a task line starts the next one; on an EMPTY task line it clears the
|
||||
// marker instead. Both halves are needed — without the second, a list would be
|
||||
// impossible to get out of without deleting characters by hand.
|
||||
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const el = e.target as HTMLTextAreaElement;
|
||||
const start = el.selectionStart ?? 0;
|
||||
// A selection means Enter is replacing something; let the browser do it.
|
||||
if (start !== el.selectionEnd) return;
|
||||
const before = body.value.slice(0, start);
|
||||
const rest = body.value.slice(start);
|
||||
// Only at the END of a line. Mid-line, Enter splits the line, which is what anyone
|
||||
// pressing it there meant.
|
||||
if (rest !== "" && !rest.startsWith("\n")) return;
|
||||
const lineStart = before.lastIndexOf("\n") + 1;
|
||||
const match = TASK_LINE_RE.exec(before.slice(lineStart));
|
||||
if (!match) return;
|
||||
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
|
||||
function onTaskEnter(index: number): void {
|
||||
const next = afterEnter(blocks.value, index);
|
||||
blocks.value = next.blocks;
|
||||
void focusBlock(next.focus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backspace at the very start of an EMPTY item removes it.
|
||||
*
|
||||
* Worth having on the web where Android's is not: a browser sends a real keydown for
|
||||
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
|
||||
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
|
||||
* only one of them can offer.
|
||||
*/
|
||||
function onTaskBackspace(index: number, e: KeyboardEvent): void {
|
||||
const el = e.target as HTMLInputElement;
|
||||
if (el.value !== "" || el.selectionStart !== 0) return;
|
||||
e.preventDefault();
|
||||
if (!(match[4] ?? "").trim()) {
|
||||
body.value = body.value.slice(0, lineStart) + rest;
|
||||
void nextTick(() => el.setSelectionRange(lineStart, lineStart));
|
||||
return;
|
||||
}
|
||||
// The bullet and indent are carried over, not normalised: continuing someone's
|
||||
// `*` list with a `-` would be an edit they did not ask for.
|
||||
const marker = `${match[1]}${match[2]} [ ] `;
|
||||
body.value = `${before}\n${marker}${rest}`;
|
||||
const caret = start + 1 + marker.length;
|
||||
void nextTick(() => el.setSelectionRange(caret, caret));
|
||||
removeBlock(index);
|
||||
}
|
||||
|
||||
function removeBlock(index: number): void {
|
||||
const next = withoutIndex(blocks.value, index);
|
||||
blocks.value = next.blocks;
|
||||
void focusBlock(next.focus);
|
||||
}
|
||||
|
||||
// ---- reminder ----
|
||||
@@ -307,27 +364,17 @@ function labelChip(c: string): string {
|
||||
|
||||
// ---- add a checklist ----
|
||||
//
|
||||
// Inserts `- [ ] ` at the caret. That is the whole action now: a checklist is lines of
|
||||
// the body (M304), so unlike every other toolbar button this one needs NO persisted
|
||||
// note to hang anything off — `ensureDraft` is gone from it, and it works on an empty
|
||||
// compose box the moment it opens.
|
||||
async function addChecklist() {
|
||||
const el = bodyInput.value;
|
||||
const marker = "- [ ] ";
|
||||
if (!el) {
|
||||
body.value = body.value ? `${body.value.replace(/\n+$/, "")}\n${marker}` : marker;
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? body.value.length;
|
||||
const before = body.value.slice(0, start);
|
||||
// Start a new line unless the caret already sits at the beginning of one — a marker
|
||||
// in the middle of a sentence is not a list item, it is a typo.
|
||||
const prefix = before === "" || before.endsWith("\n") ? "" : "\n";
|
||||
body.value = `${before}${prefix}${marker}${body.value.slice(start)}`;
|
||||
const caret = start + prefix.length + marker.length;
|
||||
await nextTick();
|
||||
el.focus();
|
||||
el.setSelectionRange(caret, caret);
|
||||
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
|
||||
// this one needs NO persisted note to hang anything off — a checklist is part of the
|
||||
// body (M304), so it works on an empty compose box the moment it opens.
|
||||
//
|
||||
// Appends rather than inserting at the caret because a block editor has no single
|
||||
// caret to insert at: the field that had focus may not be the one being looked at by
|
||||
// the time this runs.
|
||||
function addChecklist(): void {
|
||||
const next = plusTask(blocks.value);
|
||||
blocks.value = next.blocks;
|
||||
void focusBlock(next.focus);
|
||||
}
|
||||
|
||||
// ---- attachments ----
|
||||
@@ -406,7 +453,7 @@ async function restoreRevisionAt(revId: string) {
|
||||
const id = noteId.value;
|
||||
if (!id) return;
|
||||
const updated = await notes.restoreRevision(id, revId);
|
||||
body.value = updated.body;
|
||||
setBody(updated.body);
|
||||
color.value = updated.color;
|
||||
baseline.value = { body: updated.body, color: updated.color };
|
||||
void loadRevisions(); // the pre-restore state became a new revision
|
||||
@@ -509,14 +556,50 @@ function revPreview(rev: NoteRevision): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<!-- The body, as fields and checkboxes rather than as markup. A checklist
|
||||
item is a real input; a run of prose is one textarea, so typing a
|
||||
paragraph still feels like typing a paragraph. -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<template v-for="(block, i) in blocks" :key="block.id">
|
||||
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 shrink-0 accent-brand"
|
||||
:checked="block.checked"
|
||||
:aria-label="block.text || 'Checklist item'"
|
||||
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => setBlockEl(block.id, el)"
|
||||
:value="block.text"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
|
||||
:class="block.checked ? 'text-neutral-400 line-through' : ''"
|
||||
@input="setText(i, ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter.prevent="onTaskEnter(i)"
|
||||
@keydown.backspace="onTaskBackspace(i, $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
|
||||
aria-label="Delete item"
|
||||
@click="removeBlock(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
:ref="(el) => setBlockEl(block.id, el)"
|
||||
:value="block.text"
|
||||
rows="1"
|
||||
:placeholder="i === 0 ? bodyPlaceholder : ''"
|
||||
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@input="onProseInput(i, $event)"
|
||||
@keydown="onProseKeydown"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<!-- No checklist component. The items are lines of the textarea above, which
|
||||
is what lets a list sit between two paragraphs (M304). -->
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// The editor's shape for a note body: a list of blocks rather than one string.
|
||||
//
|
||||
// The note is still one markdown body underneath (M304) — this is a rendering and
|
||||
// input shape, and nothing below the editor can tell it exists. `joinBlocks` puts the
|
||||
// string back together on every edit, and for a body already in canonical form it
|
||||
// returns exactly what `splitBlocks` was handed.
|
||||
//
|
||||
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and
|
||||
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one,
|
||||
// and that is a different editor with a different set of problems.
|
||||
//
|
||||
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
|
||||
// like typing a paragraph, and a separate field under every sentence would break the
|
||||
// caret mid-sentence. Only a checklist item earns a block, because only a checklist
|
||||
// item needs a widget.
|
||||
//
|
||||
// The mirror of android/.../ui/EditorBlock.kt, deliberately: the two editors should
|
||||
// behave the same, and the cheapest way to keep them that way is for the shapes to
|
||||
// read alike.
|
||||
|
||||
import { parseTaskLine, renderTaskLine } from "./markdown";
|
||||
|
||||
export interface EditorBlock {
|
||||
/** Stable across edits, so Vue keeps a field's caret when a block is inserted above
|
||||
* it. Content cannot serve as the key — two empty items are identical and neither
|
||||
* is the other. */
|
||||
id: number;
|
||||
text: string;
|
||||
/** null for prose; ticked-or-not for a checklist item. */
|
||||
checked: boolean | null;
|
||||
}
|
||||
|
||||
/** Split a body into blocks, numbering them from `firstId`. */
|
||||
export function splitBlocks(body: string, firstId = 0): EditorBlock[] {
|
||||
const out: EditorBlock[] = [];
|
||||
const prose: string[] = [];
|
||||
let id = firstId;
|
||||
|
||||
const flushProse = () => {
|
||||
if (prose.length) {
|
||||
out.push({ id: id++, text: prose.join("\n"), checked: null });
|
||||
prose.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of (body ?? "").split("\n")) {
|
||||
const task = parseTaskLine(line);
|
||||
if (task) {
|
||||
flushProse();
|
||||
out.push({ id: id++, text: task.text, checked: task.checked });
|
||||
} else {
|
||||
prose.push(line);
|
||||
}
|
||||
}
|
||||
flushProse();
|
||||
|
||||
// Never empty: an empty note still needs one field to type into.
|
||||
return out.length ? out : [{ id, text: "", checked: null }];
|
||||
}
|
||||
|
||||
/** The body those blocks stand for. */
|
||||
export function joinBlocks(blocks: EditorBlock[]): string {
|
||||
return blocks.map((b) => (b.checked === null ? b.text : renderTaskLine(b.text, b.checked))).join("\n");
|
||||
}
|
||||
|
||||
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
|
||||
export function nextId(blocks: EditorBlock[]): number {
|
||||
return blocks.reduce((max, b) => Math.max(max, b.id), -1) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Enter does on a checklist item, and which block should hold the caret after.
|
||||
*
|
||||
* On an item with words in it, a new empty item below. On an EMPTY one, the item
|
||||
* becomes prose — which is how a list ENDS, and the only way to get a paragraph after
|
||||
* one. Without that half a list is impossible to get out of.
|
||||
*
|
||||
* Appends rather than splitting at the caret: splitting an item in two is a rarity,
|
||||
* and the caret is at the end for every ordinary use of that key.
|
||||
*/
|
||||
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
|
||||
const block = blocks[index];
|
||||
const out = [...blocks];
|
||||
if (!block.text.trim()) {
|
||||
out[index] = { ...block, text: "", checked: null };
|
||||
return { blocks: out, focus: block.id };
|
||||
}
|
||||
const id = nextId(blocks);
|
||||
out.splice(index + 1, 0, { id, text: "", checked: false });
|
||||
return { blocks: out, focus: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a block, leaving at least one field to type into.
|
||||
*
|
||||
* Focus goes to the block above — or, when the first one was removed, to whichever
|
||||
* takes its place. `index - 1` alone is -1 there, which would leave nothing focused.
|
||||
*/
|
||||
export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } {
|
||||
const kept = blocks.filter((_, i) => i !== index);
|
||||
const fallback: EditorBlock[] = [{ id: nextId(blocks), text: "", checked: null }];
|
||||
const remaining = kept.length ? kept : fallback;
|
||||
return { blocks: remaining, focus: remaining[Math.max(0, index - 1)]?.id ?? null };
|
||||
}
|
||||
|
||||
/** One more empty checklist item at the end, and the id to put the caret in. */
|
||||
export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus: number } {
|
||||
const id = nextId(blocks);
|
||||
return { blocks: [...blocks, { id, text: "", checked: false }], focus: id };
|
||||
}
|
||||
@@ -66,6 +66,27 @@ export function parseInline(text: string): InlineToken[] {
|
||||
// which is at least consistent.
|
||||
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
|
||||
|
||||
export interface TaskLine {
|
||||
checked: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One task line's parts, or null when the line is prose.
|
||||
*
|
||||
* Exported so the EDITOR's block split (notes/blocks.ts) and this read-view parser
|
||||
* agree by construction rather than by comment. One grammar, one matcher. */
|
||||
export function parseTaskLine(line: string): TaskLine | null {
|
||||
const m = TASK_RE.exec(line);
|
||||
return m ? { checked: m[1] !== " ", text: m[2] ?? "" } : null;
|
||||
}
|
||||
|
||||
/** One item as the body line that stores it, in canonical form — `- [x] `, lowercase,
|
||||
* and no trailing space when the item is empty so a round trip does not grow it. */
|
||||
export function renderTaskLine(text: string, checked: boolean): string {
|
||||
const mark = checked ? "x" : " ";
|
||||
return text ? `- [${mark}] ${text}` : `- [${mark}]`;
|
||||
}
|
||||
|
||||
export function parseMarkdown(text: string): Block[] {
|
||||
const lines = (text ?? "").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
@@ -124,15 +145,15 @@ export function parseMarkdown(text: string): Block[] {
|
||||
// Task list, BEFORE the plain bullet below — which would otherwise swallow
|
||||
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
|
||||
// ordering reason as `code` being matched before emphasis in INLINE_RE.
|
||||
if (TASK_RE.test(line)) {
|
||||
if (parseTaskLine(line)) {
|
||||
flushPara();
|
||||
const items: InlineToken[][] = [];
|
||||
const tasks: TaskMeta[] = [];
|
||||
while (i < lines.length) {
|
||||
const m = TASK_RE.exec(lines[i]);
|
||||
if (!m) break;
|
||||
items.push(parseInline(m[2] ?? ""));
|
||||
tasks.push({ index: taskIndex, checked: m[1] !== " " });
|
||||
const task = parseTaskLine(lines[i]);
|
||||
if (!task) break;
|
||||
items.push(parseInline(task.text));
|
||||
tasks.push({ index: taskIndex, checked: task.checked });
|
||||
taskIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user