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.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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user