CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
`promotingTasks` had four returns against ReturnCount's limit of two — three of them the same `return this`. Collapsed into a null-or-task guard and a `changed` flag, which says the contract more plainly anyway: the list comes back untouched unless something was actually promoted. Mirrored in blocks.ts even though nothing lints it there. The two files are kept line-by-line alike on purpose, and letting them drift on shape is how the next person stops trusting that reading one tells you the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
6.3 KiB
TypeScript
145 lines
6.3 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.
|
|
*
|
|
* `splitBlocks` runs once, when the editor opens. After that the blocks are the state
|
|
* and nothing reads the body again — every edit travels the other way, through
|
|
* `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note
|
|
* was closed and reopened, even though it was already a real item in storage and the
|
|
* card was already drawing a checkbox for it. The editor was the only place that
|
|
* disagreed.
|
|
*
|
|
* ON BLUR, and only the block being left. There is no good moment to convert while
|
|
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
|
|
* written, and converting the instant `- [ ]` is complete does it before the item has
|
|
* any text. Blur is the one moment the person has demonstrably finished with the block,
|
|
* so a re-split costs no caret and cannot catch a half-typed line.
|
|
*
|
|
* Returns THE SAME ARRAY, not an equal copy, when there was nothing to promote — the
|
|
* caller leans on that to leave the ref alone, and a blur that changed nothing must not
|
|
* re-key every field below it.
|
|
*
|
|
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
|
|
* would have on reopen. That is the only case where this changes the body rather than
|
|
* only the way it is drawn.
|
|
*/
|
|
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;
|
|
}
|