From 24667fa3b24b874c63f950d4700500edfabe04e7 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:35:36 +0300 Subject: [PATCH 01/19] docs: design multiline todo editing --- .impeccable.md | 21 + ...2026-07-16-multiline-todo-composer-edit.md | 641 ++++++++++++++++++ ...-16-multiline-todo-composer-edit-design.md | 85 +++ 3 files changed, 747 insertions(+) create mode 100644 .impeccable.md create mode 100644 docs/superpowers/plans/2026-07-16-multiline-todo-composer-edit.md create mode 100644 docs/superpowers/specs/2026-07-16-multiline-todo-composer-edit-design.md diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..18264ce --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,21 @@ +## Design Context + +### Users + +BusyWeek is for people doing quick, date-aware personal planning, primarily on a phone and secondarily in a desktop browser. They need to capture a thought quickly, see the whole wording later, mark it complete, and move it to another date without learning a project-management system. + +### Brand Personality + +Playful, energetic, and reassuring. The bilingual “BusyWeek! / 好忙啊” identity should make a busy schedule feel approachable rather than corporate or anxious. + +### Aesthetic Direction + +Preserve the product's recognizable Material-era blue gradient, white date cards, coral add action, rounded touch targets, and light motion. Native iOS is the primary interaction reference; Web is a faithful responsive companion rather than a separate redesign. New work should feel like a careful continuation of the existing app, not a generic modern productivity dashboard. + +### Design Principles + +1. Make capture immediate: opening, typing, choosing a date, and saving should stay within thumb reach. +2. Preserve the user's words: Todo rows expand to show the complete text instead of truncating it to an arbitrary line count. +3. Let motion explain change: inserts, removals, filtering, and date moves should visibly carry surrounding rows into their new positions. +4. Prefer deliberate touch interactions: destructive and edit actions must not collide with checkbox taps or scrolling; long press is reserved for full Todo editing. +5. Keep one interaction model across Native and Web while adapting measurement, keyboard avoidance, and responsive geometry to each platform. diff --git a/docs/superpowers/plans/2026-07-16-multiline-todo-composer-edit.md b/docs/superpowers/plans/2026-07-16-multiline-todo-composer-edit.md new file mode 100644 index 0000000..aa52af2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-multiline-todo-composer-edit.md @@ -0,0 +1,641 @@ +# Multiline Todo and Composer Editing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render uncapped multiline Todos with smooth variable-height motion, and replace inline editing with a long-press full composer that can edit text and date on Native and Web. + +**Architecture:** Keep data mutation and row geometry in small pure TypeScript modules covered by Node tests. Resolve a stable text-measurement facade to `lynx-pretext` or browser Pretext per Rspeedy environment, then correct its prediction with the renderer's `` event. Reuse one draft-based composer for create/edit, and add a testable Web-only 500ms pointer bridge because the pinned Web Core does not synthesize Lynx `longpress`. + +**Tech Stack:** Vue Lynx 0.4, TypeScript 5.9, Rspeedy/Rsbuild multi-environment aliases, `lynx-pretext@0.0.1`, `@chenglou/pretext@0.0.8`, Node `node:test`, Lynx DevTool, iOS Simulator. + +--- + +## File map + +- Create `src/todoComposer.ts`: immutable create/edit/delete/move domain operations. +- Create `src/todoTextLayout.ts`: pure row-height and event-normalization rules. +- Create `src/textLayoutBackend.lynx.ts`: cached `lynx-pretext` adapter. +- Create `src/textLayoutBackend.web.ts`: capability-guarded cached Canvas Pretext adapter. +- Create `src/text-layout-backend.d.ts`: stable virtual-module contract. +- Create `web/todo-longpress.js`: testable 500ms pointer recognizer and DOM installer. +- Create `tests/todo-editor.test.ts`, `tests/todo-text-layout.test.ts`, `tests/web-longpress.test.ts`. +- Modify `src/App.vue`: shared composer intent/drafts, long press, width probe, renderer correction, and dynamic row styles. +- Modify `src/App.css`: variable-height rows, hidden measurement probe, long-press feedback, and 14px keyboard spacing. +- Modify `src/timelineMotion.ts` and `tests/timeline-motion.test.ts`: prefix-sum variable-height motion. +- Modify `lynx.config.ts`, `package.json`, and `package-lock.json`: environment backends and pinned dependencies. +- Modify `web/index.html` and `scripts/assemble-web.mjs`: install and publish the Web long-press bridge. +- Modify `src/starterTimeline.ts`, `tests/starter-timeline.test.ts`, and `tests/web-regressions.test.ts`: copy and structural regressions. +- Delete `src/nativeInput.ts`, `src/todoKeyboardAvoidance.ts`, `tests/native-input.test.ts`, and `tests/todo-keyboard-avoidance.test.ts`: superseded inline-editor code. + +### Task 1: Pure composer mutations + +**Files:** +- Create: `tests/todo-editor.test.ts` +- Create: `src/todoComposer.ts` + +- [ ] **Step 1: Write failing create/edit/move/delete tests** + +```ts +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + commitComposerDraft, + createComposerDraft, +} from '../src/todoComposer.ts' +import type { Timeline } from '../src/types.ts' + +function fixture(): Timeline { + return { + '2026-07-16': { + date: '2026-07-16', + todos: [{ + id: 'a', date: '2026-07-16', dayType: 0, + done: true, text: 'original', + }], + }, + } +} + +test('edit draft is isolated until save', () => { + const timeline = fixture() + const draft = createComposerDraft(timeline, { + kind: 'edit', todoId: 'a', sourceDate: '2026-07-16', + }, '2026-07-20') + draft.text = 'changed' + assert.equal(timeline['2026-07-16'].todos[0].text, 'original') +}) + +test('moving an edit preserves identity and completion', () => { + const result = commitComposerDraft(fixture(), { + kind: 'edit', todoId: 'a', sourceDate: '2026-07-16', + }, { text: ' moved ', date: '2026-07-18' }, { + today: '2026-07-16', idFactory: () => 'unused', + }) + assert.equal(result['2026-07-16'], undefined) + assert.deepEqual(result['2026-07-18'].todos[0], { + id: 'a', date: '2026-07-18', dayType: 2, + done: true, text: 'moved', + }) +}) +``` + +Add separate tests for same-day order preservation, missing target no-op, blank edit deletion, blank create default text, and cancel (never calling commit) leaving the serialized timeline unchanged. + +- [ ] **Step 2: Run the tests and verify RED** + +Run: `node --test tests/todo-editor.test.ts` + +Expected: module-not-found failure for `src/todoComposer.ts`. + +- [ ] **Step 3: Implement immutable composer helpers** + +```ts +import type { Timeline, Todo } from './types.js' +import { getDateDiff } from './util.js' + +export type ComposerIntent = + | { kind: 'create' } + | { kind: 'edit'; todoId: string; sourceDate: string } + +export interface ComposerDraft { text: string; date: string } +export interface CommitOptions { today: string; idFactory: () => string } + +export function createComposerDraft( + timeline: Timeline, + intent: ComposerIntent, + today: string, +): ComposerDraft { + if (intent.kind === 'create') return { text: '', date: today } + const todo = timeline[intent.sourceDate]?.todos.find( + (item) => item.id === intent.todoId, + ) + return todo ? { text: todo.text, date: todo.date } : { text: '', date: today } +} + +export function commitComposerDraft( + timeline: Timeline, + intent: ComposerIntent, + draft: ComposerDraft, + options: CommitOptions, +): Timeline { + const text = draft.text.trim() + if (intent.kind === 'create') { + const todo: Todo = { + id: options.idFactory(), date: draft.date, + dayType: getDateDiff(draft.date, options.today), + done: false, text: text || '写点啥呀!', + } + const target = timeline[draft.date] + return { + ...timeline, + [draft.date]: { + date: draft.date, + todos: [...(target?.todos ?? []), todo], + }, + } + } + + const source = timeline[intent.sourceDate] + const index = source?.todos.findIndex((item) => item.id === intent.todoId) ?? -1 + if (!source || index < 0) return timeline + const current = source.todos[index] + const without = source.todos.filter((item) => item.id !== current.id) + const next = { ...timeline } + if (without.length) next[intent.sourceDate] = { ...source, todos: without } + else delete next[intent.sourceDate] + if (!text) return next + + const edited: Todo = { + ...current, + text, + date: draft.date, + dayType: getDateDiff(draft.date, options.today), + } + if (draft.date === intent.sourceDate) { + const todos = [...source.todos] + todos[index] = edited + return { ...timeline, [intent.sourceDate]: { ...source, todos } } + } + const target = next[draft.date] + next[draft.date] = { + date: draft.date, + todos: [...(target?.todos ?? []), edited], + } + return next +} +``` + +- [ ] **Step 4: Run focused and full tests** + +Run: `node --test tests/todo-editor.test.ts && node --test tests/*.test.ts` + +Expected: all composer tests and the existing 44-test baseline pass. + +- [ ] **Step 5: Commit the pure domain change** + +```bash +git add src/todoComposer.ts tests/todo-editor.test.ts +git commit -m "feat: add draft-based todo composer mutations" +``` + +### Task 2: Arbitrary row heights and prefix-sum motion + +**Files:** +- Create: `tests/todo-text-layout.test.ts` +- Create: `src/todoTextLayout.ts` +- Modify: `tests/timeline-motion.test.ts` +- Modify: `src/timelineMotion.ts` + +- [ ] **Step 1: Write failing geometry tests** + +```ts +import assert from 'node:assert/strict' +import test from 'node:test' +import { + TODO_MIN_ROW_HEIGHT, + rowHeightFromTextHeight, + rowHeightFromLayoutEvent, +} from '../src/todoTextLayout.ts' + +test('row height has no line-count cap', () => { + assert.equal(rowHeightFromTextHeight(20), 52) + assert.equal(rowHeightFromTextHeight(40), 56) + assert.equal(rowHeightFromTextHeight(80), 96) + assert.equal(rowHeightFromTextHeight(160), 176) +}) + +test('invalid measurements use the minimum', () => { + assert.equal(rowHeightFromTextHeight(Number.NaN), TODO_MIN_ROW_HEIGHT) + assert.equal(rowHeightFromLayoutEvent({ detail: {} }), null) +}) + +test('renderer height corrects a prediction', () => { + assert.equal(rowHeightFromLayoutEvent({ + detail: { lineCount: 4, size: { width: 180, height: 80 } }, + }), 96) +}) +``` + +Update `tests/timeline-motion.test.ts` so rows `a=52`, `b=96`, and `c=72` use cumulative offsets; removing `b` must move the survivor, later day, and total height by exactly 96px. Add invalid/zero/NaN fallback assertions. + +- [ ] **Step 2: Run and verify RED** + +Run: `node --test tests/todo-text-layout.test.ts tests/timeline-motion.test.ts` + +Expected: missing `todoTextLayout.ts` and fixed-height offset assertion failures. + +- [ ] **Step 3: Implement pure row geometry** + +```ts +export const TODO_MIN_ROW_HEIGHT = 52 +export const TODO_TEXT_LINE_HEIGHT = 20 +export const TODO_VERTICAL_CHROME = 16 + +export function rowHeightFromTextHeight(textHeight: number): number { + if (!Number.isFinite(textHeight) || textHeight <= 0) return TODO_MIN_ROW_HEIGHT + return Math.max(TODO_MIN_ROW_HEIGHT, Math.ceil(textHeight) + TODO_VERTICAL_CHROME) +} + +export function rowHeightFromLayoutEvent(event: unknown): number | null { + const detail = (event as { detail?: { lineCount?: unknown; size?: { height?: unknown } } })?.detail + const height = detail?.size?.height + const lineCount = detail?.lineCount + if (typeof lineCount !== 'number' || lineCount < 1) return null + if (typeof height !== 'number' || !Number.isFinite(height) || height <= 0) { + return rowHeightFromTextHeight(lineCount * TODO_TEXT_LINE_HEIGHT) + } + return rowHeightFromTextHeight(height) +} +``` + +- [ ] **Step 4: Convert motion to prefix sums** + +Change the signature to: + +```ts +export function createTimelineMotionLayout( + visibleDays: VisibleDay[], + rowHeights: Readonly> = {}, +): TimelineMotionLayout +``` + +For each Todo, sanitize `rowHeights[id]` to at least 52, assign the current cursor to `todoOffsets[id]`, save the resolved value in `todoHeights[id]`, and advance the cursor. Set `todosHeight` to that cursor and use it in the day/total prefix sum. + +- [ ] **Step 5: Run geometry and full suites GREEN** + +Run: `node --test tests/todo-text-layout.test.ts tests/timeline-motion.test.ts && node --test tests/*.test.ts` + +Expected: all tests pass with mixed row heights and exact removal deltas. + +- [ ] **Step 6: Commit variable-height geometry** + +```bash +git add src/todoTextLayout.ts src/timelineMotion.ts tests/todo-text-layout.test.ts tests/timeline-motion.test.ts +git commit -m "feat: support variable-height todo motion" +``` + +### Task 3: Platform-specific Pretext backends + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `lynx.config.ts` +- Create: `src/text-layout-backend.d.ts` +- Create: `src/textLayoutBackend.lynx.ts` +- Create: `src/textLayoutBackend.web.ts` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Add failing backend/config source regressions** + +Assert that `lynx.config.ts` maps `@busyweek/text-layout-backend$` to the two wrapper files, both wrappers export `measureTodoText`, Web imports `@chenglou/pretext`, Native imports `lynx-pretext`, and the Web wrapper catches missing `OffscreenCanvas`/`Intl.Segmenter` by returning `null`. + +- [ ] **Step 2: Run and verify RED** + +Run: `node --test tests/web-regressions.test.ts` + +Expected: alias/backend assertions fail because no wrappers exist. + +- [ ] **Step 3: Install exact 0.x dependencies** + +Run: `npm install --save-exact lynx-pretext@0.0.1 @chenglou/pretext@0.0.8` + +Expected: both exact versions appear under `dependencies` and in `package-lock.json`. + +- [ ] **Step 4: Add the virtual contract and cached wrappers** + +```ts +declare module '@busyweek/text-layout-backend' { + export interface TodoTextMeasurement { + lineCount: number + textHeight: number + } + export function measureTodoText( + text: string, + width: number, + ): TodoTextMeasurement | null + export function clearTodoTextMeasurementCache(): void +} +``` + +Each wrapper keeps `Map>`, calls `prepare(text, platformFont, { whiteSpace: 'pre-wrap' })` once, and calls `layout(prepared, width, 20)` on each width. It returns `null` on invalid width/result or exceptions. Web first checks Worker `OffscreenCanvas` and `Intl.Segmenter`; Native uses `15px` and Web uses the explicit BusyWeek sans-serif stack. + +- [ ] **Step 5: Configure environment aliases** + +```ts +import { fileURLToPath } from 'node:url' + +const textBackend = (name: string) => + fileURLToPath(new URL(`./src/${name}`, import.meta.url)) + +environments: { + lynx: { resolve: { alias: { + '@busyweek/text-layout-backend$': textBackend('textLayoutBackend.lynx.ts'), + } } }, + web: { resolve: { alias: { + '@busyweek/text-layout-backend$': textBackend('textLayoutBackend.web.ts'), + } } }, +}, +``` + +- [ ] **Step 6: Verify tests, types, both builds, and isolation** + +Run: + +```bash +node --test tests/web-regressions.test.ts +npx tsc -b +npm run build:web +test -s dist/main.lynx.bundle +test -s dist/main.web.bundle +! rg -a "OffscreenCanvas|Emoji_Presentation" dist/main.lynx.bundle +! rg -a "SegmenterPolyfill|getTextInfo\(seg" dist/main.web.bundle +``` + +Expected: tests/types/build pass; both bundles exist; each backend's unique markers are absent from the other platform bundle. + +- [ ] **Step 7: Commit backend isolation** + +```bash +git add package.json package-lock.json lynx.config.ts src/text-layout-backend.d.ts src/textLayoutBackend.lynx.ts src/textLayoutBackend.web.ts tests/web-regressions.test.ts +git commit -m "feat: add native and web text measurement backends" +``` + +### Task 4: Reuse the composer for long-press editing + +**Files:** +- Modify: `src/App.vue` +- Modify: `tests/web-regressions.test.ts` +- Delete: `src/nativeInput.ts` +- Delete: `src/todoKeyboardAvoidance.ts` +- Delete: `tests/native-input.test.ts` +- Delete: `tests/todo-keyboard-avoidance.test.ts` + +- [ ] **Step 1: Replace old inline-edit assertions with failing composer assertions** + +Assert all of the following in `tests/web-regressions.test.ts`: + +```ts +assert.match(appSource, /@longpress\.stop="openTodoEditor\(day\.key, todo\)"/) +assert.doesNotMatch(appSource, /class="todo-body"[^>]*@tap=/) +assert.doesNotMatch(appSource, /({ kind: 'create' }) +const composerText = ref('') +const composerDate = ref(getTodayDate()) +const composerTitle = computed(() => + composerIntent.value.kind === 'edit' ? '编辑事项' : '添加事项', +) +const composerSubmitLabel = computed(() => + composerIntent.value.kind === 'edit' ? '保存' : '添加', +) +``` + +Implement a shared `openComposer(intent)` that creates an isolated draft, assigns text/date before `state = 'INPUT'`, awaits `nextTick`, calls `setComposerValue(composerText.value)`, then focuses. `openCreateComposer()` passes `{kind:'create'}`; `openTodoEditor(dayKey,todo)` passes the stable edit intent. `submitComposer()` calls `commitComposerDraft`, clears stale measured height for the edited id, assigns `timeline.value` once, dismisses the keyboard, closes picker flags, and closes the composer. + +- [ ] **Step 4: Replace template edit interaction** + +The Todo body becomes: + +```vue + + {{ todo.text }} + +``` + +Bind composer textarea/pickers to `composerText`/`composerDate`, render `{{ composerTitle }}` and `{{ composerSubmitLabel }}`, and submit through `submitComposer`. + +- [ ] **Step 5: Remove the superseded inline stack** + +Delete inline edit refs, keyboard generation/timer/spacer, `vFocus`, `startEdit`, `finishEdit`, and `onEdit*`. Simplify the global keyboard listener to update the composer only. Delete the two dead helper modules and their tests; keep `nativeKeyboard.ts` because the full composer still uses it. + +- [ ] **Step 6: Run focused/full tests and typecheck** + +Run: `node --test tests/todo-editor.test.ts tests/web-regressions.test.ts && node --test tests/*.test.ts && npx tsc -b` + +Expected: new edit behavior and all surviving tests pass; TypeScript reports no errors. + +- [ ] **Step 7: Commit the shared editor** + +```bash +git add -A src tests +git commit -m "feat: edit todos in the full composer" +``` + +### Task 5: Integrate prediction, renderer correction, and dynamic CSS + +**Files:** +- Modify: `src/App.vue` +- Modify: `src/App.css` +- Modify: `tests/web-regressions.test.ts` +- Modify: `src/starterTimeline.ts` +- Modify: `tests/starter-timeline.test.ts` + +- [ ] **Step 1: Write failing integration/source regressions** + +Assert a hidden `.todo-width-probe`, probe `layoutchange`, fallback `boundingClientRect`, use of `measureTodoText`, renderer ``, slot height binding, 14px keyboard padding, no fixed `.todo-slot/.todo/.checkbox-hit` 52px height, and starter copy mentioning long-press editing of text/date. + +- [ ] **Step 2: Run and verify RED** + +Run: `node --test tests/starter-timeline.test.ts tests/web-regressions.test.ts` + +Expected: every new measurement/spacing/copy assertion fails against the fixed-height UI. + +- [ ] **Step 3: Add width and height registries in App** + +```ts +const todoTextWidth = ref(0) +const correctedTodoHeights = ref>({}) + +const predictedTodoHeights = computed(() => { + const heights: Record = {} + for (const day of visibleDays.value) { + for (const todo of day.todos) { + const measured = measureTodoText(todo.text, todoTextWidth.value) + heights[todo.id] = correctedTodoHeights.value[todo.id] + ?? rowHeightFromTextHeight(measured?.textHeight ?? 0) + } + } + return heights +}) +``` + +Pass this map to `createTimelineMotionLayout`. `todoSlotStyle` returns both `transform` and `height`; add `todoRowStyle` for the nested row. On a valid text layout event, immutably update only that id. When probe width changes by more than 0.5px, assign the width and clear corrections so the new prediction is used until fresh layout events arrive. + +- [ ] **Step 4: Add exact-width probe and fallback query** + +Mount one transparent absolute Todo row matching the day card geometry, with `@layoutchange` on its body. Normalize `detail.width`/`detail.size.width`; after `nextTick`, query `#todo-width-probe-body` with `boundingClientRect` and a failure callback. If both paths are unavailable, use a conservative 240px fallback so the list renders and renderer events can correct it. + +- [ ] **Step 5: Make CSS variable-height and adjust keyboard rhythm** + +Use inline slot/row heights with: + +```css +.todo-slot { position: absolute; top: 0; left: 0; width: 100%; } +.todo { min-height: 52px; height: 100%; } +.checkbox-hit { min-height: 52px; height: 100%; } +.todo-text { width: 100%; line-height: 20px; word-break: break-word; } +.todo-width-probe { position: absolute; width: 94%; left: 3%; opacity: 0; pointer-events: none; } +.addpage--keyboard .addpage-bottom { padding-bottom: 14px; } +``` + +Remove `.todo-input`, `.todo--editing`, and edit-spacer styles. Add restrained pressed feedback to `.todo-body:active .todo-text` without changing checkbox/delete behavior. + +- [ ] **Step 6: Run all tests, types, and build** + +Run: `node --test tests/*.test.ts && npx tsc -b && npm run build` + +Expected: all automated checks pass and both environment bundles build. + +- [ ] **Step 7: Commit multiline layout integration** + +```bash +git add src/App.vue src/App.css src/starterTimeline.ts tests/starter-timeline.test.ts tests/web-regressions.test.ts +git commit -m "feat: render uncapped multiline todos" +``` + +### Task 6: Testable Web long-press synthesis + +**Files:** +- Create: `tests/web-longpress.test.ts` +- Create: `web/todo-longpress.js` +- Modify: `web/index.html` +- Modify: `scripts/assemble-web.mjs` +- Modify: `tests/web-regressions.test.ts` + +- [ ] **Step 1: Write failing controller tests with an injected clock** + +Cover: no trigger at 499ms, one trigger at 500ms, movement beyond 12px cancels, pointer up/cancel cancels, scroll cancels, wrong target does not start, and a second pointer cannot steal the active gesture. + +```ts +const clock = createFakeClock() +const fired: unknown[] = [] +const gesture = createTodoLongPressGesture({ + onLongPress: (target) => fired.push(target), + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, +}) +gesture.start({ pointerId: 1, target: body, x: 10, y: 10 }) +clock.advance(499) +assert.equal(fired.length, 0) +clock.advance(1) +assert.deepEqual(fired, [body]) +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `node --test tests/web-longpress.test.ts` + +Expected: module-not-found failure for `web/todo-longpress.js`. + +- [ ] **Step 3: Implement controller and DOM installer** + +Export `createTodoLongPressGesture` with injected timer functions and `installTodoLongPress(root)`. The installer uses delegated capture listeners on the Lynx shadow root, finds `.todo-body` through `event.composedPath()`, starts only primary-button gestures, cancels on pointer move/up/cancel/lost capture, root scroll, document visibility change, and window blur, and dispatches: + +```js +target.dispatchEvent(new CustomEvent('longpress', { + bubbles: true, + composed: true, + detail: { clientX: x, clientY: y }, +})) +``` + +Guard the root against duplicate installation. Suppress `contextmenu` only when the composed path contains `.todo-body`. + +- [ ] **Step 4: Install and publish the module** + +Convert the Web enhancement script to `type="module"`, import `installTodoLongPress` from `./todo-longpress.js`, and call it as soon as the shadow root exists, before the CSS-injection early return. Update `scripts/assemble-web.mjs` to copy the module beside `dist/index.html`. + +- [ ] **Step 5: Run controller, source, and assembled-build verification** + +Run: + +```bash +node --test tests/web-longpress.test.ts tests/web-regressions.test.ts +npm run build:web +test -s dist/todo-longpress.js +``` + +Expected: fake-clock behavior passes and the assembled static Web app contains the module. + +- [ ] **Step 6: Commit Web gesture parity** + +```bash +git add web/todo-longpress.js web/index.html scripts/assemble-web.mjs tests/web-longpress.test.ts tests/web-regressions.test.ts +git commit -m "feat: synthesize todo long press on web" +``` + +### Task 7: Cross-platform acceptance and publication + +**Files:** +- Modify as needed from verified defects only. +- Add verification screenshots to `docs/verification/port/` only if they are stable and useful; keep transient video/logs under `/tmp`. + +- [ ] **Step 1: Run the complete static gate fresh** + +```bash +git diff --check +node --test tests/*.test.ts +npx tsc -b +npm run build:web +test -s dist/main.lynx.bundle +test -s dist/main.web.bundle +test -s dist/todo-longpress.js +``` + +Expected: zero failures, clean diff, and all deliverables present. + +- [ ] **Step 2: Verify Web at phone and desktop widths** + +Serve `dist/`, clear `localStorage.busyWeek`, and test at 390×844 and 1024×844. Use short CJK, long CJK, spaced and unbroken Latin, emoji, and four explicit lines. Confirm full text, no overlap, width reflow, 550ms long press, prefilled edit composer/date, save/cancel, cross-date move preserving completion, checkbox/delete isolation, and smooth survivor/day displacement. + +- [ ] **Step 3: Verify iOS Simulator** + +Start `npm run dev`, open the printed `main.lynx.bundle?fullscreen=true` through Lynx DevTool client `localhost:8903` on iPhone 17 Pro simulator `EABC0BC7-12FE-4940-969C-FF3D6B9135F5`, and repeat the text/edit/date/cancel corpus. Confirm a drag before 500ms scrolls without opening edit. Measure shortcut→picker and picker→keyboard clearances as `14±2px`. Inspect Todo/day box models for four-plus lines and capture console warnings/errors. + +- [ ] **Step 4: Fix any discovered defect with a new RED/GREEN cycle** + +For each defect, first add the smallest failing unit or source regression, run it RED, implement the fix, then rerun it GREEN and repeat the full static gate. + +- [ ] **Step 5: Review final diff against every specification requirement** + +Check composer spacing, uncapped lines, Native long press, Web long press, text/date editing, draft cancellation, identity/completion preservation, variable-height animation, Native/Web scroll, fallback behavior, backend isolation, reduced motion, and removal of dead inline-editor code. + +- [ ] **Step 6: Commit final verification fixes, push, and create PR** + +```bash +git add -A +git commit -m "fix: polish multiline todo editing" # only when verification produced changes +git push -u origin codex/todo-composer-multiline +gh pr create --base master --head codex/todo-composer-multiline \ + --title "Improve multiline todos and long-press editing" \ + --body-file /tmp/busyweek-pr-body.md +``` + +Expected: a new ready-for-review PR targeting `master`, with test/build/Web/iOS evidence in the body. diff --git a/docs/superpowers/specs/2026-07-16-multiline-todo-composer-edit-design.md b/docs/superpowers/specs/2026-07-16-multiline-todo-composer-edit-design.md new file mode 100644 index 0000000..8d2bc08 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-multiline-todo-composer-edit-design.md @@ -0,0 +1,85 @@ +# Multiline Todo and Composer Editing Design + +## Goals + +- Give the composer controls the same visual clearance above the software keyboard as the clearance between the quick-day shortcuts and the picker row. +- Render every Todo's complete text at its natural number of lines on Native and Web. +- Replace inline row editing with a long-press interaction that opens the existing full-screen composer in edit mode. +- Allow an edit to change both the Todo text and its date while preserving identity and completion state. +- Keep Clear-style displacement motion correct for variable-height rows, including removal, completed-item hiding, and cross-date moves. + +## Interaction model + +The floating action button opens the composer with a create intent. A long press on `.todo-body` opens the same composer with an edit intent containing the Todo id and its source date. The checkbox and delete hit targets keep their existing stopped tap handlers, so neither can accidentally open the editor. A normal tap on the Todo body no longer swaps in an inline input. + +Native Lynx supplies its standard 500ms `longpress` event and cancels it when scrolling wins the gesture. The pinned Web Core revision does not synthesize that event, so `web/index.html` installs a delegated shadow-root pointer bridge. It starts a 500ms timer only for `.todo-body`, cancels on pointer movement beyond a small threshold, pointer cancellation/up, or timeline scroll, and dispatches a bubbling/composed `CustomEvent('longpress')` to the Lynx element. It never prevents pointer movement, so Web scrolling remains native. A scoped `contextmenu` handler suppresses the browser menu only after this Todo-body gesture. + +The composer title and submit label are intent-specific: + +- create: `添加事项` / `添加` +- edit: `编辑事项` / `保存` + +Opening an edit copies the Todo text and date into isolated draft refs, then uses the existing post-mount `setValue` followed by focus sequence. Closing or swiping the composer away discards the draft. Saving trims the text. To preserve the prior inline editor's behavior, an empty edit removes the Todo; an empty create continues to use `写点啥呀!`. + +When the date changes, save removes the Todo from its source day, deletes an empty source day, updates `date` and `dayType`, and inserts the same logical Todo into the target day. Its stable `id` and `done` values remain unchanged. The timeline's existing date sort determines the resulting visual order. + +## Cross-platform text measurement + +The row height is not capped. Both builds use the same small internal facade with `prepare`/`layout` semantics, but Rspeedy resolves its implementation by environment: + +- `lynx`: `lynx-pretext@0.0.1`, whose width primitive is `lynx.getTextInfo()` and whose source is safe for PrimJS. +- `web`: `@chenglou/pretext@0.0.8`, whose width primitive is Canvas 2D `measureText()` and which uses `OffscreenCanvas` in the Lynx Web worker. + +Environment-level `resolve.alias` keeps the browser package and its Unicode/`Intl.Segmenter` requirements out of the Native bundle. The Web backend is capability guarded: if Canvas or segmentation is unavailable, the app starts with the minimum height and relies on the renderer layout event instead of failing the list. + +An invisible, non-interactive row-width probe uses the exact day-card/Todo geometry. Its `.todo-body` emits `layoutchange`; a SelectorQuery `boundingClientRect` call is the fallback. Once the usable text width is known, each Todo is prepared once per text/font combination and laid out cheaply for that width. A resize invalidates only width-dependent layout results, not the prepared text cache. + +Measurements use `whiteSpace: 'pre-wrap'`, a `15px` body font, and a `20px` line height. Row height is: + +```text +max(52, measuredTextHeight + 16) +``` + +The 16px vertical chrome gives multiline text 8px above and below while retaining the existing 52px minimum and touch comfort for a single line. + +Prediction is not the final authority. Every visible `` listens for Lynx's `layout` event and reports its renderer-owned `lineCount` and `size.height`. A valid actual height replaces the prediction for that Todo. Text edits and text-width changes invalidate stale actual values. This corrects font, emoji, bidi, and engine differences without introducing a fixed line limit. + +## Variable-height motion + +`createTimelineMotionLayout` receives a Todo-height map. It replaces `index * 52` with per-day prefix sums and replaces `count * 52` with the sum of resolved heights. Missing or invalid measurements fall back to 52px. + +The template assigns both transform and height to every `.todo-slot`; the nested `.todo` stretches to that height. Checkbox and delete hit areas remain at least 44px and vertically center within taller rows. The leaving VNode keeps its last inline height while survivors, day-card heights, and later day offsets transition to their new prefix-sum positions. This preserves the current exit-left plus surrounding displacement choreography for rows of any height. + +## Composer spacing + +The quick-day viewport is 50px tall while its chip is 44px, and the viewport has an 8px bottom margin. Therefore the visual shortcut-to-picker clearance is 14px. When the native keyboard is open, `.addpage-bottom` uses 14px bottom padding instead of 8px, matching that rhythm while retaining the existing keyboard-height offset. + +## Cleanup + +Inline edit state, ``, focus directive, edit-keyboard spacer, and the dedicated inline keyboard-avoidance modules/tests become dead code and are removed. Composer keyboard avoidance remains in place and now covers both creation and editing. + +## Failure handling + +- Invalid or unavailable predicted measurement returns the 52px minimum and never prevents rendering. +- Invalid layout events are ignored. +- SelectorQuery calls keep failure callbacks and cannot red-screen if the probe or composer disappears. +- Long press is attached only to the Todo body; Lynx cancels it when scrolling wins the gesture. +- Picker sheets retain their current unmount-on-close behavior and operate on the same draft date in both intents. + +## Verification + +- Unit-test row-height clamping, arbitrary line counts, renderer correction normalization, cache invalidation, and variable-height prefix sums. +- Unit-test editing in place, moving across dates, preserving `id`/`done`, source-day cleanup, cancellation, and empty-edit removal through extracted pure helpers. +- Add source regressions for long press, removal of inline input/keyboard spacer, dynamic title/submit labels, 14px keyboard spacing, layout events, and environment aliases. +- Unit-test the Web long-press bridge's 500ms trigger plus movement, release, and scroll cancellation with fake timers and a minimal event target. +- Build both `lynx` and `web` environments and confirm each bundle contains only its intended Pretext backend. +- Web: verify one-line, long CJK, long Latin, emoji, explicit newline, completed-item hiding, deletion, long-press edit, date move, cancel, and responsive desktop widths. +- iOS Simulator: verify the same text corpus, 500ms long press, composer prefill, keyboard clearance, picker/date changes, scroll cancellation of long press, and smooth displacement after rows leave or move. + +## References + +- [Pretext](https://github.com/chenglou/pretext) +- [lynx-pretext](https://github.com/Huxpro/lynx-pretext) +- [Lynx `getTextInfo`](https://lynxjs.org/api/lynx-api/lynx/lynx-get-text-info) +- [Lynx `` layout event](https://lynxjs.org/api/elements/built-in/text) +- [Rspeedy/Rsbuild environment aliases](https://rsbuild.rs/config/resolve/alias) From 2e1a454fd2fb7a52ec37ebe99ff5d0e4f5392991 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:01:59 +0300 Subject: [PATCH 02/19] feat: add draft-based todo composer mutations --- src/todoComposer.ts | 110 +++++++++++++++ tests/todo-editor.test.ts | 289 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 src/todoComposer.ts create mode 100644 tests/todo-editor.test.ts diff --git a/src/todoComposer.ts b/src/todoComposer.ts new file mode 100644 index 0000000..3e42196 --- /dev/null +++ b/src/todoComposer.ts @@ -0,0 +1,110 @@ +import type { Timeline, Todo } from './types.js' +// @ts-expect-error Node's direct TypeScript runner requires the on-disk extension. +import { getDateDiff } from './util.ts' + +export type ComposerIntent = + | { kind: 'create' } + | { kind: 'edit'; todoId: string; sourceDate: string } + +export interface ComposerDraft { + text: string + date: string +} + +export interface CommitOptions { + today: string + idFactory: () => string +} + +const DEFAULT_TODO_TEXT = '写点啥呀!' + +export function createComposerDraft( + timeline: Timeline, + intent: ComposerIntent, + today: string, +): ComposerDraft { + if (intent.kind === 'create') return { text: '', date: today } + + const original = timeline[intent.sourceDate]?.todos.find( + (todo) => todo.id === intent.todoId, + ) + return original + ? { text: original.text, date: original.date } + : { text: '', date: today } +} + +export function commitComposerDraft( + timeline: Timeline, + intent: ComposerIntent, + draft: ComposerDraft, + options: CommitOptions, +): Timeline { + const text = draft.text.trim() + + if (intent.kind === 'create') { + const targetDay = timeline[draft.date] + const created: Todo = { + id: options.idFactory(), + date: draft.date, + dayType: getDateDiff(draft.date, options.today), + done: false, + text: text || DEFAULT_TODO_TEXT, + } + + return { + ...timeline, + [draft.date]: { + date: draft.date, + todos: [...(targetDay?.todos ?? []), created], + }, + } + } + + const sourceDay = timeline[intent.sourceDate] + const sourceIndex = + sourceDay?.todos.findIndex((todo) => todo.id === intent.todoId) ?? -1 + if (!sourceDay || sourceIndex < 0) return timeline + + const remainingTodos = sourceDay.todos.filter( + (_, index) => index !== sourceIndex, + ) + if (!text) { + const result = { ...timeline } + if (remainingTodos.length === 0) { + delete result[intent.sourceDate] + } else { + result[intent.sourceDate] = { ...sourceDay, todos: remainingTodos } + } + return result + } + + const updated: Todo = { + ...sourceDay.todos[sourceIndex], + text, + date: draft.date, + dayType: getDateDiff(draft.date, options.today), + } + + if (draft.date === intent.sourceDate) { + const todos = [...sourceDay.todos] + todos[sourceIndex] = updated + return { + ...timeline, + [intent.sourceDate]: { ...sourceDay, todos }, + } + } + + const result = { ...timeline } + if (remainingTodos.length === 0) { + delete result[intent.sourceDate] + } else { + result[intent.sourceDate] = { ...sourceDay, todos: remainingTodos } + } + + const targetDay = timeline[draft.date] + result[draft.date] = { + date: draft.date, + todos: [...(targetDay?.todos ?? []), updated], + } + return result +} diff --git a/tests/todo-editor.test.ts b/tests/todo-editor.test.ts new file mode 100644 index 0000000..9a8119f --- /dev/null +++ b/tests/todo-editor.test.ts @@ -0,0 +1,289 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + commitComposerDraft, + createComposerDraft, +} from '../src/todoComposer.ts' +import type { Timeline, Todo } from '../src/types.ts' + +const TODAY = '2026-07-16' + +function todo( + values: Pick & Partial, +): Todo { + return { + dayType: 0, + done: false, + ...values, + } +} + +function freezeTimeline(timeline: Timeline): Timeline { + for (const day of Object.values(timeline)) { + for (const item of day.todos) Object.freeze(item) + Object.freeze(day.todos) + Object.freeze(day) + } + return Object.freeze(timeline) +} + +test('creates an empty composer draft for today', () => { + assert.deepEqual(createComposerDraft({}, { kind: 'create' }, TODAY), { + text: '', + date: TODAY, + }) +}) + +test('copies an edit into an isolated draft until save', () => { + const timeline = freezeTimeline({ + '2026-07-17': { + date: '2026-07-17', + todos: [ + todo({ + id: 'edit-me', + date: '2026-07-17', + text: 'Original text', + }), + ], + }, + }) + const before = JSON.stringify(timeline) + + const draft = createComposerDraft( + timeline, + { kind: 'edit', todoId: 'edit-me', sourceDate: '2026-07-17' }, + TODAY, + ) + + assert.deepEqual(draft, { text: 'Original text', date: '2026-07-17' }) + draft.text = 'Unsaved text' + draft.date = '2026-07-20' + assert.equal(JSON.stringify(timeline), before) +}) + +test('falls back to an empty draft for a missing edit target', () => { + assert.deepEqual( + createComposerDraft( + {}, + { kind: 'edit', todoId: 'missing', sourceDate: '2026-07-17' }, + TODAY, + ), + { text: '', date: TODAY }, + ) +}) + +test('trims and appends a created Todo without mutating the timeline', () => { + const timeline = freezeTimeline({ + '2026-07-20': { + date: '2026-07-20', + todos: [ + todo({ id: 'existing', date: '2026-07-20', text: 'Already here' }), + ], + }, + }) + const before = JSON.stringify(timeline) + let idCalls = 0 + + const result = commitComposerDraft( + timeline, + { kind: 'create' }, + { text: ' First line\nSecond line ', date: '2026-07-20' }, + { + today: TODAY, + idFactory: () => { + idCalls += 1 + return 'new-id' + }, + }, + ) + + assert.equal(idCalls, 1) + assert.equal(JSON.stringify(timeline), before) + assert.notStrictEqual(result, timeline) + assert.deepEqual(result['2026-07-20'].todos, [ + timeline['2026-07-20'].todos[0], + { + id: 'new-id', + date: '2026-07-20', + dayType: 4, + done: false, + text: 'First line\nSecond line', + }, + ]) +}) + +test('uses the default text when a create draft is blank', () => { + const result = commitComposerDraft( + {}, + { kind: 'create' }, + { text: ' \n\t ', date: '2026-07-18' }, + { today: TODAY, idFactory: () => 'default-id' }, + ) + + assert.deepEqual(result, { + '2026-07-18': { + date: '2026-07-18', + todos: [ + { + id: 'default-id', + date: '2026-07-18', + dayType: 2, + done: false, + text: '写点啥呀!', + }, + ], + }, + }) +}) + +test('moves an edit by stable id and appends it to the target day', () => { + const timeline = freezeTimeline({ + '2026-07-16': { + date: '2026-07-16', + todos: [ + todo({ + id: 'stable-id', + date: '2026-07-16', + dayType: 0, + done: true, + text: 'Before move', + }), + ], + }, + '2026-07-18': { + date: '2026-07-18', + todos: [ + todo({ id: 'target-first', date: '2026-07-18', text: 'Target first' }), + ], + }, + }) + const before = JSON.stringify(timeline) + + const result = commitComposerDraft( + timeline, + { kind: 'edit', todoId: 'stable-id', sourceDate: '2026-07-16' }, + { text: ' After move ', date: '2026-07-18' }, + { today: TODAY, idFactory: () => 'unused' }, + ) + + assert.equal(JSON.stringify(timeline), before) + assert.equal('2026-07-16' in result, false) + assert.deepEqual( + result['2026-07-18'].todos.map((item) => item.id), + ['target-first', 'stable-id'], + ) + assert.deepEqual(result['2026-07-18'].todos[1], { + id: 'stable-id', + date: '2026-07-18', + dayType: 2, + done: true, + text: 'After move', + }) +}) + +test('keeps a same-date edit at its original position', () => { + const timeline = freezeTimeline({ + '2026-07-17': { + date: '2026-07-17', + todos: [ + todo({ id: 'before', date: '2026-07-17', text: 'Before' }), + todo({ + id: 'edit-me', + date: '2026-07-17', + dayType: 99, + done: true, + text: 'Old text', + }), + todo({ id: 'after', date: '2026-07-17', text: 'After' }), + ], + }, + }) + + const result = commitComposerDraft( + timeline, + { kind: 'edit', todoId: 'edit-me', sourceDate: '2026-07-17' }, + { text: ' Updated text ', date: '2026-07-17' }, + { today: TODAY, idFactory: () => 'unused' }, + ) + + assert.deepEqual( + result['2026-07-17'].todos.map((item) => item.id), + ['before', 'edit-me', 'after'], + ) + assert.deepEqual(result['2026-07-17'].todos[1], { + id: 'edit-me', + date: '2026-07-17', + dayType: 1, + done: true, + text: 'Updated text', + }) + assert.equal(timeline['2026-07-17'].todos[1].text, 'Old text') +}) + +test('returns the original timeline when the edit target is missing', () => { + const timeline = freezeTimeline({ + '2026-07-17': { + date: '2026-07-17', + todos: [todo({ id: 'present', date: '2026-07-17', text: 'Present' })], + }, + }) + let idCalls = 0 + + const result = commitComposerDraft( + timeline, + { kind: 'edit', todoId: 'missing', sourceDate: '2026-07-17' }, + { text: 'Replacement', date: '2026-07-18' }, + { + today: TODAY, + idFactory: () => { + idCalls += 1 + return 'unused' + }, + }, + ) + + assert.strictEqual(result, timeline) + assert.equal(idCalls, 0) +}) + +test('deletes a Todo for a blank edit and removes its empty source day', () => { + const timeline = freezeTimeline({ + '2026-07-17': { + date: '2026-07-17', + todos: [todo({ id: 'delete-me', date: '2026-07-17', text: 'Delete me' })], + }, + }) + const before = JSON.stringify(timeline) + + const result = commitComposerDraft( + timeline, + { kind: 'edit', todoId: 'delete-me', sourceDate: '2026-07-17' }, + { text: ' \n ', date: '2026-07-20' }, + { today: TODAY, idFactory: () => 'unused' }, + ) + + assert.deepEqual(result, {}) + assert.equal(JSON.stringify(timeline), before) +}) + +test('cancel leaves the serialized timeline unchanged by never committing', () => { + const timeline = freezeTimeline({ + '2026-07-17': { + date: '2026-07-17', + todos: [todo({ id: 'cancel-me', date: '2026-07-17', text: 'Keep me' })], + }, + }) + const before = JSON.stringify(timeline) + const draft = createComposerDraft( + timeline, + { kind: 'edit', todoId: 'cancel-me', sourceDate: '2026-07-17' }, + TODAY, + ) + + draft.text = 'Discard this change' + draft.date = '2026-07-20' + // Cancel closes the composer without calling commitComposerDraft. + + assert.equal(JSON.stringify(timeline), before) +}) From f0473ef7e945d747601c596186587b5ffda15f1b Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:43:01 +0300 Subject: [PATCH 03/19] feat: support variable-height todo motion --- src/timelineMotion.ts | 25 ++++++-- src/todoTextLayout.ts | 38 +++++++++++++ tests/timeline-motion.test.ts | 101 ++++++++++++++++++++++++++------- tests/todo-text-layout.test.ts | 67 ++++++++++++++++++++++ 4 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 src/todoTextLayout.ts create mode 100644 tests/todo-text-layout.test.ts diff --git a/src/timelineMotion.ts b/src/timelineMotion.ts index c20cfa1..0991de8 100644 --- a/src/timelineMotion.ts +++ b/src/timelineMotion.ts @@ -1,6 +1,8 @@ import type { VisibleDay } from './timelineView.js' +// @ts-expect-error Node's direct TypeScript runner requires the on-disk extension. +import { TODO_MIN_ROW_HEIGHT } from './todoTextLayout.ts' -export const TODO_ROW_HEIGHT = 52 +export const TODO_ROW_HEIGHT = TODO_MIN_ROW_HEIGHT export const DAY_HEADER_HEIGHT = 42 export const DAY_GAP = 10 @@ -8,6 +10,7 @@ export interface DayMotionLayout { offset: number todosHeight: number todoOffsets: Record + todoHeights: Record } export interface TimelineMotionLayout { @@ -17,18 +20,28 @@ export interface TimelineMotionLayout { export function createTimelineMotionLayout( visibleDays: VisibleDay[], + rowHeights: Readonly> = {}, ): TimelineMotionLayout { const days: Record = {} let offset = 0 for (const day of visibleDays) { const todoOffsets: Record = {} - day.todos.forEach((todo, index) => { - todoOffsets[todo.id] = index * TODO_ROW_HEIGHT - }) + const todoHeights: Record = {} + let todosHeight = 0 - const todosHeight = day.todos.length * TODO_ROW_HEIGHT - days[day.key] = { offset, todosHeight, todoOffsets } + for (const todo of day.todos) { + const suppliedHeight = rowHeights[todo.id] + const resolvedHeight = Number.isFinite(suppliedHeight) + ? Math.max(TODO_MIN_ROW_HEIGHT, suppliedHeight) + : TODO_MIN_ROW_HEIGHT + + todoOffsets[todo.id] = todosHeight + todoHeights[todo.id] = resolvedHeight + todosHeight += resolvedHeight + } + + days[day.key] = { offset, todosHeight, todoOffsets, todoHeights } offset += DAY_GAP + DAY_HEADER_HEIGHT + todosHeight } diff --git a/src/todoTextLayout.ts b/src/todoTextLayout.ts new file mode 100644 index 0000000..8f323dd --- /dev/null +++ b/src/todoTextLayout.ts @@ -0,0 +1,38 @@ +export const TODO_MIN_ROW_HEIGHT = 52 +export const TODO_TEXT_LINE_HEIGHT = 20 +export const TODO_VERTICAL_CHROME = 16 + +export function rowHeightFromTextHeight(textHeight: number): number { + if (!Number.isFinite(textHeight) || textHeight <= 0) { + return TODO_MIN_ROW_HEIGHT + } + + return Math.max( + TODO_MIN_ROW_HEIGHT, + Math.ceil(textHeight) + TODO_VERTICAL_CHROME, + ) +} + +export function rowHeightFromLayoutEvent(event: unknown): number | null { + const detail = ( + event as { + detail?: { lineCount?: unknown; size?: { height?: unknown } } + } + )?.detail + const height = detail?.size?.height + const lineCount = detail?.lineCount + + if (typeof lineCount !== 'number' || lineCount < 1) { + return null + } + + if ( + typeof height !== 'number' || + !Number.isFinite(height) || + height <= 0 + ) { + return rowHeightFromTextHeight(lineCount * TODO_TEXT_LINE_HEIGHT) + } + + return rowHeightFromTextHeight(height) +} diff --git a/tests/timeline-motion.test.ts b/tests/timeline-motion.test.ts index 92eb8a9..ceedb46 100644 --- a/tests/timeline-motion.test.ts +++ b/tests/timeline-motion.test.ts @@ -4,9 +4,9 @@ import test from 'node:test' import { DAY_GAP, DAY_HEADER_HEIGHT, - TODO_ROW_HEIGHT, createTimelineMotionLayout, } from '../src/timelineMotion.ts' +import { TODO_MIN_ROW_HEIGHT } from '../src/todoTextLayout.ts' import type { VisibleDay } from '../src/timelineView.ts' function day(key: string, ids: string[]): VisibleDay { @@ -22,44 +22,103 @@ function day(key: string, ids: string[]): VisibleDay { } } -test('positions todos and day cards using fixed Clear-style slots', () => { +test('positions mixed-height todos and day cards using cumulative offsets', () => { const layout = createTimelineMotionLayout([ - day('2026-07-15', ['a', 'b']), - day('2026-07-16', ['c']), - ]) + day('2026-07-15', ['a', 'b', 'c']), + day('2026-07-16', ['d']), + ], { + a: 52, + b: 96, + c: 72, + }) assert.equal(layout.days['2026-07-15'].offset, 0) assert.equal(layout.days['2026-07-15'].todoOffsets.a, 0) - assert.equal(layout.days['2026-07-15'].todoOffsets.b, TODO_ROW_HEIGHT) - assert.equal(layout.days['2026-07-15'].todosHeight, 2 * TODO_ROW_HEIGHT) + assert.equal(layout.days['2026-07-15'].todoOffsets.b, 52) + assert.equal(layout.days['2026-07-15'].todoOffsets.c, 148) + assert.deepEqual(layout.days['2026-07-15'].todoHeights, { + a: 52, + b: 96, + c: 72, + }) + assert.equal(layout.days['2026-07-15'].todosHeight, 220) assert.equal( layout.days['2026-07-16'].offset, - DAY_GAP + DAY_HEADER_HEIGHT + 2 * TODO_ROW_HEIGHT, + DAY_GAP + DAY_HEADER_HEIGHT + 220, ) assert.equal( layout.height, - 2 * DAY_GAP + 2 * DAY_HEADER_HEIGHT + 3 * TODO_ROW_HEIGHT, + 2 * DAY_GAP + 2 * DAY_HEADER_HEIGHT + 220 + TODO_MIN_ROW_HEIGHT, ) }) -test('retained todos and later day cards move by the removed row height', () => { +test('retained todos and later day cards move by the removed variable row height', () => { const before = createTimelineMotionLayout([ - day('2026-07-15', ['a', 'b']), - day('2026-07-16', ['c']), - ]) + day('2026-07-15', ['a', 'b', 'c']), + day('2026-07-16', ['d']), + ], { + a: 52, + b: 96, + c: 72, + }) const after = createTimelineMotionLayout([ - day('2026-07-15', ['b']), - day('2026-07-16', ['c']), - ]) + day('2026-07-15', ['a', 'c']), + day('2026-07-16', ['d']), + ], { + a: 52, + b: 96, + c: 72, + }) assert.equal( - before.days['2026-07-15'].todoOffsets.b - - after.days['2026-07-15'].todoOffsets.b, - TODO_ROW_HEIGHT, + before.days['2026-07-15'].todoOffsets.c - + after.days['2026-07-15'].todoOffsets.c, + 96, ) assert.equal( before.days['2026-07-16'].offset - after.days['2026-07-16'].offset, - TODO_ROW_HEIGHT, + 96, ) - assert.equal(before.height - after.height, TODO_ROW_HEIGHT) + assert.equal(before.height - after.height, 96) +}) + +test('sanitizes missing and invalid row heights to the minimum', () => { + const layout = createTimelineMotionLayout( + [ + day('2026-07-15', [ + 'missing', + 'infinite', + 'zero', + 'nan', + 'negative', + 'small', + ]), + ], + { + infinite: Number.POSITIVE_INFINITY, + zero: 0, + nan: Number.NaN, + negative: -12, + small: TODO_MIN_ROW_HEIGHT - 1, + }, + ) + + const dayLayout = layout.days['2026-07-15'] + assert.deepEqual(dayLayout.todoHeights, { + missing: TODO_MIN_ROW_HEIGHT, + infinite: TODO_MIN_ROW_HEIGHT, + zero: TODO_MIN_ROW_HEIGHT, + nan: TODO_MIN_ROW_HEIGHT, + negative: TODO_MIN_ROW_HEIGHT, + small: TODO_MIN_ROW_HEIGHT, + }) + assert.deepEqual(dayLayout.todoOffsets, { + missing: 0, + infinite: 52, + zero: 104, + nan: 156, + negative: 208, + small: 260, + }) + assert.equal(dayLayout.todosHeight, 6 * TODO_MIN_ROW_HEIGHT) }) diff --git a/tests/todo-text-layout.test.ts b/tests/todo-text-layout.test.ts new file mode 100644 index 0000000..397a3eb --- /dev/null +++ b/tests/todo-text-layout.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + TODO_MIN_ROW_HEIGHT, + TODO_TEXT_LINE_HEIGHT, + TODO_VERTICAL_CHROME, + rowHeightFromLayoutEvent, + rowHeightFromTextHeight, +} from '../src/todoTextLayout.ts' + +test('derives todo row height from measured text height without a line cap', () => { + assert.equal(rowHeightFromTextHeight(20), 52) + assert.equal(rowHeightFromTextHeight(40), 56) + assert.equal(rowHeightFromTextHeight(80), 96) + assert.equal(rowHeightFromTextHeight(160), 176) +}) + +test('uses the minimum row height for invalid or non-positive text height', () => { + assert.equal(rowHeightFromTextHeight(Number.NaN), TODO_MIN_ROW_HEIGHT) + assert.equal(rowHeightFromTextHeight(0), TODO_MIN_ROW_HEIGHT) + assert.equal(rowHeightFromTextHeight(-1), TODO_MIN_ROW_HEIGHT) +}) + +test('returns null when a text layout event has no valid line count', () => { + assert.equal(rowHeightFromLayoutEvent({}), null) + assert.equal( + rowHeightFromLayoutEvent({ + detail: { lineCount: 0, size: { height: 80 } }, + }), + null, + ) +}) + +test('uses renderer text height from a valid layout event', () => { + assert.equal( + rowHeightFromLayoutEvent({ + detail: { + lineCount: 4, + size: { width: 180, height: 80 }, + }, + }), + 96, + ) +}) + +test('falls back to line count when renderer text height is missing or invalid', () => { + const expectedHeight = + 4 * TODO_TEXT_LINE_HEIGHT + TODO_VERTICAL_CHROME + + assert.equal( + rowHeightFromLayoutEvent({ detail: { lineCount: 4 } }), + expectedHeight, + ) + assert.equal( + rowHeightFromLayoutEvent({ + detail: { lineCount: 4, size: { height: Number.NaN } }, + }), + expectedHeight, + ) + assert.equal( + rowHeightFromLayoutEvent({ + detail: { lineCount: 4, size: { height: 0 } }, + }), + expectedHeight, + ) +}) From 9deb84dbec062db886d6cb784bd94c7c502f9735 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:04:10 +0300 Subject: [PATCH 04/19] fix: reject invalid text layout counts --- src/todoTextLayout.ts | 6 +++++- tests/todo-text-layout.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/todoTextLayout.ts b/src/todoTextLayout.ts index 8f323dd..b34cb76 100644 --- a/src/todoTextLayout.ts +++ b/src/todoTextLayout.ts @@ -22,7 +22,11 @@ export function rowHeightFromLayoutEvent(event: unknown): number | null { const height = detail?.size?.height const lineCount = detail?.lineCount - if (typeof lineCount !== 'number' || lineCount < 1) { + if ( + typeof lineCount !== 'number' || + !Number.isFinite(lineCount) || + lineCount < 1 + ) { return null } diff --git a/tests/todo-text-layout.test.ts b/tests/todo-text-layout.test.ts index 397a3eb..558c4ae 100644 --- a/tests/todo-text-layout.test.ts +++ b/tests/todo-text-layout.test.ts @@ -24,6 +24,24 @@ test('uses the minimum row height for invalid or non-positive text height', () = test('returns null when a text layout event has no valid line count', () => { assert.equal(rowHeightFromLayoutEvent({}), null) + assert.equal( + rowHeightFromLayoutEvent({ + detail: { lineCount: Number.NaN, size: { height: 80 } }, + }), + null, + ) + assert.equal( + rowHeightFromLayoutEvent({ + detail: { lineCount: Number.POSITIVE_INFINITY, size: { height: 80 } }, + }), + null, + ) + assert.equal( + rowHeightFromLayoutEvent({ + detail: { lineCount: Number.NEGATIVE_INFINITY, size: { height: 80 } }, + }), + null, + ) assert.equal( rowHeightFromLayoutEvent({ detail: { lineCount: 0, size: { height: 80 } }, From a83d3a07d40810fb4f2cef18b15858e074a4f20e Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:04:02 +0300 Subject: [PATCH 05/19] feat: add native and web text measurement backends --- lynx.config.ts | 21 ++++++++- package-lock.json | 41 +++++++++++++++++ package.json | 2 + src/text-layout-backend.d.ts | 25 +++++++++++ src/textLayoutBackend.lynx.ts | 66 ++++++++++++++++++++++++++++ src/textLayoutBackend.web.ts | 83 +++++++++++++++++++++++++++++++++++ tests/web-regressions.test.ts | 69 ++++++++++++++++++++++++++++- 7 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 src/text-layout-backend.d.ts create mode 100644 src/textLayoutBackend.lynx.ts create mode 100644 src/textLayoutBackend.web.ts diff --git a/lynx.config.ts b/lynx.config.ts index 583c8fa..c3bd6b4 100644 --- a/lynx.config.ts +++ b/lynx.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from '@lynx-js/rspeedy' +import { fileURLToPath } from 'node:url' import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin' import { pluginVueLynx } from 'vue-lynx/plugin' @@ -6,9 +7,25 @@ import { pluginVueLynx } from 'vue-lynx/plugin' export default defineConfig({ environments: { // Native (iOS/Android) bundle. - lynx: {}, + lynx: { + resolve: { + alias: { + '@busyweek/text-layout-backend$': fileURLToPath( + new URL('./src/textLayoutBackend.lynx.ts', import.meta.url), + ), + }, + }, + }, // Lynx Web Platform bundle. - web: {}, + web: { + resolve: { + alias: { + '@busyweek/text-layout-backend$': fileURLToPath( + new URL('./src/textLayoutBackend.web.ts', import.meta.url), + ), + }, + }, + }, }, plugins: [ pluginQRCode({ diff --git a/package-lock.json b/package-lock.json index 45037f8..bc51dac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "3.0.0", "license": "ISC", "dependencies": { + "@chenglou/pretext": "0.0.8", "@lynx-js/web-core": "https://pkg.pr.new/@lynx-js/web-core@043cd321bd84da9cb3c3cf928e3888b561f207c6", + "lynx-pretext": "0.0.1", "vue": "^3.5.0", "vue-lynx": "0.4.0" }, @@ -84,6 +86,12 @@ "node": ">=6.9.0" } }, + "node_modules/@chenglou/pretext": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.8.tgz", + "integrity": "sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==", + "license": "MIT" + }, "node_modules/@colordx/core": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.4.3.tgz", @@ -122,6 +130,30 @@ "tslib": "^2.4.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.12.tgz", + "integrity": "sha512-5H3r5ZJ2jZqHEv9K343lvHmeMDKMxssawAVD2H4J9xtu0ZXb6MlNxwLqdwBxJSHFU0C24KSZnffgmAi+59mK4A==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7" + } + }, + "node_modules/@formatjs/intl-segmenter": { + "version": "12.2.12", + "resolved": "https://registry.npmjs.org/@formatjs/intl-segmenter/-/intl-segmenter-12.2.12.tgz", + "integrity": "sha512-+QfrN80j6Gv4Yn2icDe9VZIga8T0tXpBMJW5+oECmWrPWl1w6dnvfdAwVymQNRtRUZWXwUwDYJjOwBBvvsQ8eg==", + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "0.8.12" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -3955,6 +3987,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lynx-pretext": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/lynx-pretext/-/lynx-pretext-0.0.1.tgz", + "integrity": "sha512-ioHum2r1KJVtIUfFXJg3ifecmb3/F6mEpFeTbgJHKkHi/Mc32MNqmKS2mCOHWn8gwEQeXso4WRpbjlP+zqu3cw==", + "license": "MIT", + "dependencies": { + "@formatjs/intl-segmenter": "^12.2.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 8151ab1..52acbe3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "preview": "rspeedy preview" }, "dependencies": { + "@chenglou/pretext": "0.0.8", "@lynx-js/web-core": "https://pkg.pr.new/@lynx-js/web-core@043cd321bd84da9cb3c3cf928e3888b561f207c6", + "lynx-pretext": "0.0.1", "vue": "^3.5.0", "vue-lynx": "0.4.0" }, diff --git a/src/text-layout-backend.d.ts b/src/text-layout-backend.d.ts new file mode 100644 index 0000000..a6fb0d8 --- /dev/null +++ b/src/text-layout-backend.d.ts @@ -0,0 +1,25 @@ +declare module '@busyweek/text-layout-backend' { + export interface TodoTextMeasurement { + lineCount: number + textHeight: number + } + + export function measureTodoText( + text: string, + width: number, + ): TodoTextMeasurement | null + + export function clearTodoTextMeasurementCache(): void +} + +declare namespace lynx { + function getTextInfo( + text: string, + options: { + fontSize: string + fontFamily?: string + }, + ): { + width: number + } +} diff --git a/src/textLayoutBackend.lynx.ts b/src/textLayoutBackend.lynx.ts new file mode 100644 index 0000000..91c5c86 --- /dev/null +++ b/src/textLayoutBackend.lynx.ts @@ -0,0 +1,66 @@ +import { + clearCache as clearPretextCache, + layout, + prepare, + type PreparedText, +} from 'lynx-pretext' + +export interface TodoTextMeasurement { + lineCount: number + textHeight: number +} + +const PLATFORM_FONT = '15px sans-serif' +const WHITE_SPACE = 'pre-wrap' as const +const LINE_HEIGHT = 20 + +const preparedTextCache = new Map() + +function createPreparationKey(text: string): string { + return JSON.stringify([text, PLATFORM_FONT, WHITE_SPACE]) +} + +export function measureTodoText( + text: string, + width: number, +): TodoTextMeasurement | null { + if (typeof text !== 'string' || text.length === 0) return null + if (!Number.isFinite(width) || width <= 0) return null + + try { + const preparationKey = createPreparationKey(text) + let prepared = preparedTextCache.get(preparationKey) + + if (prepared === undefined) { + prepared = prepare(text, PLATFORM_FONT, { whiteSpace: WHITE_SPACE }) + preparedTextCache.set(preparationKey, prepared) + } + + const result = layout(prepared, width, LINE_HEIGHT) + if ( + !Number.isFinite(result.lineCount) || + result.lineCount <= 0 || + !Number.isFinite(result.height) || + result.height <= 0 + ) { + return null + } + + return { + lineCount: result.lineCount, + textHeight: result.height, + } + } catch { + return null + } +} + +export function clearTodoTextMeasurementCache(): void { + preparedTextCache.clear() + + try { + clearPretextCache() + } catch { + // A missing native text capability must not make cache cleanup crash UI work. + } +} diff --git a/src/textLayoutBackend.web.ts b/src/textLayoutBackend.web.ts new file mode 100644 index 0000000..0165396 --- /dev/null +++ b/src/textLayoutBackend.web.ts @@ -0,0 +1,83 @@ +import { + clearCache as clearPretextCache, + layout, + prepare, + type PreparedText, +} from '@chenglou/pretext' + +export interface TodoTextMeasurement { + lineCount: number + textHeight: number +} + +type TextMeasurementGlobals = { + OffscreenCanvas?: unknown + Intl?: { + Segmenter?: unknown + } +} + +const PLATFORM_FONT = + '15px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "WenQuanYi Micro Hei", sans-serif' +const WHITE_SPACE = 'pre-wrap' as const +const LINE_HEIGHT = 20 + +const preparedTextCache = new Map() + +function supportsTextMeasurement(): boolean { + const globals = globalThis as unknown as TextMeasurementGlobals + return ( + typeof globals.OffscreenCanvas === 'function' && + typeof globals.Intl?.Segmenter === 'function' + ) +} + +function createPreparationKey(text: string): string { + return JSON.stringify([text, PLATFORM_FONT, WHITE_SPACE]) +} + +export function measureTodoText( + text: string, + width: number, +): TodoTextMeasurement | null { + if (typeof text !== 'string' || text.length === 0) return null + if (!Number.isFinite(width) || width <= 0) return null + if (!supportsTextMeasurement()) return null + + try { + const preparationKey = createPreparationKey(text) + let prepared = preparedTextCache.get(preparationKey) + + if (prepared === undefined) { + prepared = prepare(text, PLATFORM_FONT, { whiteSpace: WHITE_SPACE }) + preparedTextCache.set(preparationKey, prepared) + } + + const result = layout(prepared, width, LINE_HEIGHT) + if ( + !Number.isFinite(result.lineCount) || + result.lineCount <= 0 || + !Number.isFinite(result.height) || + result.height <= 0 + ) { + return null + } + + return { + lineCount: result.lineCount, + textHeight: result.height, + } + } catch { + return null + } +} + +export function clearTodoTextMeasurementCache(): void { + preparedTextCache.clear() + + try { + clearPretextCache() + } catch { + // Browser capability changes must not make cache cleanup crash rendering. + } +} diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index c521c03..e8937d0 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import test from 'node:test' import { loadTimeline, saveTimeline } from '../src/store.ts' @@ -20,6 +20,73 @@ const dayPickerCss = readFileSync( ) const storeSource = readFileSync(new URL('../src/store.ts', import.meta.url), 'utf8') const webHost = readFileSync(new URL('../web/index.html', import.meta.url), 'utf8') +const lynxConfigSource = readFileSync( + new URL('../lynx.config.ts', import.meta.url), + 'utf8', +) +const packageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { dependencies?: Record } + +function readOptionalSource(relativePath: string): string { + const sourceUrl = new URL(relativePath, import.meta.url) + return existsSync(sourceUrl) ? readFileSync(sourceUrl, 'utf8') : '' +} + +const nativeTextLayoutBackendSource = readOptionalSource( + '../src/textLayoutBackend.lynx.ts', +) +const webTextLayoutBackendSource = readOptionalSource( + '../src/textLayoutBackend.web.ts', +) + +test('Rspeedy maps the exact text-layout virtual request per environment', () => { + assert.match( + lynxConfigSource, + /import\s*\{\s*fileURLToPath\s*\}\s*from\s*['"]node:url['"]/, + ) + assert.match( + lynxConfigSource, + /lynx:\s*\{[\s\S]*?resolve:\s*\{[\s\S]*?alias:\s*\{[\s\S]*?['"]@busyweek\/text-layout-backend\$['"]:\s*fileURLToPath\(\s*new URL\(\s*['"]\.\/src\/textLayoutBackend\.lynx\.ts['"]\s*,\s*import\.meta\.url\s*,?\s*\)\s*,?\s*\)/, + ) + assert.match( + lynxConfigSource, + /web:\s*\{[\s\S]*?resolve:\s*\{[\s\S]*?alias:\s*\{[\s\S]*?['"]@busyweek\/text-layout-backend\$['"]:\s*fileURLToPath\(\s*new URL\(\s*['"]\.\/src\/textLayoutBackend\.web\.ts['"]\s*,\s*import\.meta\.url\s*,?\s*\)\s*,?\s*\)/, + ) +}) + +test('both text-layout wrappers expose the stable measurement contract', () => { + for (const source of [ + nativeTextLayoutBackendSource, + webTextLayoutBackendSource, + ]) { + assert.match(source, /export function measureTodoText\(/) + assert.match(source, /export function clearTodoTextMeasurementCache\(/) + } +}) + +test('text-layout wrappers use their platform-specific Pretext packages', () => { + assert.match( + nativeTextLayoutBackendSource, + /from\s*['"]lynx-pretext['"]/, + ) + assert.match( + webTextLayoutBackendSource, + /from\s*['"]@chenglou\/pretext['"]/, + ) +}) + +test('the Web text-layout wrapper guards Canvas capabilities and package failures', () => { + assert.match(webTextLayoutBackendSource, /OffscreenCanvas/) + assert.match(webTextLayoutBackendSource, /Intl[\s\S]*?Segmenter/) + assert.match(webTextLayoutBackendSource, /catch\s*\{/) + assert.match(webTextLayoutBackendSource, /catch\s*\{[\s\S]*?return null/) +}) + +test('text-layout backends use pinned Pretext package versions', () => { + assert.equal(packageJson.dependencies?.['lynx-pretext'], '0.0.1') + assert.equal(packageJson.dependencies?.['@chenglou/pretext'], '0.0.8') +}) test('the assembled web runtime maps Lynx textarea to x-textarea', () => { const client = readFileSync( From 1f28341327da881ba3d7a7be3a0920e12deab895 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:35:24 +0300 Subject: [PATCH 06/19] feat: edit todos in the full composer --- src/App.vue | 274 +++++++------------------- src/nativeInput.ts | 48 ----- src/todoKeyboardAvoidance.ts | 179 ----------------- tests/native-input.test.ts | 55 ------ tests/todo-keyboard-avoidance.test.ts | 270 ------------------------- tests/web-regressions.test.ts | 192 ++++++++++++++---- 6 files changed, 225 insertions(+), 793 deletions(-) delete mode 100644 src/nativeInput.ts delete mode 100644 src/todoKeyboardAvoidance.ts delete mode 100644 tests/native-input.test.ts delete mode 100644 tests/todo-keyboard-avoidance.test.ts diff --git a/src/App.vue b/src/App.vue index 7e11cfd..46f42ee 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,7 +9,6 @@ import { } from 'vue-lynx' import './App.css' -import { syncNativeInputOnMount } from './nativeInput.js' import { getElementKeyboardHeight, type NativeKeyboardEvent, @@ -18,7 +17,11 @@ import { createStarterTimeline } from './starterTimeline.js' import { loadTimeline, saveTimeline } from './store.js' import { createTimelineMotionLayout } from './timelineMotion.js' import { getVisibleDays } from './timelineView.js' -import { keepTodoEditAboveKeyboard } from './todoKeyboardAvoidance.js' +import { + commitComposerDraft, + createComposerDraft, + type ComposerIntent, +} from './todoComposer.js' import type { Timeline, Todo } from './types.js' import { getDateDiff, @@ -38,11 +41,15 @@ type AppState = 'LIST' | 'INPUT' const state = ref('LIST') const timeline = ref({}) const showCompleted = ref(true) -const editingId = ref(null) - -// the "new todo" being composed on the add page -const newTodoText = ref('') -const newTodoDate = ref(getTodayDate()) +const composerIntent = ref({ kind: 'create' }) +const composerText = ref('') +const composerDate = ref(getTodayDate()) +const composerTitle = computed(() => + composerIntent.value.kind === 'edit' ? '编辑事项' : '添加事项', +) +const composerSubmitLabel = computed(() => + composerIntent.value.kind === 'edit' ? '保存' : '添加', +) // cross-platform pickers (built from Lynx primitives — work on web + native) const dayPickerOpen = ref(false) @@ -53,28 +60,20 @@ const datePickerOpen = ref(false) // on older iOS hosts; the global event remains as a newer-runtime fallback. const keyboardHeight = ref(0) -// Edit inputs live inside the main timeline instead of the full-screen -// composer. Their keyboard needs extra scroll range plus an explicit scroll -// adjustment; Lynx does not perform either automatically for built-in input. -const editKeyboardHeight = ref(0) -const editKeyboardSpacerHeight = ref(0) -let editAvoidanceGeneration = 0 -let editKeyboardCleanupTimer: ReturnType | undefined - // Fast relative-day choices complement (rather than replace) the full day and // calendar pickers. Fixed choices keep the strip predictable on small screens. const quickDayOffsets = [0, 1, 2, 3, 4, 5, 6, 7] const selectedDayOffset = computed(() => - getDateDiff(newTodoDate.value, getTodayDate()), + getDateDiff(composerDate.value, getTodayDate()), ) // day-type + weekday label for the currently chosen date const dayTypeLabel = computed( - () => `${getDayType(newTodoDate.value)} ${getDay(newTodoDate.value)}`, + () => `${getDayType(composerDate.value)} ${getDay(composerDate.value)}`, ) // short "M月D日" for the date field const prettyDate = computed(() => { - const { month0, day } = parseDate(newTodoDate.value) + const { month0, day } = parseDate(composerDate.value) return `${month0 + 1}月${day}日` }) @@ -84,12 +83,7 @@ const prettyDate = computed(() => { // Lynx CSS px), so it maps 1:1 onto paddingBottom. // https://lynxjs.org/api/elements/built-in/input.html#keyboard-avoidance function onKeyboardStatus(status: string, height: number) { - const nextHeight = status === 'on' ? height : 0 - if (editingId.value) { - setEditKeyboardHeight(editingId.value, nextHeight) - } else { - keyboardHeight.value = nextHeight - } + keyboardHeight.value = status === 'on' ? height : 0 } function onComposerKeyboard(event: NativeKeyboardEvent) { keyboardHeight.value = getElementKeyboardHeight(event) @@ -119,12 +113,6 @@ onMounted(async () => { onUnmounted(() => { removeKbListener?.() - editAvoidanceGeneration += 1 - editingId.value = null - editKeyboardHeight.value = 0 - editKeyboardSpacerHeight.value = 0 - if (editKeyboardCleanupTimer) clearTimeout(editKeyboardCleanupTimer) - editKeyboardCleanupTimer = undefined }) watch(timeline, (tl) => saveTimeline(tl), { deep: true }) @@ -139,9 +127,6 @@ const motionLayout = computed(() => const dayListStyle = computed(() => ({ height: `${motionLayout.value.height}px`, })) -const editKeyboardSpacerStyle = computed(() => ({ - height: `${editKeyboardSpacerHeight.value}px`, -})) function daySlotStyle(dayKey: string) { const offset = motionLayout.value.days[dayKey]?.offset ?? 0 @@ -184,20 +169,38 @@ function genId(): string { } // --- actions --------------------------------------------------------------- -async function openInput() { - newTodoText.value = '' - newTodoDate.value = getTodayDate() +async function openComposer(intent: ComposerIntent) { + const draft = createComposerDraft(timeline.value, intent, getTodayDate()) + composerIntent.value = intent + composerText.value = draft.text + composerDate.value = draft.date + dayPickerOpen.value = false + datePickerOpen.value = false + keyboardHeight.value = 0 state.value = 'INPUT' await nextTick() - setComposerValue(newTodoText.value) + setComposerValue(composerText.value) focusComposer() } -function closeInput() { - state.value = 'LIST' + +async function openCreateComposer() { + await openComposer({ kind: 'create' }) } -function toggleInput() { - if (state.value === 'LIST') openInput() - else closeInput() + +async function openTodoEditor(dayKey: string, todo: Todo) { + await openComposer({ + kind: 'edit', + todoId: todo.id, + sourceDate: dayKey, + }) +} + +function closeComposer() { + dismissKb() + dayPickerOpen.value = false + datePickerOpen.value = false + keyboardHeight.value = 0 + state.value = 'LIST' } // keyboard dismiss: blur the textarea (hides the soft keyboard) @@ -270,8 +273,7 @@ function onAddTouchEnd(e: { const up = swipeStartY - t.clientY const dx = Math.abs(t.clientX - swipeStartX) if (up > 80 && up > dx) { - dismissKb() - closeInput() + closeComposer() } } @@ -286,147 +288,25 @@ function openDatePicker() { } function pickQuickDay(offset: number) { - newTodoDate.value = getDiffDate(offset) + composerDate.value = getDiffDate(offset) } -function addTodo() { - dismissKb() - const date = newTodoDate.value - const dayType = getDateDiff(date, getTodayDate()) - const text = newTodoText.value.trim() || '写点啥呀!' - - const tl = timeline.value - if (!tl[date]) { - tl[date] = { date, todos: [] } - } - tl[date].todos.push({ id: genId(), date, dayType, done: false, text }) - closeInput() +function submitComposer() { + const nextTimeline = commitComposerDraft( + timeline.value, + composerIntent.value, + { text: composerText.value, date: composerDate.value }, + { today: getTodayDate(), idFactory: genId }, + ) + timeline.value = nextTimeline + closeComposer() } function checkTodo(todo: Todo) { todo.done = !todo.done } -// tap a todo's body → swap to the edit ; v-focus (below) focuses it -// on mount so it edits in a single tap. -function startEdit(todo: Todo) { - cancelEditKeyboardCleanup() - editAvoidanceGeneration += 1 - editingId.value = todo.id -} - -function cancelEditKeyboardCleanup() { - if (!editKeyboardCleanupTimer) return - clearTimeout(editKeyboardCleanupTimer) - editKeyboardCleanupTimer = undefined -} - -// Keep the dummy until iOS finishes its keyboard dismissal animation. Clearing -// the scroll range immediately on blur can clamp the list before the keyboard -// has left the screen and produces a visible jump. -function scheduleEditKeyboardCleanup() { - cancelEditKeyboardCleanup() - const generation = ++editAvoidanceGeneration - editKeyboardCleanupTimer = setTimeout(() => { - if (generation !== editAvoidanceGeneration) return - editKeyboardHeight.value = 0 - editKeyboardSpacerHeight.value = 0 - editKeyboardCleanupTimer = undefined - }, 320) -} - -function setEditKeyboardHeight(todoId: string, height: number) { - if (editingId.value !== todoId) return - - if (!Number.isFinite(height) || height <= 0) { - scheduleEditKeyboardCleanup() - return - } - - cancelEditKeyboardCleanup() - editKeyboardHeight.value = height - // Expand first so even the last row has enough range when measurement and - // scrollTo run. The measured plan can reduce this for a viewport whose - // bottom already sits above the screen bottom. - editKeyboardSpacerHeight.value = height - const generation = ++editAvoidanceGeneration - - if (typeof lynx === 'undefined') return - - void keepTodoEditAboveKeyboard({ - inputId: todoId, - keyboardHeight: height, - nextTick, - createSelectorQuery: () => - (lynx as unknown as { createSelectorQuery: () => any }) - .createSelectorQuery(), - isCurrent: () => - generation === editAvoidanceGeneration && editingId.value === todoId, - onSpacerHeight: (spacerHeight) => { - if ( - generation === editAvoidanceGeneration && - editingId.value === todoId - ) { - editKeyboardSpacerHeight.value = spacerHeight - } - }, - }).catch(() => { - /* input disappeared or the host lacks a query method — never red-screen */ - }) -} - -function onEditFocus(todoId: string) { - if (editingId.value !== todoId || editKeyboardHeight.value <= 0) return - setEditKeyboardHeight(todoId, editKeyboardHeight.value) -} - -function onEditKeyboard(todoId: string, event: NativeKeyboardEvent) { - setEditKeyboardHeight(todoId, getElementKeyboardHeight(event)) -} - -// Seed and focus the edit input after it exists in the native tree. vue-lynx -// 0.4.0 only pushes the mounted `value` attribute, which iOS ignores once the -// control is live, so setValue must run before focus (vue-lynx #203). -const vFocus = { - mounted( - el: { focus?: () => void }, - binding: { value?: { id?: string; value?: string } }, - ) { - const id = binding.value?.id - if (!id) return - - void syncNativeInputOnMount({ - el, - id, - value: binding.value?.value ?? '', - nextTick, - createSelectorQuery: - typeof lynx === 'undefined' - ? undefined - : () => - (lynx as unknown as { createSelectorQuery: () => any }) - .createSelectorQuery(), - }).catch(() => { - /* ignore — falls back to tapping the field again */ - }) - }, -} - -function finishEdit(dayKey: string, todo: Todo) { - if (editingId.value !== todo.id) return - editingId.value = null - scheduleEditKeyboardCleanup() - todo.text = todo.text.trim() - if (!todo.text) { - removeTodo(dayKey, todo.id) - } -} - function removeTodo(dayKey: string, id: string) { - if (editingId.value === id) { - editingId.value = null - scheduleEditKeyboardCleanup() - } const day = timeline.value[dayKey] if (!day) return day.todos = day.todos.filter((todo) => todo.id !== id) @@ -520,7 +400,6 @@ function removeTodo(dayKey: string, id: string) { @@ -539,28 +418,15 @@ function removeTodo(dayKey: string, id: string) { > - - + {{ todo.text }} - - - + @@ -612,10 +474,10 @@ function removeTodo(dayKey: string, id: string) { @touchend="onAddTouchEnd" > - + - 添加事项 + {{ composerTitle }} @@ -623,7 +485,7 @@ function removeTodo(dayKey: string, id: string) { id="addpage-ta" ref="taEl" class="addpage-input" - v-model="newTodoText" + v-model="composerText" placeholder="又有事情忙啦?" @keyboard="onComposerKeyboard" @keyboardheightchange="onComposerKeyboard" @@ -664,8 +526,8 @@ function removeTodo(dayKey: string, id: string) { {{ prettyDate }} 📅 - - 添加 + + {{ composerSubmitLabel }} @@ -674,12 +536,12 @@ function removeTodo(dayKey: string, id: string) { diff --git a/src/nativeInput.ts b/src/nativeInput.ts deleted file mode 100644 index c6fe0a9..0000000 --- a/src/nativeInput.ts +++ /dev/null @@ -1,48 +0,0 @@ -type InvokeOptions = { - method: 'setValue' | 'focus' - params?: { value: string } - fail: () => void -} - -type SelectedElement = { - invoke: (options: InvokeOptions) => SelectedElement - exec: () => void -} - -type SelectorQuery = { - select: (selector: string) => SelectedElement -} - -type SyncNativeInputOptions = { - el: { focus?: () => void } - id: string - value: string - nextTick: () => Promise - createSelectorQuery?: () => SelectorQuery - fail?: () => void -} - -export async function syncNativeInputOnMount( - options: SyncNativeInputOptions, -): Promise { - const { el, id, value, nextTick, createSelectorQuery } = options - const fail = options.fail ?? (() => {}) - - await nextTick() - - if (createSelectorQuery && id) { - createSelectorQuery() - .select(`#${id}`) - .invoke({ method: 'setValue', params: { value }, fail }) - .exec() - } - - el.focus?.() - - if (createSelectorQuery && id) { - createSelectorQuery() - .select(`#${id}`) - .invoke({ method: 'focus', fail }) - .exec() - } -} diff --git a/src/todoKeyboardAvoidance.ts b/src/todoKeyboardAvoidance.ts deleted file mode 100644 index 2f4a3c4..0000000 --- a/src/todoKeyboardAvoidance.ts +++ /dev/null @@ -1,179 +0,0 @@ -export const TODO_EDIT_KEYBOARD_GAP = 16 - -export interface TodoKeyboardAvoidanceGeometry { - rootBottom: number - viewportBottom: number - inputBottom: number - scrollY: number - keyboardHeight: number - gap?: number -} - -export interface TodoKeyboardAvoidancePlan { - spacerHeight: number - visibleBottom: number - overlap: number - targetScrollY: number | null -} - -export function calculateTodoKeyboardAvoidance( - geometry: TodoKeyboardAvoidanceGeometry, -): TodoKeyboardAvoidancePlan { - const { - rootBottom, - viewportBottom, - inputBottom, - scrollY, - keyboardHeight, - gap = TODO_EDIT_KEYBOARD_GAP, - } = geometry - - if (!Number.isFinite(keyboardHeight) || keyboardHeight <= 0) { - return { - spacerHeight: 0, - visibleBottom: viewportBottom, - overlap: 0, - targetScrollY: null, - } - } - - const viewportBottomGap = Math.max(0, rootBottom - viewportBottom) - const spacerHeight = Math.max(0, keyboardHeight - viewportBottomGap) - const visibleBottom = Math.min( - viewportBottom, - rootBottom - keyboardHeight, - ) - const overlap = Math.max(0, inputBottom + gap - visibleBottom) - - return { - spacerHeight, - visibleBottom, - overlap, - targetScrollY: - overlap > 0 - ? Math.max(0, Number.isFinite(scrollY) ? scrollY : 0) + overlap - : null, - } -} - -type NativeInvokeOptions = { - method: string - params?: Record - success: (value: unknown) => void - fail: (error: unknown) => void -} - -type NativeSelectedElement = { - invoke: (options: NativeInvokeOptions) => NativeSelectedElement - exec: () => void -} - -type NativeSelectorQuery = { - select: (selector: string) => NativeSelectedElement -} - -export interface KeepTodoEditAboveKeyboardOptions { - inputId: string - keyboardHeight: number - createSelectorQuery: () => NativeSelectorQuery - nextTick: () => Promise - onSpacerHeight: (height: number) => void - isCurrent?: () => boolean - rootSelector?: string - scrollSelector?: string -} - -const SCREEN_RECT_PARAMS = { - relativeTo: 'screen', - androidEnableTransformProps: true, - iosEnableTransformProps: true, - harmonyEnableTransformProps: true, -} as const - -function invokeNative( - createSelectorQuery: () => NativeSelectorQuery, - selector: string, - method: string, - params?: Record, -): Promise { - return new Promise((resolve, reject) => { - const invokeOptions: NativeInvokeOptions = { - method, - success: (value) => resolve(value as T), - fail: reject, - } - if (params) invokeOptions.params = params - - createSelectorQuery() - .select(selector) - .invoke(invokeOptions) - .exec() - }) -} - -export async function keepTodoEditAboveKeyboard( - options: KeepTodoEditAboveKeyboardOptions, -): Promise { - const { - inputId, - keyboardHeight, - createSelectorQuery, - nextTick, - onSpacerHeight, - isCurrent = () => true, - rootSelector = '#app-root', - scrollSelector = '#timeline-scroll', - } = options - - await nextTick() - if (!isCurrent()) return null - - const [rootRect, viewportRect, inputRect, scrollInfo] = await Promise.all([ - invokeNative<{ bottom: number }>( - createSelectorQuery, - rootSelector, - 'boundingClientRect', - SCREEN_RECT_PARAMS, - ), - invokeNative<{ bottom: number }>( - createSelectorQuery, - scrollSelector, - 'boundingClientRect', - SCREEN_RECT_PARAMS, - ), - invokeNative<{ bottom: number }>( - createSelectorQuery, - `#edit-${inputId}`, - 'boundingClientRect', - SCREEN_RECT_PARAMS, - ), - invokeNative<{ scrollY?: number; scrollTop?: number }>( - createSelectorQuery, - scrollSelector, - 'getScrollInfo', - ), - ]) - - if (!isCurrent()) return null - - const plan = calculateTodoKeyboardAvoidance({ - rootBottom: rootRect.bottom, - viewportBottom: viewportRect.bottom, - inputBottom: inputRect.bottom, - scrollY: scrollInfo.scrollY ?? scrollInfo.scrollTop ?? 0, - keyboardHeight, - }) - - onSpacerHeight(plan.spacerHeight) - await nextTick() - if (!isCurrent()) return null - - if (plan.targetScrollY !== null) { - await invokeNative(createSelectorQuery, scrollSelector, 'scrollTo', { - offset: plan.targetScrollY, - smooth: true, - }) - } - - return plan -} diff --git a/tests/native-input.test.ts b/tests/native-input.test.ts deleted file mode 100644 index c30af75..0000000 --- a/tests/native-input.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { syncNativeInputOnMount } from '../src/nativeInput.ts' - -test('seeds a newly mounted native input with the current todo before focusing', async () => { - const calls: unknown[] = [] - const selected = { - invoke(options: unknown) { - calls.push(['invoke', options]) - return this - }, - exec() { - calls.push(['exec']) - }, - } - - await syncNativeInputOnMount({ - el: { - focus() { - calls.push(['web-focus']) - }, - }, - id: 'edit-todo-1', - value: 'existing todo', - nextTick: async () => { - calls.push(['nextTick']) - }, - fail: assert.fail, - createSelectorQuery: () => ({ - select(selector: string) { - calls.push(['select', selector]) - return selected - }, - }), - }) - - assert.deepEqual(calls, [ - ['nextTick'], - ['select', '#edit-todo-1'], - [ - 'invoke', - { - method: 'setValue', - params: { value: 'existing todo' }, - fail: assert.fail, - }, - ], - ['exec'], - ['web-focus'], - ['select', '#edit-todo-1'], - ['invoke', { method: 'focus', fail: assert.fail }], - ['exec'], - ]) -}) diff --git a/tests/todo-keyboard-avoidance.test.ts b/tests/todo-keyboard-avoidance.test.ts deleted file mode 100644 index 5e5c9cf..0000000 --- a/tests/todo-keyboard-avoidance.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { - calculateTodoKeyboardAvoidance, - keepTodoEditAboveKeyboard, -} from '../src/todoKeyboardAvoidance.ts' - -test('scrolls a covered input just above the keyboard', () => { - assert.deepEqual( - calculateTodoKeyboardAvoidance({ - rootBottom: 874, - viewportBottom: 874, - inputBottom: 738, - scrollY: 0, - keyboardHeight: 308, - }), - { - spacerHeight: 308, - visibleBottom: 566, - overlap: 188, - targetScrollY: 188, - }, - ) -}) - -test('does not move a todo that is already visible', () => { - assert.deepEqual( - calculateTodoKeyboardAvoidance({ - rootBottom: 874, - viewportBottom: 874, - inputBottom: 500, - scrollY: 120, - keyboardHeight: 308, - }), - { - spacerHeight: 308, - visibleBottom: 566, - overlap: 0, - targetScrollY: null, - }, - ) -}) - -test('adds overlap to the current absolute scroll position', () => { - const plan = calculateTodoKeyboardAvoidance({ - rootBottom: 874, - viewportBottom: 874, - inputBottom: 610, - scrollY: 180, - keyboardHeight: 308, - }) - - assert.equal(plan.overlap, 60) - assert.equal(plan.targetScrollY, 240) -}) - -test('subtracts a viewport bottom gap from the keyboard spacer', () => { - const plan = calculateTodoKeyboardAvoidance({ - rootBottom: 874, - viewportBottom: 824, - inputBottom: 600, - scrollY: 0, - keyboardHeight: 308, - }) - - assert.equal(plan.spacerHeight, 258) - assert.equal(plan.visibleBottom, 566) - assert.equal(plan.targetScrollY, 50) -}) - -test('invalid or hidden keyboard heights clear avoidance without scrolling', () => { - for (const keyboardHeight of [0, -1, Number.NaN]) { - assert.deepEqual( - calculateTodoKeyboardAvoidance({ - rootBottom: 874, - viewportBottom: 824, - inputBottom: 800, - scrollY: 120, - keyboardHeight, - }), - { - spacerHeight: 0, - visibleBottom: 824, - overlap: 0, - targetScrollY: null, - }, - ) - } -}) - -type InvokeOptions = { - method: string - params?: Record - success?: (value: unknown) => void - fail?: (error: unknown) => void -} - -function createNativeHarness(options?: { - inputBottom?: number - scrollY?: number - scrollTop?: number -}) { - const calls: Array<{ - selector: string - options: InvokeOptions - }> = [] - const rects: Record = { - '#app-root': { bottom: 874 }, - '#timeline-scroll': { bottom: 874 }, - '#edit-todo-1': { bottom: options?.inputBottom ?? 738 }, - } - - return { - calls, - createSelectorQuery() { - let selector = '' - let invocation: InvokeOptions | undefined - - const query = { - select(value: string) { - selector = value - return query - }, - invoke(value: InvokeOptions) { - invocation = value - calls.push({ selector, options: value }) - return query - }, - exec() { - assert.ok(invocation) - if (invocation.method === 'boundingClientRect') { - invocation.success?.(rects[selector]) - } else if (invocation.method === 'getScrollInfo') { - invocation.success?.( - options?.scrollTop === undefined - ? { scrollY: options?.scrollY ?? 0 } - : { scrollTop: options.scrollTop }, - ) - } else if (invocation.method === 'scrollTo') { - invocation.success?.({}) - } else { - invocation.fail?.(new Error(`unexpected method ${invocation.method}`)) - } - }, - } - - return query - }, - } -} - -test('measures transformed screen rects and performs offset-only absolute scrollTo', async () => { - const harness = createNativeHarness({ scrollY: 40 }) - const spacerHeights: number[] = [] - const ticks: string[] = [] - - const plan = await keepTodoEditAboveKeyboard({ - inputId: 'todo-1', - keyboardHeight: 308, - createSelectorQuery: harness.createSelectorQuery, - nextTick: async () => { - ticks.push('tick') - }, - onSpacerHeight: (height) => spacerHeights.push(height), - }) - - assert.deepEqual(plan, { - spacerHeight: 308, - visibleBottom: 566, - overlap: 188, - targetScrollY: 228, - }) - assert.deepEqual(spacerHeights, [308]) - assert.equal(ticks.length, 2) - - const rectCalls = harness.calls.filter( - (call) => call.options.method === 'boundingClientRect', - ) - assert.deepEqual( - rectCalls.map((call) => call.selector).sort(), - ['#app-root', '#edit-todo-1', '#timeline-scroll'], - ) - for (const call of rectCalls) { - assert.deepEqual(call.options.params, { - relativeTo: 'screen', - androidEnableTransformProps: true, - iosEnableTransformProps: true, - harmonyEnableTransformProps: true, - }) - assert.equal(typeof call.options.fail, 'function') - } - - const getScrollInfo = harness.calls.find( - (call) => call.options.method === 'getScrollInfo', - ) - assert.equal(getScrollInfo?.selector, '#timeline-scroll') - assert.equal(typeof getScrollInfo?.options.fail, 'function') - - const scrollTo = harness.calls.find( - (call) => call.options.method === 'scrollTo', - ) - assert.equal(scrollTo?.selector, '#timeline-scroll') - assert.deepEqual(scrollTo?.options.params, { - offset: 228, - smooth: true, - }) - assert.equal(typeof scrollTo?.options.fail, 'function') -}) - -test('keeps the spacer but skips scrollTo when the input is already visible', async () => { - const harness = createNativeHarness({ inputBottom: 500, scrollY: 90 }) - - const plan = await keepTodoEditAboveKeyboard({ - inputId: 'todo-1', - keyboardHeight: 308, - createSelectorQuery: harness.createSelectorQuery, - nextTick: async () => {}, - onSpacerHeight: () => {}, - }) - - assert.equal(plan?.targetScrollY, null) - assert.equal( - harness.calls.some((call) => call.options.method === 'scrollTo'), - false, - ) -}) - -test('uses the scrollTop alias when the native scroll backend omits scrollY', async () => { - const harness = createNativeHarness({ scrollTop: 40 }) - - const plan = await keepTodoEditAboveKeyboard({ - inputId: 'todo-1', - keyboardHeight: 308, - createSelectorQuery: harness.createSelectorQuery, - nextTick: async () => {}, - onSpacerHeight: () => {}, - }) - - assert.equal(plan?.targetScrollY, 228) - const scrollTo = harness.calls.find( - (call) => call.options.method === 'scrollTo', - ) - assert.deepEqual(scrollTo?.options.params, { - offset: 228, - smooth: true, - }) -}) - -test('cancels before scrolling when focus changes during async layout', async () => { - const harness = createNativeHarness() - let current = true - - const plan = await keepTodoEditAboveKeyboard({ - inputId: 'todo-1', - keyboardHeight: 308, - createSelectorQuery: harness.createSelectorQuery, - nextTick: async () => {}, - isCurrent: () => current, - onSpacerHeight: () => { - current = false - }, - }) - - assert.equal(plan, null) - assert.equal( - harness.calls.some((call) => call.options.method === 'scrollTo'), - false, - ) -}) diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index e8937d0..15cb2c6 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -33,6 +33,26 @@ function readOptionalSource(relativePath: string): string { return existsSync(sourceUrl) ? readFileSync(sourceUrl, 'utf8') : '' } +function getFunctionSource(name: string): string { + const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`) + const match = signature.exec(appSource) + assert.ok(match, `expected App.vue to define ${name}()`) + + const start = match.index + const bodyStart = appSource.indexOf('{', start) + assert.notEqual(bodyStart, -1, `expected ${name}() to have a body`) + + let depth = 0 + for (let index = bodyStart; index < appSource.length; index += 1) { + if (appSource[index] === '{') depth += 1 + if (appSource[index] !== '}') continue + depth -= 1 + if (depth === 0) return appSource.slice(start, index + 1) + } + + assert.fail(`expected ${name}() to have a closing brace`) +} + const nativeTextLayoutBackendSource = readOptionalSource( '../src/textLayoutBackend.lynx.ts', ) @@ -334,28 +354,32 @@ test('the composer keeps its picker and adds a legacy-compatible quick-day scrol ) }) -test('only the todo body starts editing and the final row has no duplicate divider', () => { +test('long-pressing only the todo body opens the full editor', () => { + const todoBodyTag = appSource.match(/]*>/)?.[0] + + assert.ok(todoBodyTag) assert.match( - appSource, - /class="checkbox-hit"[\s\S]*?@tap\.stop="checkTodo\(todo\)"/, + todoBodyTag, + /@longpress\.stop="openTodoEditor\(day\.key, todo\)"/, ) + assert.doesNotMatch(todoBodyTag, /@tap(?:\.|=|\s)/) assert.match( appSource, - /class="todo-body"[^>]*@tap="startEdit\(todo\)"/s, + /class="checkbox-hit"[\s\S]*?@tap\.stop="checkTodo\(todo\)"/, ) - assert.doesNotMatch( + assert.match( appSource, - /class="bw-text todo-text"[^>]*@tap="startEdit\(todo\)"/s, + /class="delete"[^>]*@tap\.stop="removeTodo\(day\.key, todo\.id\)"/s, ) assert.match( appSource, - /class="delete"[^>]*@tap\.stop="removeTodo\(day\.key, todo\.id\)"/s, + /]*>[\s\S]*?]*class="bw-text todo-text"[^>]*>[\s\S]*?\{\{ todo\.text \}\}[\s\S]*?<\/text>[\s\S]*?<\/view>/, ) + assert.doesNotMatch(appSource, /class="todo-input"/) +}) + +test('todo rows retain their divider and tap-isolation styling', () => { assert.doesNotMatch(appCss, /\.todo:active/) - assert.doesNotMatch( - appCss, - /\.todo--editing\s*\{[^}]*background-color:/s, - ) assert.match( appSource, /'todo--last':\s*todoIndex\s*===\s*day\.todos\.length\s*-\s*1/, @@ -366,48 +390,146 @@ test('only the todo body starts editing and the final row has no duplicate divid ) }) -test('todo editing routes both native keyboard events into timeline avoidance', () => { - assert.match(appSource, /keepTodoEditAboveKeyboard/) - assert.match(appSource, /const editKeyboardHeight = ref\(0\)/) +test('the full composer owns create and edit drafts and labels', () => { assert.match( appSource, - //, + /from ['"]\.\/todoComposer\.js['"]/, ) + assert.match(appSource, /\btype ComposerIntent\b/) + assert.match(appSource, /\bcreateComposerDraft\b/) + assert.match(appSource, /\bcommitComposerDraft\b/) assert.match( appSource, - /function onKeyboardStatus\([\s\S]*?editingId\.value[\s\S]*?setEditKeyboardHeight/, + /const composerIntent = ref\(\{ kind: ['"]create['"] \}\)/, ) + assert.match(appSource, /const composerText = ref\(['"]['"]\)/) + assert.match( + appSource, + /const composerDate = ref\(getTodayDate\(\)\)/, + ) + assert.match( + appSource, + /const composerTitle = computed\([\s\S]*?kind === ['"]edit['"]\s*\? ['"]编辑事项['"]\s*:\s*['"]添加事项['"]/, + ) + assert.match( + appSource, + /const composerSubmitLabel = computed\([\s\S]*?kind === ['"]edit['"]\s*\? ['"]保存['"]\s*:\s*['"]添加['"]/, + ) + assert.match(appSource, /class="bw-text addpage-title">\{\{ composerTitle \}\}/) + assert.match( + appSource, + /class="bw-text addpage-submit-text">\{\{ composerSubmitLabel \}\}/, + ) + assert.match(appSource, /v-model="composerText"/) + assert.match(appSource, /v-model="composerDate"/) }) -test('timeline gains a temporary keyboard spacer with delayed race-safe cleanup', () => { - assert.match(appSource, /id="app-root"\s+class="app"/) +test('composer drafts are assigned before native setValue and focus', () => { + const openComposer = getFunctionSource('openComposer') + const openCreateComposer = getFunctionSource('openCreateComposer') + const openTodoEditor = getFunctionSource('openTodoEditor') + + assert.match( + openComposer, + /createComposerDraft\(timeline\.value, intent, getTodayDate\(\)\)/, + ) + const intentAssignment = openComposer.indexOf('composerIntent.value = intent') + const textAssignment = openComposer.indexOf('composerText.value = draft.text') + const dateAssignment = openComposer.indexOf('composerDate.value = draft.date') + const stateOpen = openComposer.indexOf("state.value = 'INPUT'") + const tick = openComposer.indexOf('await nextTick()') + const setValue = openComposer.indexOf('setComposerValue(composerText.value)') + const focus = openComposer.indexOf('focusComposer()') + + for (const [label, index] of [ + ['intent assignment', intentAssignment], + ['text assignment', textAssignment], + ['date assignment', dateAssignment], + ['state open', stateOpen], + ['nextTick', tick], + ['setValue', setValue], + ['focus', focus], + ] as const) { + assert.notEqual(index, -1, `expected openComposer() ${label}`) + } + assert.ok(intentAssignment < textAssignment) + assert.ok(textAssignment < dateAssignment) + assert.ok(dateAssignment < stateOpen) + assert.ok(stateOpen < tick) + assert.ok(tick < setValue) + assert.ok(setValue < focus) + assert.match( - appSource, - /\s*/s, + openCreateComposer, + /openComposer\(\{ kind: ['"]create['"] \}\)/, + ) + assert.match( + openTodoEditor, + /openComposer\(\{[\s\S]*?kind: ['"]edit['"][\s\S]*?todoId: todo\.id[\s\S]*?sourceDate: dayKey[\s\S]*?\}\)/, + ) +}) + +test('submitting commits once while cancel and back only discard the draft', () => { + const submitComposer = getFunctionSource('submitComposer') + const closeComposer = getFunctionSource('closeComposer') + + assert.equal( + [...submitComposer.matchAll(/\bcommitComposerDraft\s*\(/g)].length, + 1, + ) + assert.equal( + [...submitComposer.matchAll(/\btimeline\.value\s*=/g)].length, + 1, + ) + assert.match( + submitComposer, + /const nextTimeline = commitComposerDraft\([\s\S]*?timeline\.value = nextTimeline/, ) + assert.match(submitComposer, /today: getTodayDate\(\)/) + assert.match(submitComposer, /idFactory: genId/) + assert.match(submitComposer, /closeComposer\(\)/) + assert.doesNotMatch(closeComposer, /commitComposerDraft/) + assert.match(closeComposer, /dismissKb\(\)/) + assert.match(closeComposer, /dayPickerOpen\.value = false/) + assert.match(closeComposer, /datePickerOpen\.value = false/) + assert.match(closeComposer, /state\.value = ['"]LIST['"]/) assert.match( appSource, - /const editKeyboardSpacerStyle = computed\([\s\S]*?editKeyboardSpacerHeight\.value/, + /class="addpage-back"\s+@tap="closeComposer"/, ) - assert.match(appSource, /setTimeout\([\s\S]*?320/) - assert.match(appSource, /editAvoidanceGeneration/) assert.match( - appCss, - /\.edit-keyboard-spacer\s*\{[^}]*height:\s*0[^}]*width:\s*100%/s, + appSource, + /function onAddTouchEnd\([\s\S]*?closeComposer\(\)/, ) }) -test('unmount invalidates in-flight edit keyboard avoidance work', () => { - const start = appSource.indexOf('onUnmounted(() => {') - const end = appSource.indexOf('\n})', start) - const unmountBlock = appSource.slice(start, end + 3) +test('inline edit state and keyboard avoidance are absent', () => { + assert.doesNotMatch(appSource, /\beditingId\b/) + assert.doesNotMatch(appSource, /edit-keyboard-spacer/) + assert.doesNotMatch(appSource, /\beditKeyboard(?:Height|SpacerHeight|CleanupTimer)\b/) + assert.doesNotMatch(appSource, /\beditAvoidanceGeneration\b/) + assert.doesNotMatch(appSource, /\b(?:schedule|cancel)EditKeyboardCleanup\b/) + assert.doesNotMatch(appSource, /\bsetEditKeyboardHeight\b/) + assert.doesNotMatch(appSource, /\bvFocus\b|v-focus/) + assert.doesNotMatch( + appSource, + /\b(?:startEdit|finishEdit|onEditFocus|onEditKeyboard)\b/, + ) + assert.doesNotMatch(appSource, /nativeInput|todoKeyboardAvoidance/) +}) - assert.notEqual(start, -1) - assert.notEqual(end, -1) - assert.match(unmountBlock, /editAvoidanceGeneration \+= 1/) - assert.match(unmountBlock, /editingId\.value = null/) - assert.match(unmountBlock, /editKeyboardHeight\.value = 0/) - assert.match(unmountBlock, /editKeyboardSpacerHeight\.value = 0/) +test('native keyboard updates remain scoped to the full composer', () => { + const onKeyboardStatus = getFunctionSource('onKeyboardStatus') + + assert.match( + onKeyboardStatus, + /keyboardHeight\.value = status === ['"]on['"] \? height : 0/, + ) + assert.doesNotMatch(onKeyboardStatus, /editing|Edit/) + assert.match( + appSource, + //, + ) }) test('Clear-style motion gives retained todos and day cards explicit slots', () => { From 5163c431b1a61d90d4025e8a71f07e3f42ffb414 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:01:55 +0300 Subject: [PATCH 07/19] fix: harden composer submission and blur --- src/App.vue | 4 +++- tests/web-regressions.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/App.vue b/src/App.vue index 46f42ee..d294823 100644 --- a/src/App.vue +++ b/src/App.vue @@ -243,7 +243,7 @@ function dismissKb() { ;(lynx as unknown as { createSelectorQuery: () => any }) .createSelectorQuery() .select('#addpage-ta') - .invoke({ method: 'blur' }) + .invoke({ method: 'blur', fail: () => {} }) .exec() } } catch { @@ -292,6 +292,8 @@ function pickQuickDay(offset: number) { } function submitComposer() { + if (state.value !== 'INPUT') return + const nextTimeline = commitComposerDraft( timeline.value, composerIntent.value, diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index 15cb2c6..0372680 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -503,6 +503,27 @@ test('submitting commits once while cancel and back only discard the draft', () ) }) +test('composer submission rejects re-entry before committing', () => { + const submitComposer = getFunctionSource('submitComposer') + const stateGuard = submitComposer.indexOf( + "if (state.value !== 'INPUT') return", + ) + const commit = submitComposer.indexOf('commitComposerDraft(') + + assert.notEqual(stateGuard, -1) + assert.notEqual(commit, -1) + assert.ok(stateGuard < commit) +}) + +test('native composer blur absorbs invocation failures', () => { + const dismissKb = getFunctionSource('dismissKb') + + assert.match( + dismissKb, + /\.invoke\(\{\s*method: ['"]blur['"],\s*fail: \(\) => \{\}\s*\}\)/, + ) +}) + test('inline edit state and keyboard avoidance are absent', () => { assert.doesNotMatch(appSource, /\beditingId\b/) assert.doesNotMatch(appSource, /edit-keyboard-spacer/) From 5f6fd6acb3fe70a8ef33c1b9dd3911a1a6a553c6 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:25:59 +0300 Subject: [PATCH 08/19] feat: render uncapped multiline todos --- src/App.css | 56 +++++---- src/App.vue | 175 +++++++++++++++++++++++++- src/starterTimeline.ts | 2 +- tests/starter-timeline.test.ts | 2 +- tests/web-regressions.test.ts | 216 ++++++++++++++++++++++++++++++++- web/index.html | 3 +- 6 files changed, 425 insertions(+), 29 deletions(-) diff --git a/src/App.css b/src/App.css index d0be3ca..58ce62a 100644 --- a/src/App.css +++ b/src/App.css @@ -102,6 +102,7 @@ padding-top: 10px; } .timeline-content { + position: relative; display: linear; linear-direction: column; width: 100%; @@ -128,9 +129,17 @@ top: 0; left: 0; width: 100%; - height: 52px; transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1); } +.todo-width-probe { + position: absolute; + top: 0; + left: 3%; + width: 94%; + height: 52px; + opacity: 0; + pointer-events: none; +} .empty { display: flex; @@ -206,10 +215,8 @@ display: flex; flex-direction: row; align-items: center; - /* fixed height (not min-height) keeps the row geometry identical on native - and web — min-height + body padding produced stray top/bottom gaps on the - first/last row under Lynx's native flex layout. */ - height: 52px; + min-height: 52px; + height: 100%; width: 100%; padding-left: 4px; padding-right: 10px; @@ -259,7 +266,8 @@ .checkbox-hit { flex-shrink: 0; width: 44px; - height: 52px; + min-height: 52px; + height: 100%; margin-right: 3px; display: flex; align-items: center; @@ -286,21 +294,30 @@ } .checkbox-mark--on { opacity: 1; transform: scale(1); } -/* the row height is fixed, so the body just centers its single line (no - vertical padding, which is what caused the native gaps). */ -.todo-body { flex: 1; display: flex; flex-direction: column; justify-content: center; } -.todo-text { font-size: 15px; line-height: 20px; color: #2b2f33; transition: color 0.25s ease; } -.todo-text--done { color: #c2c9d0; text-decoration: line-through; } -.todo-input { +/* Eight pixels above and below the renderer-owned text height match the + 16px row chrome used by todoTextLayout.ts. */ +.todo-body { + flex: 1; + min-width: 0; + height: 100%; + padding-top: 8px; + padding-bottom: 8px; + display: flex; + flex-direction: column; + justify-content: center; +} +.todo-text { width: 100%; - height: 24px; + flex-shrink: 0; font-size: 15px; line-height: 20px; color: #2b2f33; - background-color: transparent; - border: none; - outline: none; + white-space: pre-wrap; + word-break: break-word; + transition: color 0.25s ease, opacity 0.16s ease; } +.todo-body:active .todo-text { opacity: 0.68; } +.todo-text--done { color: #c2c9d0; text-decoration: line-through; } .delete { width: 34px; @@ -314,11 +331,6 @@ .delete-text { color: #d6dbe0; font-size: 15px; } .timeline-spacer { height: calc(104px + env(safe-area-inset-bottom)); } -.edit-keyboard-spacer { - flex-shrink: 0; - height: 0; - width: 100%; -} /* ===== Floating action button ===== */ .fab { @@ -415,7 +427,7 @@ padding-top: 6px; padding-bottom: calc(28px + env(safe-area-inset-bottom)); } -.addpage--keyboard .addpage-bottom { padding-bottom: 8px; } +.addpage--keyboard .addpage-bottom { padding-bottom: 14px; } /* Quick relative-day choices. Fixed-width direct children let both the legacy scroll-x flag and modern scroll-orientation calculate real overflow. */ diff --git a/src/App.vue b/src/App.vue index d294823..e6da87f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,6 +7,10 @@ import { ref, watch, } from 'vue-lynx' +import { + clearTodoTextMeasurementCache, + measureTodoText, +} from '@busyweek/text-layout-backend' import './App.css' import { @@ -16,6 +20,11 @@ import { import { createStarterTimeline } from './starterTimeline.js' import { loadTimeline, saveTimeline } from './store.js' import { createTimelineMotionLayout } from './timelineMotion.js' +import { + TODO_MIN_ROW_HEIGHT, + rowHeightFromLayoutEvent, + rowHeightFromTextHeight, +} from './todoTextLayout.js' import { getVisibleDays } from './timelineView.js' import { commitComposerDraft, @@ -37,6 +46,8 @@ import DayPickerSheet from './components/DayPickerSheet.vue' type AppState = 'LIST' | 'INPUT' +const TODO_WIDTH_FALLBACK = 240 + // --- reactive state (ported from the original `data` object) --------------- const state = ref('LIST') const timeline = ref({}) @@ -60,6 +71,13 @@ const datePickerOpen = ref(false) // on older iOS hosts; the global event remains as a newer-runtime fallback. const keyboardHeight = ref(0) +// Pretext gives the background thread an immediate layout prediction. Native +// renderer measurements below remain authoritative for font/bidi/emoji edges. +const todoTextWidth = ref(TODO_WIDTH_FALLBACK) +const correctedTodoHeights = ref>({}) +const todoTextLayoutRevision = ref(0) +const lastTodoLayoutHeights = new Map() + // Fast relative-day choices complement (rather than replace) the full day and // calendar pickers. Fixed choices keep the strip predictable on small screens. const quickDayOffsets = [0, 1, 2, 3, 4, 5, 6, 7] @@ -107,12 +125,16 @@ function bindKeyboard() { // --- load & persist -------------------------------------------------------- onMounted(async () => { bindKeyboard() + await nextTick() + measureTodoWidthProbe() const stored = await loadTimeline() timeline.value = stored ?? createStarterTimeline(getTodayDate()) }) onUnmounted(() => { removeKbListener?.() + lastTodoLayoutHeights.clear() + clearTodoTextMeasurementCache() }) watch(timeline, (tl) => saveTimeline(tl), { deep: true }) @@ -121,8 +143,32 @@ watch(timeline, (tl) => saveTimeline(tl), { deep: true }) const visibleDays = computed(() => getVisibleDays(timeline.value, showCompleted.value), ) +const predictedTodoHeights = computed(() => { + const heights: Record = {} + + for (const day of visibleDays.value) { + for (const todo of day.todos) { + const measurement = measureTodoText(todo.text, todoTextWidth.value) + const predictedHeight = rowHeightFromTextHeight( + measurement?.textHeight ?? 0, + ) + const correctedHeight = correctedTodoHeights.value[todo.id] + + heights[todo.id] = + typeof correctedHeight === 'number' && + Number.isFinite(correctedHeight) + ? correctedHeight + : predictedHeight + } + } + + return heights +}) const motionLayout = computed(() => - createTimelineMotionLayout(visibleDays.value), + createTimelineMotionLayout( + visibleDays.value, + predictedTodoHeights.value, + ), ) const dayListStyle = computed(() => ({ height: `${motionLayout.value.height}px`, @@ -140,7 +186,27 @@ function dayTodosStyle(dayKey: string) { function todoSlotStyle(dayKey: string, todoId: string) { const offset = motionLayout.value.days[dayKey]?.todoOffsets[todoId] ?? 0 - return { transform: `translateY(${offset}px)` } + const height = resolveTodoLayoutHeight(dayKey, todoId) + return { + height: `${height}px`, + transform: `translateY(${offset}px)`, + } +} + +function todoRowStyle(dayKey: string, todoId: string) { + const height = resolveTodoLayoutHeight(dayKey, todoId) + return { height: `${height}px` } +} + +function resolveTodoLayoutHeight(dayKey: string, todoId: string): number { + const height = motionLayout.value.days[dayKey]?.todoHeights[todoId] + + if (typeof height === 'number' && Number.isFinite(height)) { + lastTodoLayoutHeights.set(todoId, height) + return height + } + + return lastTodoLayoutHeights.get(todoId) ?? TODO_MIN_ROW_HEIGHT } const isEmpty = computed(() => visibleDays.value.length === 0) @@ -168,6 +234,87 @@ function genId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } +function normalizeTodoWidth(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : null +} + +function updateTodoTextWidth(width: number) { + if (!Number.isFinite(width) || width <= 0) return + if (Math.abs(width - todoTextWidth.value) <= 0.5) return + + todoTextWidth.value = width + correctedTodoHeights.value = {} + // Uncapped x-text emits `layout` when connected, but does not observe width + // changes. Remount only its measurement node so renderer authority returns. + todoTextLayoutRevision.value += 1 +} + +function todoTextLayoutKey(todoId: string): string { + return `${todoId}:${todoTextLayoutRevision.value}` +} + +function onTodoWidthProbeLayout(event: { + detail?: { width?: unknown; size?: { width?: unknown } } +}) { + const width = + normalizeTodoWidth(event.detail?.width) ?? + normalizeTodoWidth(event.detail?.size?.width) + if (width !== null) updateTodoTextWidth(width) +} + +function measureTodoWidthProbe() { + try { + if (typeof lynx === 'undefined') return + ;(lynx as unknown as { createSelectorQuery: () => any }) + .createSelectorQuery() + .select('#todo-width-probe-body') + .invoke({ + method: 'boundingClientRect', + success: (result: { + width?: unknown + data?: { width?: unknown } + }) => { + const width = + normalizeTodoWidth(result?.width) ?? + normalizeTodoWidth(result?.data?.width) + if (width !== null) updateTodoTextWidth(width) + }, + fail: () => {}, + }) + .exec() + } catch { + /* The stable 240px fallback remains when SelectorQuery is unavailable. */ + } +} + +function onTodoTextLayout(todoId: string, event: unknown) { + const height = rowHeightFromLayoutEvent(event) + if (height === null) return + + const currentHeight = correctedTodoHeights.value[todoId] + if ( + typeof currentHeight === 'number' && + Math.abs(currentHeight - height) <= 0.5 + ) { + return + } + + correctedTodoHeights.value = { + ...correctedTodoHeights.value, + [todoId]: height, + } +} + +function clearCorrectedTodoHeight(todoId: string) { + if (!(todoId in correctedTodoHeights.value)) return + + const nextHeights = { ...correctedTodoHeights.value } + delete nextHeights[todoId] + correctedTodoHeights.value = nextHeights +} + // --- actions --------------------------------------------------------------- async function openComposer(intent: ComposerIntent) { const draft = createComposerDraft(timeline.value, intent, getTodayDate()) @@ -300,6 +447,9 @@ function submitComposer() { { text: composerText.value, date: composerDate.value }, { today: getTodayDate(), idFactory: genId }, ) + if (composerIntent.value.kind === 'edit') { + clearCorrectedTodoHeight(composerIntent.value.todoId) + } timeline.value = nextTimeline closeComposer() } @@ -357,6 +507,24 @@ function removeTodo(dayKey: string, id: string) { :scroll-y="true" > + + + + + + + + + @@ -401,6 +569,7 @@ function removeTodo(dayKey: string, id: string) { > {{ todo.text }} diff --git a/src/starterTimeline.ts b/src/starterTimeline.ts index 0085235..5faed74 100644 --- a/src/starterTimeline.ts +++ b/src/starterTimeline.ts @@ -2,7 +2,7 @@ import type { Timeline } from './types.js' const STARTER_TODO_TEXTS = [ '右上角可显示或隐藏已完成', - '点击文字编辑事项', + '长按事项可编辑内容和日期', '点击圆圈完成事项', ] as const diff --git a/tests/starter-timeline.test.ts b/tests/starter-timeline.test.ts index e33904d..9578cf1 100644 --- a/tests/starter-timeline.test.ts +++ b/tests/starter-timeline.test.ts @@ -9,7 +9,7 @@ test('creates three pending instructional todos for the supplied date', () => { assert.deepEqual(todos.map((todo) => todo.text), [ '右上角可显示或隐藏已完成', - '点击文字编辑事项', + '长按事项可编辑内容和日期', '点击圆圈完成事项', ]) assert.ok(todos.every((todo) => todo.date === '2026-07-14')) diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index 0372680..39f403c 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -300,7 +300,7 @@ test('the composer uses the native textarea placeholder and compact keyboard spa ) assert.match( appCss, - /\.addpage--keyboard\s+\.addpage-bottom\s*\{[^}]*padding-bottom:\s*8px/s, + /\.addpage--keyboard\s+\.addpage-bottom\s*\{[^}]*padding-bottom:\s*14px/s, ) assert.match( appCss, @@ -567,6 +567,220 @@ test('Clear-style motion gives retained todos and day cards explicit slots', () assert.match(appCss, /\.day-enter-active\s+\.day-group/) }) +test('todo heights are predicted through the platform facade and renderer geometry', () => { + assert.match( + appSource, + /import\s*\{[\s\S]*?clearTodoTextMeasurementCache[\s\S]*?measureTodoText[\s\S]*?\}\s*from\s*['"]@busyweek\/text-layout-backend['"]/, + ) + assert.match( + appSource, + /import\s*\{[\s\S]*?rowHeightFromLayoutEvent[\s\S]*?rowHeightFromTextHeight[\s\S]*?\}\s*from\s*['"]\.\/todoTextLayout\.js['"]/, + ) + assert.match(appSource, /const TODO_WIDTH_FALLBACK = 240/) + assert.match( + appSource, + /const todoTextWidth = ref\(TODO_WIDTH_FALLBACK\)/, + ) + assert.match( + appSource, + /const correctedTodoHeights = ref>\(\{\}\)/, + ) + assert.match( + appSource, + /const predictedTodoHeights = computed\([\s\S]*?visibleDays\.value[\s\S]*?measureTodoText\(todo\.text,\s*todoTextWidth\.value\)[\s\S]*?rowHeightFromTextHeight\(\s*measurement\?\.textHeight\s*\?\?\s*0,?\s*\)[\s\S]*?correctedTodoHeights\.value\[todo\.id\]/, + ) + assert.match( + appSource, + /createTimelineMotionLayout\(\s*visibleDays\.value,\s*predictedTodoHeights\.value,?\s*\)/, + ) + assert.match( + appSource, + /onUnmounted\([\s\S]*?clearTodoTextMeasurementCache\(\)/, + ) +}) + +test('one invisible exact-geometry probe resolves the todo text width', () => { + assert.equal( + [...appSource.matchAll(/class="todo-width-probe"/g)].length, + 1, + ) + assert.match( + appSource, + /class="todo-width-probe"[^>]*accessibility-element="false"[^>]*user-interaction-enabled="false"/, + ) + assert.match( + appSource, + /class="todo-width-probe"[\s\S]*?class="checkbox-hit"[\s\S]*?id="todo-width-probe-body"[\s\S]*?class="todo-body"[\s\S]*?@layoutchange="onTodoWidthProbeLayout"[\s\S]*?class="delete"/, + ) + assert.match( + appSource, + /event[\s\S]*?\.detail\?\.width[\s\S]*?\.detail\?\.size\?\.width/, + ) + + const measureTodoWidthProbe = getFunctionSource('measureTodoWidthProbe') + assert.match(measureTodoWidthProbe, /createSelectorQuery\(\)/) + assert.match( + measureTodoWidthProbe, + /\.select\(['"]#todo-width-probe-body['"]\)/, + ) + assert.match( + measureTodoWidthProbe, + /\.invoke\(\{[\s\S]*?method:\s*['"]boundingClientRect['"][\s\S]*?success:[\s\S]*?fail:\s*\(\)\s*=>\s*\{\}/, + ) + assert.match( + appSource, + /onMounted\(async\s*\(\)\s*=>\s*\{[\s\S]*?await nextTick\(\)[\s\S]*?measureTodoWidthProbe\(\)/, + ) + assert.match( + appSource, + /Math\.abs\(width\s*-\s*todoTextWidth\.value\)\s*<=\s*0\.5/, + ) + assert.match( + appSource, + /todoTextWidth\.value\s*=\s*width[\s\S]*?correctedTodoHeights\.value\s*=\s*\{\}/, + ) +}) + +test('renderer layout corrects predictions without stale edit heights or loops', () => { + assert.match( + appSource, + / { + assert.match( + appSource, + /const todoTextLayoutRevision = ref\(0\)/, + ) + + const updateTodoTextWidth = getFunctionSource('updateTodoTextWidth') + const correctionReset = updateTodoTextWidth.indexOf( + 'correctedTodoHeights.value = {}', + ) + const revisionIncrement = updateTodoTextWidth.indexOf( + 'todoTextLayoutRevision.value += 1', + ) + assert.notEqual(correctionReset, -1) + assert.notEqual(revisionIncrement, -1) + assert.ok(correctionReset < revisionIncrement) + + const todoTextLayoutKey = getFunctionSource('todoTextLayoutKey') + assert.match(todoTextLayoutKey, /todoTextLayoutRevision\.value/) + assert.match( + appSource, + / { + const todoSlotStyle = getFunctionSource('todoSlotStyle') + const todoRowStyle = getFunctionSource('todoRowStyle') + const resolveTodoLayoutHeight = getFunctionSource( + 'resolveTodoLayoutHeight', + ) + + assert.match(todoSlotStyle, /transform:\s*`translateY\(\$\{offset\}px\)`/) + assert.match(todoSlotStyle, /height:\s*`\$\{height\}px`/) + assert.match(todoRowStyle, /height:\s*`\$\{height\}px`/) + assert.match( + resolveTodoLayoutHeight, + /motionLayout\.value\.days\[dayKey\]\?\.todoHeights\[todoId\]/, + ) + assert.match( + resolveTodoLayoutHeight, + /lastTodoLayoutHeights\.get\(todoId\)/, + ) + assert.match( + appSource, + /class="todo-slot"[\s\S]*?:style="todoSlotStyle\(day\.key, todo\.id\)"/, + ) + assert.match( + appSource, + /class="todo"[\s\S]*?:style="todoRowStyle\(day\.key, todo\.id\)"/, + ) +}) + +test('multiline todo CSS is uncapped and the measurement probe is inert', () => { + const todoSlotRule = appCss.match(/\.todo-slot\s*\{([^}]*)\}/s)?.[1] + const todoRule = appCss.match(/\.todo\s*\{([^}]*)\}/s)?.[1] + const checkboxHitRule = appCss.match(/\.checkbox-hit\s*\{([^}]*)\}/s)?.[1] + const todoBodyRule = appCss.match(/\.todo-body\s*\{([^}]*)\}/s)?.[1] + const todoTextRule = appCss.match(/\.todo-text\s*\{([^}]*)\}/s)?.[1] + const probeRule = appCss.match(/\.todo-width-probe\s*\{([^}]*)\}/s)?.[1] + + assert.ok(todoSlotRule) + assert.ok(todoRule) + assert.ok(checkboxHitRule) + assert.ok(todoBodyRule) + assert.ok(todoTextRule) + assert.ok(probeRule) + assert.doesNotMatch(todoSlotRule, /(?:^|;)\s*height:\s*52px/) + assert.match(todoRule, /min-height:\s*52px/) + assert.match(todoRule, /height:\s*100%/) + assert.doesNotMatch(todoRule, /(?:^|[;\n])\s*height:\s*52px/) + assert.match(checkboxHitRule, /min-height:\s*52px/) + assert.match(checkboxHitRule, /height:\s*100%/) + assert.doesNotMatch(checkboxHitRule, /(?:^|[;\n])\s*height:\s*52px/) + assert.match(todoBodyRule, /padding-top:\s*8px/) + assert.match(todoBodyRule, /padding-bottom:\s*8px/) + assert.match(todoTextRule, /width:\s*100%/) + assert.match(todoTextRule, /flex-shrink:\s*0/) + assert.match(todoTextRule, /line-height:\s*20px/) + assert.match(todoTextRule, /word-break:\s*break-word/) + assert.doesNotMatch(todoTextRule, /max-height|max-lines|text-overflow|ellipsis/) + assert.match(probeRule, /position:\s*absolute/) + assert.match(probeRule, /width:\s*94%/) + assert.match(probeRule, /left:\s*3%/) + assert.match(probeRule, /opacity:\s*0/) + assert.match(probeRule, /pointer-events:\s*none/) + assert.match( + appCss, + /\.todo-body:active\s+\.todo-text\s*\{[^}]*(?:opacity|color):/s, + ) +}) + +test('obsolete inline-edit selectors are absent from app and Web host CSS', () => { + for (const source of [appCss, webHost]) { + assert.doesNotMatch(source, /\.(?:todo-input|todo--editing|edit-keyboard-spacer)\b/) + } +}) + test('Clear-style removal keeps the departing layer above movers and avoids empty-state pushdown', () => { assert.match( appCss, diff --git a/web/index.html b/web/index.html index 52d2ae1..7ae33cb 100644 --- a/web/index.html +++ b/web/index.html @@ -279,8 +279,7 @@ opacity: 0; transition: opacity 180ms ease, background-color 180ms ease; } - .todo:hover .delete:not([l-e-name]), - .todo--editing .delete:not([l-e-name]) { opacity: 1; } + .todo:hover .delete:not([l-e-name]) { opacity: 1; } } @media all and (min-width: 1000px) { From b6e00bfafdbe70d072ab290253a24dadef2707c0 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:14:41 +0300 Subject: [PATCH 09/19] fix: refresh multiline layout across edits --- src/App.css | 4 +- src/App.vue | 61 ++++++++++++++- src/textLayoutBackend.lynx.ts | 2 +- tests/web-regressions.test.ts | 136 ++++++++++++++++++++++++++++++++-- web/index.html | 6 ++ 5 files changed, 196 insertions(+), 13 deletions(-) diff --git a/src/App.css b/src/App.css index 58ce62a..2284c29 100644 --- a/src/App.css +++ b/src/App.css @@ -312,8 +312,8 @@ font-size: 15px; line-height: 20px; color: #2b2f33; - white-space: pre-wrap; - word-break: break-word; + white-space: normal; + word-break: break-all; transition: color 0.25s ease, opacity 0.16s ease; } .todo-body:active .todo-text { opacity: 0.68; } diff --git a/src/App.vue b/src/App.vue index e6da87f..3a7df3a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -45,6 +45,10 @@ import DatePickerSheet from './components/DatePickerSheet.vue' import DayPickerSheet from './components/DayPickerSheet.vue' type AppState = 'LIST' | 'INPUT' +type TodoTextLayoutBinding = { + key: string + onLayout: (event: unknown) => void +} const TODO_WIDTH_FALLBACK = 240 @@ -76,6 +80,8 @@ const keyboardHeight = ref(0) const todoTextWidth = ref(TODO_WIDTH_FALLBACK) const correctedTodoHeights = ref>({}) const todoTextLayoutRevision = ref(0) +const todoTextEditGenerations = new Map() +const todoTextLayoutBindings = new Map() const lastTodoLayoutHeights = new Map() // Fast relative-day choices complement (rather than replace) the full day and @@ -240,6 +246,22 @@ function normalizeTodoWidth(value: unknown): number | null { : null } +function createTodoTextLayoutToken( + todoId: string, + editGeneration: number, + widthRevision: number, +): string { + return `${todoId}:${editGeneration}:${widthRevision}` +} + +function isCurrentTodoTextLayoutBinding( + bindings: Map, + todoId: string, + binding: TodoTextLayoutBinding, +): boolean { + return bindings.get(todoId) === binding +} + function updateTodoTextWidth(width: number) { if (!Number.isFinite(width) || width <= 0) return if (Math.abs(width - todoTextWidth.value) <= 0.5) return @@ -249,10 +271,39 @@ function updateTodoTextWidth(width: number) { // Uncapped x-text emits `layout` when connected, but does not observe width // changes. Remount only its measurement node so renderer authority returns. todoTextLayoutRevision.value += 1 + todoTextLayoutBindings.clear() } -function todoTextLayoutKey(todoId: string): string { - return `${todoId}:${todoTextLayoutRevision.value}` +function refreshTodoTextLayoutAfterEdit(todoId: string) { + const currentGeneration = todoTextEditGenerations.get(todoId) ?? 0 + todoTextEditGenerations.set(todoId, currentGeneration + 1) + todoTextLayoutBindings.delete(todoId) +} + +function getTodoTextLayoutBinding(todoId: string): TodoTextLayoutBinding { + const key = createTodoTextLayoutToken( + todoId, + todoTextEditGenerations.get(todoId) ?? 0, + todoTextLayoutRevision.value, + ) + const currentBinding = todoTextLayoutBindings.get(todoId) + if (currentBinding?.key === key) return currentBinding + + const binding: TodoTextLayoutBinding = { + key, + onLayout: (event) => { + if ( + !isCurrentTodoTextLayoutBinding( + todoTextLayoutBindings, + todoId, + binding, + ) + ) return + onTodoTextLayout(todoId, event) + }, + } + todoTextLayoutBindings.set(todoId, binding) + return binding } function onTodoWidthProbeLayout(event: { @@ -448,6 +499,7 @@ function submitComposer() { { today: getTodayDate(), idFactory: genId }, ) if (composerIntent.value.kind === 'edit') { + refreshTodoTextLayoutAfterEdit(composerIntent.value.todoId) clearCorrectedTodoHeight(composerIntent.value.todoId) } timeline.value = nextTimeline @@ -461,6 +513,7 @@ function checkTodo(todo: Todo) { function removeTodo(dayKey: string, id: string) { const day = timeline.value[dayKey] if (!day) return + todoTextLayoutBindings.delete(id) day.todos = day.todos.filter((todo) => todo.id !== id) if (day.todos.length === 0) { delete timeline.value[dayKey] @@ -595,9 +648,9 @@ function removeTodo(dayKey: string, id: string) { > {{ todo.text }} diff --git a/src/textLayoutBackend.lynx.ts b/src/textLayoutBackend.lynx.ts index 91c5c86..11c6921 100644 --- a/src/textLayoutBackend.lynx.ts +++ b/src/textLayoutBackend.lynx.ts @@ -11,7 +11,7 @@ export interface TodoTextMeasurement { } const PLATFORM_FONT = '15px sans-serif' -const WHITE_SPACE = 'pre-wrap' as const +const WHITE_SPACE = 'normal' as const const LINE_HEIGHT = 20 const preparedTextCache = new Map() diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index 39f403c..d575695 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { existsSync, readFileSync } from 'node:fs' import test from 'node:test' +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript' import { loadTimeline, saveTimeline } from '../src/store.ts' @@ -53,6 +54,19 @@ function getFunctionSource(name: string): string { assert.fail(`expected ${name}() to have a closing brace`) } +function getExecutableFunction any>( + name: string, +): T { + const source = transpileModule(getFunctionSource(name), { + compilerOptions: { + module: ModuleKind.None, + target: ScriptTarget.ES2022, + }, + }).outputText + + return Function(`${source}\nreturn ${name}`)() as T +} + const nativeTextLayoutBackendSource = readOptionalSource( '../src/textLayoutBackend.lynx.ts', ) @@ -94,6 +108,14 @@ test('text-layout wrappers use their platform-specific Pretext packages', () => webTextLayoutBackendSource, /from\s*['"]@chenglou\/pretext['"]/, ) + assert.match( + nativeTextLayoutBackendSource, + /const WHITE_SPACE = ['"]normal['"] as const/, + ) + assert.match( + webTextLayoutBackendSource, + /const WHITE_SPACE = ['"]pre-wrap['"] as const/, + ) }) test('the Web text-layout wrapper guards Canvas capabilities and package failures', () => { @@ -641,10 +663,40 @@ test('one invisible exact-geometry probe resolves the todo text width', () => { ) }) +test('todo text layout tokens and binding currentness distinguish every renderer generation', () => { + const createToken = getExecutableFunction< + (todoId: string, editGeneration: number, widthRevision: number) => string + >('createTodoTextLayoutToken') + const isCurrentBinding = getExecutableFunction< + ( + bindings: Map, + todoId: string, + candidate: unknown, + ) => boolean + >('isCurrentTodoTextLayoutBinding') + + const base = createToken('todo-1', 0, 0) + assert.equal(createToken('todo-1', 0, 0), base) + assert.notEqual(createToken('todo-2', 0, 0), base) + assert.notEqual(createToken('todo-1', 1, 0), base) + assert.notEqual(createToken('todo-1', 0, 1), base) + + const bindings = new Map() + const stale = { key: base } + const current = { key: createToken('todo-1', 1, 0) } + bindings.set('todo-1', stale) + assert.equal(isCurrentBinding(bindings, 'todo-1', stale), true) + bindings.set('todo-1', current) + assert.equal(isCurrentBinding(bindings, 'todo-1', stale), false) + assert.equal(isCurrentBinding(bindings, 'todo-1', current), true) + bindings.delete('todo-1') + assert.equal(isCurrentBinding(bindings, 'todo-1', current), false) +}) + test('renderer layout corrects predictions without stale edit heights or loops', () => { assert.match( appSource, - / { +test('per-Todo bindings remount edits and width changes while rejecting stale renderer events', () => { assert.match( appSource, /const todoTextLayoutRevision = ref\(0\)/, ) + assert.match( + appSource, + /const todoTextEditGenerations = new Map\(\)/, + ) + assert.match( + appSource, + /const todoTextLayoutBindings = new Map\(\)/, + ) const updateTodoTextWidth = getFunctionSource('updateTodoTextWidth') const correctionReset = updateTodoTextWidth.indexOf( @@ -699,12 +759,67 @@ test('probe width changes remount text so Web renderer corrections return', () = assert.notEqual(correctionReset, -1) assert.notEqual(revisionIncrement, -1) assert.ok(correctionReset < revisionIncrement) + assert.match(updateTodoTextWidth, /todoTextLayoutBindings\.clear\(\)/) + + const refreshAfterEdit = getFunctionSource( + 'refreshTodoTextLayoutAfterEdit', + ) + assert.match( + refreshAfterEdit, + /todoTextEditGenerations\.get\(todoId\)\s*\?\?\s*0/, + ) + assert.match( + refreshAfterEdit, + /todoTextEditGenerations\.set\(todoId,\s*[^)]*\+\s*1\)/, + ) + assert.match(refreshAfterEdit, /todoTextLayoutBindings\.delete\(todoId\)/) + + const getBinding = getFunctionSource('getTodoTextLayoutBinding') + assert.match( + getBinding, + /createTodoTextLayoutToken\([\s\S]*?todoId[\s\S]*?todoTextEditGenerations\.get\(todoId\)\s*\?\?\s*0[\s\S]*?todoTextLayoutRevision\.value/, + ) + assert.match( + getBinding, + /if \(currentBinding\?\.key === key\) return currentBinding/, + ) + assert.match( + getBinding, + /isCurrentTodoTextLayoutBinding\(\s*todoTextLayoutBindings,\s*todoId,\s*binding,?\s*\)/, + ) + assert.match( + getBinding, + /if\s*\(\s*!isCurrentTodoTextLayoutBinding[\s\S]*?\) return[\s\S]*?onTodoTextLayout\(todoId, event\)/, + ) + + const submitComposer = getFunctionSource('submitComposer') + const editRefresh = submitComposer.indexOf( + 'refreshTodoTextLayoutAfterEdit(composerIntent.value.todoId)', + ) + const correctionClear = submitComposer.indexOf( + 'clearCorrectedTodoHeight(composerIntent.value.todoId)', + ) + const timelineAssignment = submitComposer.indexOf( + 'timeline.value = nextTimeline', + ) + assert.notEqual(editRefresh, -1) + assert.notEqual(correctionClear, -1) + assert.notEqual(timelineAssignment, -1) + assert.ok(editRefresh < timelineAssignment) + assert.ok(correctionClear < timelineAssignment) + + const removeTodo = getFunctionSource('removeTodo') + const removedBindingInvalidation = removeTodo.indexOf( + 'todoTextLayoutBindings.delete(id)', + ) + const todoRemoval = removeTodo.indexOf('day.todos = day.todos.filter') + assert.notEqual(removedBindingInvalidation, -1) + assert.notEqual(todoRemoval, -1) + assert.ok(removedBindingInvalidation < todoRemoval) - const todoTextLayoutKey = getFunctionSource('todoTextLayoutKey') - assert.match(todoTextLayoutKey, /todoTextLayoutRevision\.value/) assert.match( appSource, - / assert.match(todoTextRule, /width:\s*100%/) assert.match(todoTextRule, /flex-shrink:\s*0/) assert.match(todoTextRule, /line-height:\s*20px/) - assert.match(todoTextRule, /word-break:\s*break-word/) + assert.match(todoTextRule, /white-space:\s*normal/) + assert.match(todoTextRule, /word-break:\s*break-all/) + assert.doesNotMatch( + appCss, + /white-space:\s*pre-wrap|word-break:\s*break-word/, + ) assert.doesNotMatch(todoTextRule, /max-height|max-lines|text-overflow|ellipsis/) assert.match(probeRule, /position:\s*absolute/) assert.match(probeRule, /width:\s*94%/) @@ -773,6 +893,10 @@ test('multiline todo CSS is uncapped and the measurement probe is inert', () => appCss, /\.todo-body:active\s+\.todo-text\s*\{[^}]*(?:opacity|color):/s, ) + assert.match( + webHost, + /\.todo-text:not\(\[l-e-name\]\)\s*\{[\s\S]*?white-space:\s*pre-wrap;[\s\S]*?word-break:\s*break-word;[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?\}/, + ) }) test('obsolete inline-edit selectors are absent from app and Web host CSS', () => { diff --git a/web/index.html b/web/index.html index 7ae33cb..a8b8b01 100644 --- a/web/index.html +++ b/web/index.html @@ -220,6 +220,12 @@ color: rgba(255, 255, 255, 0.68); } + .todo-text:not([l-e-name]) { + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + } + @media all and (min-width: 640px) { .timeline:not([l-e-name]) { width: 70%; From 14060698878c288b9b75a17f79bb7e9a04a39d6c Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:29:56 +0300 Subject: [PATCH 10/19] fix: install web wrapping styles before content --- tests/web-regressions.test.ts | 72 ++++++++++++++++++++++++++++++++++- web/index.html | 41 ++++++++++++-------- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index d575695..32dcfe9 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -144,7 +144,77 @@ test('the assembled web runtime maps Lynx textarea to x-textarea', () => { test('the web host enables the x-textarea lynxinput bridge before typing', () => { assert.match(webHost, /querySelector\(['"]#addpage-ta['"]\)/) - assert.match(webHost, /addEventListener\(['"]lynxinput['"]/) + assert.match( + webHost, + /addEventListener\(['"]lynxinput['"]/, + ) +}) + +test('the web host installs wrapping styles before waiting for app content', () => { + const enhancementStart = webHost.indexOf( + '(function installWebEnhancements()', + ) + const enhancementEnd = webHost.indexOf('', enhancementStart) + assert.notEqual(enhancementStart, -1) + assert.notEqual(enhancementEnd, -1) + + const enhancement = webHost.slice(enhancementStart, enhancementEnd) + const installerStart = enhancement.indexOf( + 'function installResponsiveStyles(root)', + ) + const injectStart = enhancement.indexOf('function inject()') + assert.notEqual(installerStart, -1) + assert.notEqual(injectStart, -1) + assert.ok(installerStart < injectStart) + + const installer = enhancement.slice(installerStart, injectStart) + assert.match( + installer, + /getElementById\(['"]busyweek-web-responsive['"]\)[\s\S]*?return/, + ) + assert.match( + installer, + /style\.id\s*=\s*['"]busyweek-web-responsive['"]/, + ) + assert.match(installer, /root\.appendChild\(style\)/) + + const inject = enhancement.slice(injectStart) + const rootRead = inject.indexOf('var root = host.shadowRoot') + const rootWait = inject.indexOf('if (!root)') + const installStyles = inject.indexOf('installResponsiveStyles(root)') + const appWait = inject.indexOf("if (!root.querySelector('.app'))") + const textareaSetup = inject.indexOf( + "var textarea = root.querySelector('#addpage-ta')", + ) + const textareaWait = inject.indexOf('if (!textarea)') + const textareaListener = inject.indexOf( + "textarea.addEventListener('lynxinput'", + ) + for (const index of [ + rootRead, + rootWait, + installStyles, + appWait, + textareaSetup, + textareaWait, + textareaListener, + ]) { + assert.notEqual(index, -1) + } + assert.ok(rootRead < rootWait) + assert.ok(rootWait < installStyles) + assert.ok(installStyles < appWait) + assert.ok(appWait < textareaSetup) + assert.ok(textareaSetup < textareaWait) + assert.ok(textareaWait < textareaListener) + assert.match( + inject.slice(appWait, textareaSetup), + /requestAnimationFrame\(inject\)[\s\S]*?return/, + ) + assert.match( + inject.slice(textareaWait, textareaListener), + /requestAnimationFrame\(inject\)[\s\S]*?return/, + ) }) test('web persistence uses localStorage when the native module is unavailable', async () => { diff --git a/web/index.html b/web/index.html index a8b8b01..ff30910 100644 --- a/web/index.html +++ b/web/index.html @@ -194,24 +194,13 @@ From 492c34329a172accdd470be144461b6d46baa8b0 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:20:53 +0300 Subject: [PATCH 11/19] feat: synthesize todo long press on web --- scripts/assemble-web.mjs | 8 +- tests/web-longpress.test.ts | 585 ++++++++++++++++++++++++++++++++++ tests/web-regressions.test.ts | 84 +++++ web/index.html | 5 +- web/todo-longpress.js | 226 +++++++++++++ 5 files changed, 906 insertions(+), 2 deletions(-) create mode 100644 tests/web-longpress.test.ts create mode 100644 web/todo-longpress.js diff --git a/scripts/assemble-web.mjs b/scripts/assemble-web.mjs index 53918f8..6f2566c 100644 --- a/scripts/assemble-web.mjs +++ b/scripts/assemble-web.mjs @@ -7,7 +7,8 @@ // Run *after* `rspeedy build`, which produces `dist/main.web.bundle`. This: // 1. copies the @lynx-js/web-core prod runtime into `dist/static/` // 2. copies the host page (`web/index.html`) to `dist/index.html` -// 3. copies the original web app into `dist/legacy/`, stubbing its (long +// 3. copies the Web long-press enhancement beside the host page +// 4. copies the original web app into `dist/legacy/`, stubbing its (long // dead) LeanCloud SDK so it boots without the CDN // // The Lynx runtime is bundled locally (no CDN) and all URLs are relative. @@ -41,6 +42,10 @@ const runtimeSrc = path.join( await mkdir(dist, { recursive: true }) await cp(runtimeSrc, path.join(dist, 'static'), { recursive: true }) await copyFile(path.join(root, 'web', 'index.html'), path.join(dist, 'index.html')) +await copyFile( + path.join(root, 'web', 'todo-longpress.js'), + path.join(dist, 'todo-longpress.js'), +) // Web app icons + PWA manifest (cross-platform home-screen / favicon support). await cp(path.join(root, 'web', 'icons'), path.join(dist, 'icons'), { @@ -87,6 +92,7 @@ await writeFile(appJsPath, patched) console.log('✓ Assembled static web site in dist/') console.log(' - dist/index.html (Lynx host page, served at /)') +console.log(' - dist/todo-longpress.js (Web long-press enhancement)') console.log(' - dist/main.web.bundle (Lynx app)') console.log(' - dist/static/ (Lynx web runtime)') console.log(' - dist/legacy/ (original web edition, served at /legacy/)') diff --git a/tests/web-longpress.test.ts b/tests/web-longpress.test.ts new file mode 100644 index 0000000..e99f423 --- /dev/null +++ b/tests/web-longpress.test.ts @@ -0,0 +1,585 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createTodoLongPressGesture, + installTodoLongPress, +} from '../web/todo-longpress.js' + +function createFakeClock() { + let now = 0 + let nextId = 1 + const timers = new Map() + + return { + setTimer(callback, delay) { + const id = nextId + nextId += 1 + timers.set(id, { callback, dueAt: now + delay, id }) + return id + }, + clearTimer(id) { + timers.delete(id) + }, + tick(duration) { + const endAt = now + duration + + while (true) { + const next = [...timers.values()] + .filter((timer) => timer.dueAt <= endAt) + .sort((left, right) => + left.dueAt === right.dueAt + ? left.id - right.id + : left.dueAt - right.dueAt, + )[0] + if (!next) break + + timers.delete(next.id) + now = next.dueAt + next.callback() + } + + now = endAt + }, + pendingCount() { + return timers.size + }, + } +} + +function createHarness() { + const clock = createFakeClock() + const firings = [] + const gesture = createTodoLongPressGesture({ + onLongPress(firing) { + firings.push(firing) + }, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }) + + return { clock, firings, gesture } +} + +test('does not trigger at 499ms and triggers exactly once at 500ms', () => { + const { clock, firings, gesture } = createHarness() + + gesture.start({ pointerId: 1, target: {}, x: 40, y: 80 }) + clock.tick(499) + assert.equal(firings.length, 0) + + clock.tick(1) + assert.equal(firings.length, 1) + + clock.tick(500) + assert.equal(firings.length, 1) +}) + +test('firing carries the original target and start coordinates', () => { + const { clock, firings, gesture } = createHarness() + const target = { name: 'todo body' } + + gesture.start({ pointerId: 7, target, x: 12, y: 34 }) + clock.tick(500) + + assert.equal(firings.length, 1) + assert.equal(firings[0].target, target) + assert.equal(firings[0].pointerId, 7) + assert.equal(firings[0].x, 12) + assert.equal(firings[0].y, 34) +}) + +test('movement farther than 12px cancels, including diagonal movement', () => { + for (const [x, y] of [ + [12.01, 0], + [9, 9], + ]) { + const { clock, firings, gesture } = createHarness() + gesture.start({ pointerId: 1, target: {}, x: 0, y: 0 }) + + gesture.move({ pointerId: 1, x, y }) + clock.tick(500) + + assert.equal(firings.length, 0) + assert.equal(clock.pendingCount(), 0) + } +}) + +test('movement at or within 12px does not cancel', () => { + for (const [x, y] of [ + [12, 0], + [7.2, 9.6], + ]) { + const { clock, firings, gesture } = createHarness() + gesture.start({ pointerId: 1, target: {}, x: 0, y: 0 }) + + gesture.move({ pointerId: 1, x, y }) + clock.tick(500) + + assert.equal(firings.length, 1) + } +}) + +test('matching pointer up ends the gesture before it fires', () => { + const { clock, firings, gesture } = createHarness() + gesture.start({ pointerId: 3, target: {}, x: 0, y: 0 }) + + gesture.end(3) + clock.tick(500) + + assert.equal(firings.length, 0) + assert.equal(clock.pendingCount(), 0) +}) + +test('matching pointer cancellation clears the active gesture', () => { + const { clock, firings, gesture } = createHarness() + gesture.start({ pointerId: 4, target: {}, x: 0, y: 0 }) + + gesture.cancel(4) + clock.tick(500) + + assert.equal(firings.length, 0) + assert.equal(clock.pendingCount(), 0) +}) + +test('general cancellation clears any active gesture', () => { + const { clock, firings, gesture } = createHarness() + gesture.start({ pointerId: 5, target: {}, x: 0, y: 0 }) + + gesture.cancel() + clock.tick(500) + + assert.equal(firings.length, 0) + assert.equal(clock.pendingCount(), 0) +}) + +test('missing or invalid targets cannot start a gesture', () => { + for (const target of [undefined, null, 'todo-body', 42]) { + const { clock, firings, gesture } = createHarness() + + assert.equal( + gesture.start({ pointerId: 1, target, x: 0, y: 0 }), + false, + ) + clock.tick(500) + + assert.equal(firings.length, 0) + assert.equal(clock.pendingCount(), 0) + } +}) + +test('a second pointer cannot steal or replace the active gesture', () => { + const { clock, firings, gesture } = createHarness() + const firstTarget = { id: 'first' } + + assert.equal( + gesture.start({ pointerId: 1, target: firstTarget, x: 10, y: 20 }), + true, + ) + assert.equal( + gesture.start({ pointerId: 2, target: { id: 'second' }, x: 30, y: 40 }), + false, + ) + clock.tick(500) + + assert.equal(firings.length, 1) + assert.equal(firings[0].target, firstTarget) + assert.equal(firings[0].pointerId, 1) +}) + +test('events from another pointer do not move, end, or cancel the active pointer', () => { + const { clock, firings, gesture } = createHarness() + const target = {} + gesture.start({ pointerId: 1, target, x: 0, y: 0 }) + + gesture.move({ pointerId: 2, x: 100, y: 100 }) + gesture.end(2) + gesture.cancel(2) + clock.tick(500) + + assert.equal(firings.length, 1) + assert.equal(firings[0].target, target) +}) + +test('a fired gesture cannot fire twice and ending it resets for the next gesture', () => { + const { clock, firings, gesture } = createHarness() + const firstTarget = { id: 'first' } + const secondTarget = { id: 'second' } + + gesture.start({ pointerId: 1, target: firstTarget, x: 0, y: 0 }) + clock.tick(1_000) + assert.equal(firings.length, 1) + + assert.equal( + gesture.start({ pointerId: 2, target: secondTarget, x: 5, y: 6 }), + false, + ) + gesture.end(1) + assert.equal( + gesture.start({ pointerId: 2, target: secondTarget, x: 5, y: 6 }), + true, + ) + clock.tick(500) + + assert.equal(firings.length, 2) + assert.equal(firings[1].target, secondTarget) +}) + +class FakeEventTarget { + listeners = new Map void>>() + additions: { type: string; capture: boolean }[] = [] + removals: { type: string; capture: boolean }[] = [] + addCount = 0 + removeCount = 0 + + addEventListener( + type: string, + listener: (event: any) => void, + options?: boolean | { capture?: boolean }, + ) { + this.addCount += 1 + this.additions.push({ + type, + capture: + options === true || + (typeof options === 'object' && options?.capture === true), + }) + const listeners = this.listeners.get(type) ?? new Set() + listeners.add(listener) + this.listeners.set(type, listeners) + } + + removeEventListener( + type: string, + listener: (event: any) => void, + options?: boolean | { capture?: boolean }, + ) { + this.removeCount += 1 + this.removals.push({ + type, + capture: + options === true || + (typeof options === 'object' && options?.capture === true), + }) + this.listeners.get(type)?.delete(listener) + } + + emit(type: string, event: any = {}) { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener.call(this, event) + } + } + + listenerCount(type: string) { + return this.listeners.get(type)?.size ?? 0 + } +} + +class FakeCustomEvent { + type: string + detail: unknown + bubbles: boolean + composed: boolean + target: FakeElement | null = null + + constructor( + type: string, + init: { detail?: unknown; bubbles?: boolean; composed?: boolean } = {}, + ) { + this.type = type + this.detail = init.detail + this.bubbles = init.bubbles ?? false + this.composed = init.composed ?? false + } +} + +class FakeWindow extends FakeEventTarget { + CustomEvent = FakeCustomEvent +} + +class FakeDocument extends FakeEventTarget { + defaultView: FakeWindow + + constructor(defaultView: FakeWindow) { + super() + this.defaultView = defaultView + } +} + +class FakeElement extends FakeEventTarget { + ownerDocument: FakeDocument + classes: Set + dispatchedEvents: FakeCustomEvent[] = [] + setPointerCaptureCount = 0 + + constructor(ownerDocument: FakeDocument, classes: string[] = []) { + super() + this.ownerDocument = ownerDocument + this.classes = new Set(classes) + } + + matches(selector: string) { + return selector === '.todo-body' && this.classes.has('todo-body') + } + + dispatchEvent(event: FakeCustomEvent) { + event.target = this + this.dispatchedEvents.push(event) + this.emit(event.type, event) + return true + } + + setPointerCapture() { + this.setPointerCaptureCount += 1 + } +} + +function createInstallerHarness() { + const clock = createFakeClock() + const windowTarget = new FakeWindow() + const documentTarget = new FakeDocument(windowTarget) + const root = new FakeElement(documentTarget) + const todoBody = new FakeElement(documentTarget, ['todo-body']) + const child = new FakeElement(documentTarget) + const outside = new FakeElement(documentTarget) + const timerOptions = { + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + } + + function pointerEvent(overrides: Record = {}) { + return { + pointerId: 1, + isPrimary: true, + button: 0, + clientX: 30, + clientY: 40, + composedPath: () => [child, todoBody, root], + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, + ...overrides, + } + } + + function start(overrides: Record = {}) { + const event = pointerEvent(overrides) + root.emit('pointerdown', event) + return event + } + + return { + child, + clock, + documentTarget, + outside, + pointerEvent, + root, + start, + timerOptions, + todoBody, + windowTarget, + } +} + +test('installer dispatches one composed bubbling longpress at 500ms from the original todo body', () => { + const harness = createInstallerHarness() + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + + const pointerDown = harness.start({ clientX: 17, clientY: 29 }) + harness.clock.tick(499) + assert.equal(harness.todoBody.dispatchedEvents.length, 0) + + harness.clock.tick(1) + assert.equal(harness.todoBody.dispatchedEvents.length, 1) + const event = harness.todoBody.dispatchedEvents[0] + assert.ok(event instanceof FakeCustomEvent) + assert.equal(event.type, 'longpress') + assert.deepEqual(event.detail, { clientX: 17, clientY: 29 }) + assert.equal(event.bubbles, true) + assert.equal(event.composed, true) + assert.equal(event.target, harness.todoBody) + assert.equal(pointerDown.defaultPrevented, false) + assert.equal(harness.todoBody.setPointerCaptureCount, 0) + assert.equal(harness.root.setPointerCaptureCount, 0) + assert.ok(harness.root.additions.every(({ capture }) => capture)) + + harness.clock.tick(500) + assert.equal(harness.todoBody.dispatchedEvents.length, 1) + cleanup() +}) + +test('installer ignores paths without a todo body, non-primary pointers, and nonzero buttons', () => { + const cases = [ + { composedPath: (harness) => [harness.outside, harness.root] }, + { isPrimary: () => false }, + { button: () => 2 }, + ] + + for (const configure of cases) { + const harness = createInstallerHarness() + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + const overrides = Object.fromEntries( + Object.entries(configure).map(([key, value]) => [key, value(harness)]), + ) + + harness.start(overrides) + assert.equal(harness.clock.pendingCount(), 0) + harness.clock.tick(500) + assert.equal(harness.todoBody.dispatchedEvents.length, 0) + cleanup() + } +}) + +test('installer safely ignores missing, invalid, or throwing composed paths', () => { + const harness = createInstallerHarness() + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + const events = [ + { ...harness.pointerEvent(), composedPath: undefined }, + { ...harness.pointerEvent(), composedPath: () => null }, + { + ...harness.pointerEvent(), + composedPath() { + throw new Error('unavailable path') + }, + }, + ] + + for (const event of events) harness.root.emit('pointerdown', event) + assert.equal(harness.clock.pendingCount(), 0) + cleanup() +}) + +test('installer cancels pending long press for every matching pointer and lifecycle signal', () => { + const cancellations = [ + ['movement beyond 12px', (harness) => + harness.root.emit( + 'pointermove', + harness.pointerEvent({ clientX: 43, clientY: 40 }), + )], + ['pointer up', (harness) => + harness.root.emit('pointerup', harness.pointerEvent())], + ['pointer cancel', (harness) => + harness.root.emit('pointercancel', harness.pointerEvent())], + ['lost pointer capture', (harness) => + harness.root.emit('lostpointercapture', harness.pointerEvent())], + ['root scroll', (harness) => + harness.root.emit('scroll', { target: harness.root })], + ['descendant scroll', (harness) => + harness.root.emit('scroll', { target: harness.child })], + ['document visibility change', (harness) => + harness.documentTarget.emit('visibilitychange')], + ['window blur', (harness) => harness.windowTarget.emit('blur')], + ] as const + + for (const [name, cancel] of cancellations) { + const harness = createInstallerHarness() + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + const pointerDown = harness.start() + assert.equal(pointerDown.defaultPrevented, false, name) + assert.equal(harness.clock.pendingCount(), 1, name) + + cancel(harness) + harness.clock.tick(500) + + assert.equal(harness.clock.pendingCount(), 0, name) + assert.equal(harness.todoBody.dispatchedEvents.length, 0, name) + cleanup() + } +}) + +test('installer does not prevent pointer movement while tracking a long press', () => { + const harness = createInstallerHarness() + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + harness.start() + const move = harness.pointerEvent({ clientX: 35, clientY: 45 }) + + harness.root.emit('pointermove', move) + + assert.equal(move.defaultPrevented, false) + assert.equal(harness.clock.pendingCount(), 1) + cleanup() +}) + +test('installer prevents contextmenu only when its composed path contains a todo body', () => { + const harness = createInstallerHarness() + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + const onTodo = harness.pointerEvent() + const outside = harness.pointerEvent({ + composedPath: () => [harness.outside, harness.root], + }) + + harness.root.emit('contextmenu', onTodo) + harness.root.emit('contextmenu', outside) + + assert.equal(onTodo.defaultPrevented, true) + assert.equal(outside.defaultPrevented, false) + cleanup() +}) + +test('installer guards duplicate roots and cleanup is idempotent before reinstall', () => { + const harness = createInstallerHarness() + const firstCleanup = installTodoLongPress(harness.root, harness.timerOptions) + const listenerCounts = { + root: harness.root.addCount, + document: harness.documentTarget.addCount, + window: harness.windowTarget.addCount, + } + harness.start() + + const duplicateCleanup = installTodoLongPress( + harness.root, + harness.timerOptions, + ) + assert.equal(duplicateCleanup, firstCleanup) + assert.deepEqual( + { + root: harness.root.addCount, + document: harness.documentTarget.addCount, + window: harness.windowTarget.addCount, + }, + listenerCounts, + ) + + firstCleanup() + firstCleanup() + assert.equal(harness.clock.pendingCount(), 0) + assert.deepEqual(harness.root.removals, harness.root.additions) + assert.equal(harness.root.listenerCount('pointerdown'), 0) + assert.equal(harness.documentTarget.listenerCount('visibilitychange'), 0) + assert.equal(harness.windowTarget.listenerCount('blur'), 0) + + const reinstalledCleanup = installTodoLongPress( + harness.root, + harness.timerOptions, + ) + assert.notEqual(reinstalledCleanup, firstCleanup) + assert.equal(harness.root.listenerCount('pointerdown'), 1) + assert.equal(harness.documentTarget.listenerCount('visibilitychange'), 1) + assert.equal(harness.windowTarget.listenerCount('blur'), 1) + harness.start() + harness.clock.tick(500) + assert.equal(harness.todoBody.dispatchedEvents.length, 1) + reinstalledCleanup() +}) + +test('installer absorbs unavailable or failing realm CustomEvent support', () => { + for (const CustomEventConstructor of [ + undefined, + class { + constructor() { + throw new Error('CustomEvent unsupported') + } + }, + ]) { + const harness = createInstallerHarness() + harness.windowTarget.CustomEvent = CustomEventConstructor as any + const cleanup = installTodoLongPress(harness.root, harness.timerOptions) + + harness.start() + assert.doesNotThrow(() => harness.clock.tick(500)) + assert.equal(harness.todoBody.dispatchedEvents.length, 0) + cleanup() + } +}) diff --git a/tests/web-regressions.test.ts b/tests/web-regressions.test.ts index 32dcfe9..c124710 100644 --- a/tests/web-regressions.test.ts +++ b/tests/web-regressions.test.ts @@ -21,6 +21,10 @@ const dayPickerCss = readFileSync( ) const storeSource = readFileSync(new URL('../src/store.ts', import.meta.url), 'utf8') const webHost = readFileSync(new URL('../web/index.html', import.meta.url), 'utf8') +const assembleWebSource = readFileSync( + new URL('../scripts/assemble-web.mjs', import.meta.url), + 'utf8', +) const lynxConfigSource = readFileSync( new URL('../lynx.config.ts', import.meta.url), 'utf8', @@ -34,6 +38,25 @@ function readOptionalSource(relativePath: string): string { return existsSync(sourceUrl) ? readFileSync(sourceUrl, 'utf8') : '' } +function getWebEnhancementScript(): { attributes: string; body: string } { + const enhancementStart = webHost.indexOf( + '(function installWebEnhancements()', + ) + const scriptStart = webHost.lastIndexOf('', scriptStart) + 1 + const scriptEnd = webHost.indexOf('', enhancementStart) + + assert.notEqual(enhancementStart, -1) + assert.notEqual(scriptStart, -1) + assert.notEqual(bodyStart, 0) + assert.notEqual(scriptEnd, -1) + + return { + attributes: webHost.slice(scriptStart + ' ) }) +test('the Web enhancement script is an ES module', () => { + const enhancementScript = getWebEnhancementScript() + + assert.match(enhancementScript.attributes, /\btype=["']module["']/) +}) + +test('the Web enhancement module imports the todo long-press installer', () => { + const enhancementScript = getWebEnhancementScript() + + assert.match( + enhancementScript.body, + /import\s*\{\s*installTodoLongPress\s*\}\s*from\s*["']\.\/todo-longpress\.js["']/, + ) +}) + +test('the Web host installs todo long press as soon as the shadow root exists', () => { + const enhancementStart = webHost.indexOf( + '(function installWebEnhancements()', + ) + const enhancementEnd = webHost.indexOf('', enhancementStart) + assert.notEqual(enhancementStart, -1) + assert.notEqual(enhancementEnd, -1) + + const enhancement = webHost.slice(enhancementStart, enhancementEnd) + const injectStart = enhancement.indexOf('function inject()') + assert.notEqual(injectStart, -1) + + const inject = enhancement.slice(injectStart) + const rootRead = inject.indexOf('var root = host.shadowRoot') + const rootWait = inject.indexOf('if (!root)') + const installLongPress = inject.indexOf('installTodoLongPress(root)') + const installStyles = inject.indexOf('installResponsiveStyles(root)') + const appWait = inject.indexOf("if (!root.querySelector('.app'))") + const textareaSetup = inject.indexOf( + "var textarea = root.querySelector('#addpage-ta')", + ) + + for (const index of [ + rootRead, + rootWait, + installLongPress, + installStyles, + appWait, + textareaSetup, + ]) { + assert.notEqual(index, -1) + } + assert.ok(rootRead < rootWait) + assert.ok(rootWait < installLongPress) + assert.ok(installLongPress < installStyles) + assert.ok(installStyles < appWait) + assert.ok(appWait < textareaSetup) +}) + +test('the Web assembler copies the todo long-press module beside index.html', () => { + assert.match( + assembleWebSource, + /copyFile\(\s*path\.join\(root,\s*["']web["'],\s*["']todo-longpress\.js["']\),\s*path\.join\(dist,\s*["']todo-longpress\.js["']\),?\s*\)/, + ) +}) + test('the web host installs wrapping styles before waiting for app content', () => { const enhancementStart = webHost.indexOf( '(function installWebEnhancements()', diff --git a/web/index.html b/web/index.html index ff30910..ac25ed0 100644 --- a/web/index.html +++ b/web/index.html @@ -192,7 +192,9 @@ })() -