diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt index c1b2ed7..f1935c9 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlock.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlock.kt index 68207a1..1309dc7 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlock.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlock.kt @@ -142,6 +142,38 @@ fun List.plusTask(): Pair, 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.promotingTasks(index: Int): List { + 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. * diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index 6478ac4..25ba158 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -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)" /> diff --git a/frontend/src/notes/blocks.ts b/frontend/src/notes/blocks.ts index 9b8eb10..d6851c2 100644 --- a/frontend/src/notes/blocks.ts +++ b/frontend/src/notes/blocks.ts @@ -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)]; +}