Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
4 changed files with 107 additions and 2 deletions
Showing only changes of commit 1a49ae7ea9 - Show all commits
@@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -99,24 +100,45 @@ fun BlockBody(
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
// Compared by IDENTITY, not equality: `promotingTasks` hands
// back the same list when there was nothing to promote, and a
// blur that changed nothing must not touch the state at all.
val promoted = blocks.promotingTasks(index)
if (promoted !== blocks) onChange(promoted)
},
)
}
}
}
}
/** A run of prose: one ordinary multi-line field, exactly as the editor always had. */
/**
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
*
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
* [promotingTasks] for why blur is the only safe moment to do that.
*
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
* the promotion is a no-op that the caller's identity check drops on the floor.
*/
@Composable
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.focusRequester(requester),
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
@@ -142,6 +142,38 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
return (this + EditorBlock(id, TextFieldValue(""), false)) to 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 THIS LIST, not an equal copy, when there was nothing to promote — the caller
* leans on that to leave the state 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.
*/
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index) ?: return this
if (block.isTask) return this
val split = splitBlocks(block.value.text, nextId())
if (split.size == 1 && !split.first().isTask) return this
return take(index) + split + drop(index + 1)
}
/**
* Put the caret at the end of the last block, for an editor that has just opened.
*
+19
View File
@@ -15,6 +15,7 @@ import {
type EditorBlock,
joinBlocks,
plusTask,
promoteTasks,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
@@ -285,6 +286,23 @@ function onProseInput(index: number, e: Event): void {
grow(el);
}
/**
* Leaving a prose block is when a `- [ ] ` typed by hand becomes a real item.
*
* See notes/blocks.ts for why blur is the only safe moment. The identity check is the
* contract `promoteTasks` offers: an untouched array back means nothing to promote, and
* reassigning the ref anyway would re-key every field below this one for no reason.
*
* `growAll` after the DOM settles, because the textarea being left is now shorter by
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
void nextTick().then(growAll);
}
/** 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) {
@@ -600,6 +618,7 @@ function revPreview(rev: NoteRevision): string {
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"
@blur="onProseBlur(i)"
/>
</template>
</div>
+32
View File
@@ -108,3 +108,35 @@ export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus:
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));
if (split.length === 1 && split[0].checked === null) return blocks;
return [...blocks.slice(0, index), ...split, ...blocks.slice(index + 1)];
}