diff --git a/docs/dancer-asset.md b/docs/dancer-asset.md new file mode 100644 index 0000000..5636b9b --- /dev/null +++ b/docs/dancer-asset.md @@ -0,0 +1,10 @@ +# BusyWeek dancer asset + +`src/assets/busyweek-dancer.png` is an original 4×4 alarm-clock mascot sprite sheet generated for BusyWeek with OpenAI's built-in image generation tool on 2026-07-17. It does not copy the character or sprite from `Huxpro/lynx-pretext`. + +- Source dimensions after optimization: 768 × 768 RGBA PNG +- Shipped size: approximately 416 KB +- Animation cadence: 12 fps; one presentation is capped at four seconds +- Redistribution: project-owned generated asset, distributed under BusyWeek's ISC license + +The solid chroma background was removed locally. The transparent sheet is translated inside a clipped viewport; no 60 fps video or per-frame asset decode is used. diff --git a/src/App.css b/src/App.css index e811587..81a54e7 100644 --- a/src/App.css +++ b/src/App.css @@ -107,6 +107,50 @@ linear-direction: column; width: 100%; } +.dancer-timeline-layer { + position: absolute; + top: 58px; + left: 50%; + width: 160px; + height: 160px; + margin-left: -80px; + z-index: 4; + pointer-events: none; +} +.dancer-sprite-window { + position: relative; + width: 160px; + height: 160px; + overflow: hidden; +} +.dancer-sprite-sheet { + position: absolute; + top: 0; + left: 0; + width: 640px; + height: 640px; + transform-origin: top left; +} +.dancer-todo-text { width: 100%; flex-shrink: 0; } +.dancer-todo-band { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + height: 20px; +} +.dancer-todo-line { + font-size: 15px; + line-height: 20px; + color: #2b2f33; + white-space: nowrap; + overflow: hidden; +} +.dancer-todo-line--right { text-align: right; } +.dancer-todo-text.todo-text--done .dancer-todo-line { + color: #c2c9d0; + text-decoration: line-through; +} .day-list { position: relative; width: 100%; @@ -318,6 +362,7 @@ } .todo-body:active .todo-text { opacity: 0.68; } .todo-text--done { color: #c2c9d0; text-decoration: line-through; } +.todo-text--dancer-hidden { opacity: 0; } .todo-input { width: 100%; height: 100%; diff --git a/src/App.vue b/src/App.vue index 3f6f374..ec6a646 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,8 @@ import { } from 'vue-lynx' import { clearTodoTextMeasurementCache, + type DancerCopyBand, + layoutDancerCopy, measureTodoText, supportsRendererLayoutCorrection, } from '@busyweek/text-layout-backend' @@ -25,7 +27,11 @@ import { } from './globalEventBinding.js' import { createStarterTimeline } from './starterTimeline.js' import { loadTimeline, saveTimeline } from './store.js' -import { createTimelineMotionLayout } from './timelineMotion.js' +import { + DAY_GAP, + DAY_HEADER_HEIGHT, + createTimelineMotionLayout, +} from './timelineMotion.js' import { keepTodoEditAboveKeyboard } from './todoKeyboardAvoidance.js' import { TODO_MIN_ROW_HEIGHT, @@ -50,6 +56,15 @@ import { } from './util.js' import DatePickerSheet from './components/DatePickerSheet.vue' import DayPickerSheet from './components/DayPickerSheet.vue' +import dancerSprite from './assets/busyweek-dancer.png' +import { + DANCER_FPS, + DANCER_LOOP_MS, + createTapSequenceArbiter, + dancerFrameAt, + dancerIntervalsForText, + spriteTransform, +} from './dancerEffect.js' type AppState = 'LIST' | 'INPUT' type TodoTextLayoutBinding = { @@ -78,6 +93,13 @@ const composerSubmitLabel = computed(() => // cross-platform pickers (built from Lynx primitives — work on web + native) const dayPickerOpen = ref(false) const datePickerOpen = ref(false) +const dancerActive = ref(false) +const dancerFrame = ref(0) +const dancerPaused = ref(false) +const prefersReducedMotion = ref(false) +let dancerStartedAt = 0 +let dancerTimer: ReturnType | undefined +let dancerDismissTimer: ReturnType | undefined // soft-keyboard height (device-independent px), used to lift the composer's // bottom bar clear of the keyboard on native. The input element event works @@ -193,6 +215,7 @@ function bindGlobalEvents() { listeners: [ ['keyboardstatuschanged', onKeyboardStatus], [BUSYWEEK_TODO_LONG_PRESS_EVENT, onWebTodoLongPress], + ['appstatuschanged', onAppStatusChanged], ], }) } catch { @@ -207,9 +230,15 @@ onMounted(async () => { measureTodoWidthProbe() const stored = await loadTimeline() timeline.value = stored ?? createStarterTimeline(getTodayDate()) + try { + const matcher = (globalThis as unknown as { matchMedia?: (query: string) => { matches: boolean } }).matchMedia?.('(prefers-reduced-motion: reduce)') + prefersReducedMotion.value = matcher?.matches === true + } catch { /* Native runtimes do not expose matchMedia. */ } }) onUnmounted(() => { + stopDancer() + subtitleTapSequence.dispose() removeGlobalEventListeners?.() lastTodoLayoutHeights.clear() clearTodoTextMeasurementCache() @@ -314,6 +343,72 @@ const emptyHint = computed(() => ? '打开右上角查看已完成' : '点右下角 + 添加事项吧', ) +const dancerSpriteStyle = computed(() => ({ transform: spriteTransform(dancerFrame.value) })) +type DancerTodoLayout = { + bands: DancerCopyBand[] + intervals: readonly { left: number; right: number }[] +} +const dancerTodoLayouts = computed(() => { + const layouts: Record = {} + if (!dancerActive.value) return layouts + for (const day of visibleDays.value) { + const dayLayout = motionLayout.value.days[day.key] + if (!dayLayout) continue + for (const todo of day.todos) { + const measurement = measureTodoText(todo.text, todoTextWidth.value) + const lineCount = measurement?.lineCount ?? 1 + const rowTop = dayLayout.offset + DAY_GAP + DAY_HEADER_HEIGHT + (dayLayout.todoOffsets[todo.id] ?? 0) + const textTop = rowTop + 8 + const geometry = dancerIntervalsForText( + textTop, + lineCount, + 20, + dancerFrame.value, + todoTextWidth.value, + ) + if (!geometry.affected) continue + layouts[todo.id] = { + intervals: geometry.intervals, + bands: layoutDancerCopy(todo.text, geometry.intervals, todoTextWidth.value), + } + } + } + return layouts +}) +function dancerTodoLeftStyle(todoId: string, index: number) { + const left = dancerTodoLayouts.value[todoId]?.intervals[index]?.left ?? 1 + return { width: `${left * 100}%` } +} +function dancerTodoRightStyle(todoId: string, index: number) { + const right = dancerTodoLayouts.value[todoId]?.intervals[index]?.right ?? 1 + return { width: `${(1 - right) * 100}%` } +} + +function stopDancer() { + dancerActive.value = false + if (dancerTimer) clearInterval(dancerTimer) + if (dancerDismissTimer) clearTimeout(dancerDismissTimer) + dancerTimer = undefined + dancerDismissTimer = undefined +} +function startDancer() { + if (dancerActive.value) { stopDancer(); return } + dancerActive.value = true + dancerStartedAt = Date.now() + dancerFrame.value = 0 + if (!prefersReducedMotion.value) { + dancerTimer = setInterval(() => { + if (!dancerPaused.value) dancerFrame.value = dancerFrameAt(Date.now() - dancerStartedAt) + }, 1000 / DANCER_FPS) + } + dancerDismissTimer = setTimeout(stopDancer, DANCER_LOOP_MS) +} +function onAppStatusChanged(status: unknown) { + const value = typeof status === 'string' ? status : (status as { status?: unknown } | null)?.status + dancerPaused.value = value === 'background' || value === 'inactive' || value === 'hide' +} +const subtitleTapSequence = createTapSequenceArbiter(startDancer) +function onSubtitleTap() { subtitleTapSequence.tap() } // --- helpers exposed to the template --------------------------------------- function isToday(dateStr: string): boolean { @@ -743,7 +838,10 @@ function removeTodo(dayKey: string, id: string) { - 好忙啊 + 好忙啊 + + + + + + + + {{ band.left?.text }} + {{ band.right?.text }} + + {{ todo.text }} @@ -877,7 +1008,10 @@ function removeTodo(dayKey: string, id: string) { v-else-if="editingId !== todo.id" class="bw-text todo-text" :key="getTodoTextLayoutBinding(todo.id).key" - :class="{ 'todo-text--done': todo.done }" + :class="{ + 'todo-text--done': todo.done, + 'todo-text--dancer-hidden': dancerTodoLayouts[todo.id], + }" >{{ todo.text }}