Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user