Files
inkwell/frontend/src/notes/blocks.ts
T
bvandeusenandClaude Opus 5.5 888c6410f0
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 2m0s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m4s
all: trim the history essays out of the longest comments
From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:03:44 -04:00

135 lines
5.6 KiB
TypeScript

// 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 };
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
* typed in the editor becomes an item without closing and reopening the note.
*
* On blur, and only the block being left: re-splitting on a keystroke would move the
* caret, and converting the moment `- [ ]` is complete would catch an item with no
* text yet.
*
* Returns THE SAME ARRAY when there was nothing to promote; the caller relies on that
* to leave the ref alone, so a blur that changed nothing doesn't re-key every field.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
* reopen.
*/
export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] {
const block = blocks[index];
if (!block || block.checked !== null) return blocks;
const split = splitBlocks(block.text, nextId(blocks));
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty array, so `split[0]` is safe.
const changed = split.length > 1 || split[0].checked !== null;
return changed ? [...blocks.slice(0, index), ...split, ...blocks.slice(index + 1)] : blocks;
}