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.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.SolidColor
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -99,24 +100,45 @@ fun BlockBody(
readOnly = readOnly, readOnly = readOnly,
requester = requester, requester = requester,
onChange = { replace(index, it) }, 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 @Composable
private fun ProseBlock( private fun ProseBlock(
block: EditorBlock, block: EditorBlock,
readOnly: Boolean, readOnly: Boolean,
requester: FocusRequester, requester: FocusRequester,
onChange: (EditorBlock) -> Unit, onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) { ) {
BlockField( BlockField(
value = block.value, value = block.value,
onValueChange = { onChange(block.copy(value = it)) }, onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.focusRequester(requester), modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly, enabled = !readOnly,
hint = R.string.editor_body_hint, 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 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. * 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, type EditorBlock,
joinBlocks, joinBlocks,
plusTask, plusTask,
promoteTasks,
splitBlocks, splitBlocks,
withoutIndex, withoutIndex,
} from "../notes/blocks"; } from "../notes/blocks";
@@ -285,6 +286,23 @@ function onProseInput(index: number, e: Event): void {
grow(el); 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). */ /** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void { function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) { 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" class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)" @input="onProseInput(i, $event)"
@keydown="onProseKeydown" @keydown="onProseKeydown"
@blur="onProseBlur(i)"
/> />
</template> </template>
</div> </div>
+32
View File
@@ -108,3 +108,35 @@ export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus:
const id = nextId(blocks); const id = nextId(blocks);
return { blocks: [...blocks, { id, text: "", checked: false }], focus: id }; 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)];
}