editor: a - [ ] typed by hand becomes a real item when you leave the line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s

`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 with itself. (#3024)

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.

`promotingTasks` / `promoteTasks` return the SAME list when there was nothing
to promote, and both call sites compare by identity. Without that, every blur
would re-key every field below it — including the blur that fires on first
composition, before a field has ever held focus.

Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror
of EditorBlock.kt, and the reason that mirror is worth keeping is that the two
editors behave identically. Fixing one would spend its whole value.

Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only
case where this changes the body rather than just how it is drawn, and exactly
what reopening the note already did.

No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it
needs the native library and cannot run in the JVM lane. No existing Android
test touches the core for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:50:43 -04:00
co-authored by Claude Opus 5
parent e7af7a4b77
commit 1a49ae7ea9
4 changed files with 107 additions and 2 deletions
@@ -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)];
}