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 { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
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
|
// 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,
|
// "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 notes = useNotesStore();
|
||||||
|
|
||||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
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 color = ref<NoteColor>(props.note?.color ?? "default");
|
||||||
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||||
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
|
// 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.
|
// on when the note already carries items, and when someone asks for one.
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const root = ref<HTMLElement | null>(null);
|
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 fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
|
|
||||||
@@ -80,7 +122,7 @@ watch(
|
|||||||
() => props.note,
|
() => props.note,
|
||||||
(n) => {
|
(n) => {
|
||||||
noteId.value = n?.id ?? null;
|
noteId.value = n?.id ?? null;
|
||||||
body.value = n?.body ?? "";
|
setBody(n?.body ?? "");
|
||||||
color.value = (n?.color ?? "default") as NoteColor;
|
color.value = (n?.color ?? "default") as NoteColor;
|
||||||
labelList.value = n ? [...n.labels] : [];
|
labelList.value = n ? [...n.labels] : [];
|
||||||
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||||
@@ -131,7 +173,7 @@ async function flush(): Promise<void> {
|
|||||||
|
|
||||||
function resetCompose(): void {
|
function resetCompose(): void {
|
||||||
noteId.value = null;
|
noteId.value = null;
|
||||||
body.value = "";
|
setBody("");
|
||||||
color.value = "default";
|
color.value = "default";
|
||||||
labelList.value = [];
|
labelList.value = [];
|
||||||
baseline.value = { body: "", color: "default" };
|
baseline.value = { body: "", color: "default" };
|
||||||
@@ -145,7 +187,9 @@ async function commitAndContinue(): Promise<void> {
|
|||||||
await flush();
|
await flush();
|
||||||
resetCompose();
|
resetCompose();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
bodyInput.value?.focus();
|
growAll();
|
||||||
|
const first = blocks.value[0];
|
||||||
|
await focusBlock(first ? first.id : null);
|
||||||
}
|
}
|
||||||
// ---- open/close animation (M7) ----
|
// ---- open/close animation (M7) ----
|
||||||
//
|
//
|
||||||
@@ -215,53 +259,66 @@ function onBackdropMousedown(): void {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const el = bodyInput.value;
|
growAll();
|
||||||
el?.focus();
|
// The LAST block, with the caret after its text: opening a note means continuing it,
|
||||||
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
|
// and type-to-compose seeds text that should be typed straight on from.
|
||||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
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
|
/** Editing one block: replace its text, leave every other block alone. */
|
||||||
// put back. See that file for why three implementations of it exist.
|
function setText(index: number, text: string): void {
|
||||||
const TASK_LINE_RE = /^(\s*)([-*]) +\[([ xX])\](?: +(.*))?$/;
|
const out = [...blocks.value];
|
||||||
|
out[index] = { ...out[index], text };
|
||||||
|
blocks.value = out;
|
||||||
|
}
|
||||||
|
|
||||||
function onBodyKeydown(e: KeyboardEvent) {
|
function setChecked(index: number, checked: boolean): void {
|
||||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
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) {
|
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void commitAndContinue();
|
void commitAndContinue();
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on a task line starts the next one; on an EMPTY task line it clears the
|
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
|
||||||
// marker instead. Both halves are needed — without the second, a list would be
|
function onTaskEnter(index: number): void {
|
||||||
// impossible to get out of without deleting characters by hand.
|
const next = afterEnter(blocks.value, index);
|
||||||
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
blocks.value = next.blocks;
|
||||||
const el = e.target as HTMLTextAreaElement;
|
void focusBlock(next.focus);
|
||||||
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;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
e.preventDefault();
|
||||||
if (!(match[4] ?? "").trim()) {
|
removeBlock(index);
|
||||||
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.
|
function removeBlock(index: number): void {
|
||||||
const marker = `${match[1]}${match[2]} [ ] `;
|
const next = withoutIndex(blocks.value, index);
|
||||||
body.value = `${before}\n${marker}${rest}`;
|
blocks.value = next.blocks;
|
||||||
const caret = start + 1 + marker.length;
|
void focusBlock(next.focus);
|
||||||
void nextTick(() => el.setSelectionRange(caret, caret));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- reminder ----
|
// ---- reminder ----
|
||||||
@@ -307,27 +364,17 @@ function labelChip(c: string): string {
|
|||||||
|
|
||||||
// ---- add a checklist ----
|
// ---- add a checklist ----
|
||||||
//
|
//
|
||||||
// Inserts `- [ ] ` at the caret. That is the whole action now: a checklist is lines of
|
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
|
||||||
// the body (M304), so unlike every other toolbar button this one needs NO persisted
|
// this one needs NO persisted note to hang anything off — a checklist is part of the
|
||||||
// note to hang anything off — `ensureDraft` is gone from it, and it works on an empty
|
// body (M304), so it works on an empty compose box the moment it opens.
|
||||||
// compose box the moment it opens.
|
//
|
||||||
async function addChecklist() {
|
// Appends rather than inserting at the caret because a block editor has no single
|
||||||
const el = bodyInput.value;
|
// caret to insert at: the field that had focus may not be the one being looked at by
|
||||||
const marker = "- [ ] ";
|
// the time this runs.
|
||||||
if (!el) {
|
function addChecklist(): void {
|
||||||
body.value = body.value ? `${body.value.replace(/\n+$/, "")}\n${marker}` : marker;
|
const next = plusTask(blocks.value);
|
||||||
return;
|
blocks.value = next.blocks;
|
||||||
}
|
void focusBlock(next.focus);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- attachments ----
|
// ---- attachments ----
|
||||||
@@ -406,7 +453,7 @@ async function restoreRevisionAt(revId: string) {
|
|||||||
const id = noteId.value;
|
const id = noteId.value;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const updated = await notes.restoreRevision(id, revId);
|
const updated = await notes.restoreRevision(id, revId);
|
||||||
body.value = updated.body;
|
setBody(updated.body);
|
||||||
color.value = updated.color;
|
color.value = updated.color;
|
||||||
baseline.value = { body: updated.body, color: updated.color };
|
baseline.value = { body: updated.body, color: updated.color };
|
||||||
void loadRevisions(); // the pre-restore state became a new revision
|
void loadRevisions(); // the pre-restore state became a new revision
|
||||||
@@ -509,14 +556,50 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<!-- The body, as fields and checkboxes rather than as markup. A checklist
|
||||||
ref="bodyInput"
|
item is a real input; a run of prose is one textarea, so typing a
|
||||||
v-model="body"
|
paragraph still feels like typing a paragraph. -->
|
||||||
rows="8"
|
<div class="flex flex-col gap-1">
|
||||||
:placeholder="bodyPlaceholder"
|
<template v-for="(block, i) in blocks" :key="block.id">
|
||||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
|
||||||
@keydown="onBodyKeydown"
|
<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
|
<!-- No checklist component. The items are lines of the textarea above, which
|
||||||
is what lets a list sit between two paragraphs (M304). -->
|
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.
|
// which is at least consistent.
|
||||||
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
|
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[] {
|
export function parseMarkdown(text: string): Block[] {
|
||||||
const lines = (text ?? "").split("\n");
|
const lines = (text ?? "").split("\n");
|
||||||
const blocks: Block[] = [];
|
const blocks: Block[] = [];
|
||||||
@@ -124,15 +145,15 @@ export function parseMarkdown(text: string): Block[] {
|
|||||||
// Task list, BEFORE the plain bullet below — which would otherwise swallow
|
// Task list, BEFORE the plain bullet below — which would otherwise swallow
|
||||||
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
|
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
|
||||||
// ordering reason as `code` being matched before emphasis in INLINE_RE.
|
// ordering reason as `code` being matched before emphasis in INLINE_RE.
|
||||||
if (TASK_RE.test(line)) {
|
if (parseTaskLine(line)) {
|
||||||
flushPara();
|
flushPara();
|
||||||
const items: InlineToken[][] = [];
|
const items: InlineToken[][] = [];
|
||||||
const tasks: TaskMeta[] = [];
|
const tasks: TaskMeta[] = [];
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const m = TASK_RE.exec(lines[i]);
|
const task = parseTaskLine(lines[i]);
|
||||||
if (!m) break;
|
if (!task) break;
|
||||||
items.push(parseInline(m[2] ?? ""));
|
items.push(parseInline(task.text));
|
||||||
tasks.push({ index: taskIndex, checked: m[1] !== " " });
|
tasks.push({ index: taskIndex, checked: task.checked });
|
||||||
taskIndex++;
|
taskIndex++;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user