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
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>
135 lines
5.6 KiB
TypeScript
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;
|
|
}
|