Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/src/data/models/hangingNotes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { HangingNote } from './types'

export const normalizeHangingAnchor = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined

export function normalizeHangingNotes(value: unknown): HangingNote[] {
if (!Array.isArray(value)) return []
const result: HangingNote[] = [], ids = new Set<string>()
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/data/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,7 @@ export type HangingNote = {

export type SyncedPreferences = BaseEntity & {
hangingNotes?: HangingNote[]
hangingNotesAnchor?: number
id: 'synced_preferences'
language: LanguageCode
uiAnimationsEnabled: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'fake-indexeddb/auto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { db } from '../db'
import { syncedPreferencesRepo as repo, SYNCED_PREFERENCES_ID } from './syncedPreferencesRepo'
import { normalizeHangingNotes } from '../models/hangingNotes'
import { normalizeHangingNotes, normalizeHangingAnchor } from '../models/hangingNotes'
import type { HangingNote } from '../models/types'
import { writeLanguage } from '../../shared/prefs/preferences'
vi.mock('../sync/repository', () => ({ requestSyncHandoff: vi.fn(), enqueueSyncOperationInBackground: vi.fn() }))
Expand Down Expand Up @@ -41,3 +41,30 @@ describe('persisting dashboard notes', () => {
expect(normalizeHangingNotes([null,a,a,{...b,pin:{u:5,y:-1,tilt:2}},{id:'bad',text:'x',color:'unknown'}])).toEqual([a,{...b,pin:{u:1,y:0,tilt:.08}}])
})
})


describe('persisting the hanging anchor', () => {
it('validates optional positions while leaving legacy placement automatic', () => {
expect([undefined, null, NaN, Infinity, '0.5'].map(normalizeHangingAnchor)).toEqual([undefined, undefined, undefined, undefined, undefined])
expect([-.2, .4, 2].map(normalizeHangingAnchor)).toEqual([0, .4, 1])
})
it('merges anchor, note, and preference changes from stale tabs', async () => {
await repo.saveHangingNotes([a], [], () => true, { next: .3 })
writeLanguage('en')
await Promise.all([
repo.saveHangingNotes([a], [a], () => true, { next: .7, previous: .3 }),
repo.saveHangingNotes([{ ...a, text: 'edited' }], [a], () => true, { next: .3, previous: .3 }),
repo.persistFromLocal(),
])
const saved = await repo.get()
expect(saved?.hangingNotesAnchor).toBe(.7)
expect(saved?.hangingNotes).toEqual([{ ...a, text: 'edited' }])
expect(saved?.language).toBe('en')
})
it('saves an empty hanger and restores its automatic position without creating a note', async () => {
await repo.saveHangingNotes([], [], () => true, { next: .7 })
await repo.saveHangingNotes([], [], () => true, { next: undefined, previous: .7 })
expect((await repo.get())?.hangingNotesAnchor).toBeUndefined()
expect((await repo.get())?.hangingNotes).toEqual([])
})
})
7 changes: 5 additions & 2 deletions apps/web/src/data/repositories/syncedPreferencesRepo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db } from '../db'
import type { HangingNote, SyncedPreferences } from '../models/types'
import { normalizeHangingNotes } from '../models/hangingNotes'
import { normalizeHangingNotes, normalizeHangingAnchor } from '../models/hangingNotes'
import { enqueueSyncOperationInBackground, requestSyncHandoff } from '../sync/repository'
import {
readDefaultCurrency,
Expand Down Expand Up @@ -127,7 +127,7 @@ export const syncedPreferencesRepo = {
return next
},
/** Merge just this edit into the latest record; unrelated preferences/sheets survive. */
async saveHangingNotes(notes: HangingNote[], previous: HangingNote[], isCurrentAccount = () => true) {
async saveHangingNotes(notes: HangingNote[], previous: HangingNote[], isCurrentAccount = () => true, anchor?: { next?: number; previous?: number }) {
const next = await db.transaction('rw', db.syncedPreferences, async () => {
if (!isCurrentAccount()) throw new Error('Account changed')
const existing = await this.get()
Expand All @@ -142,6 +142,9 @@ export const syncedPreferencesRepo = {
const hangingNotes = [...current.filter(note => !removed.has(note.id)).map(note => updates.get(note.id) ?? note), ...changed.filter(note => !currentIds.has(note.id))]
const timestamp = now()
const record: SyncedPreferences = { ...(existing ?? { ...buildLocalSnapshot(), createdAt: timestamp }), hangingNotes, updatedAt: timestamp }
if (anchor && normalizeHangingAnchor(anchor.next) !== normalizeHangingAnchor(anchor.previous)) {
record.hangingNotesAnchor = normalizeHangingAnchor(anchor.next)
}
await db.syncedPreferences.put(record)
return record
})
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/features/dashboard/desk/DeskProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ const measurePlacement = (header: HTMLElement): Placement | null => {
if (!day || !toolbar) return null
const quote = local('.dash-hero__quote')
const notes = local('.dash-notes-anchor')
const movedAnchor = header.querySelector<HTMLElement>('.dash-notes-anchor')?.dataset.noteAnchorX
if (notes && movedAnchor !== undefined && Number.isFinite(Number(movedAnchor))) {
const halfWidth = (notes.right - notes.left) / 2
notes.left = Number(movedAnchor) - halfWidth
notes.right = Number(movedAnchor) + halfWidth
}
const views = local('.dash-toolbar__views')
const actions = local('.dash-toolbar__actions')
// The desk line is the middle of the toolbar's 1px rule.
Expand Down Expand Up @@ -104,11 +110,13 @@ const useDeskPlacement = (headerRef: RefObject<HTMLElement | null>) => {
if (el) observer.observe(el)
}
window.addEventListener('resize', schedule)
header.addEventListener('focusgo:notes-anchor-moved', schedule)
void document.fonts?.ready.then(schedule)
schedule()
return () => {
observer.disconnect()
window.removeEventListener('resize', schedule)
header.removeEventListener('focusgo:notes-anchor-moved', schedule)
if (raf) cancelAnimationFrame(raf)
}
}, [headerRef])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('hanging note update preparation', () => {
mocks.save.mockReturnValue(new Promise<void>(resolve => { finish = resolve }))
render(<HangingNotes />)
let save!: Promise<unknown>
await act(async () => { save = mocks.create.mock.calls[0][2]([], () => []) })
await act(async () => { save = mocks.create.mock.calls[0][2]([], () => [], { next: undefined, before: () => undefined }) })
let prepared = false
const preparation = prepareAppUpdate().then(() => { prepared = true })
await act(async () => { await Promise.resolve() })
Expand All @@ -40,7 +40,7 @@ describe('hanging note update preparation', () => {
mocks.save.mockRejectedValue(new Error('disk unavailable'))
const view = render(<HangingNotes />)
await act(async () => {
await expect(mocks.create.mock.calls[0][2]([], () => [])).rejects.toThrow('disk unavailable')
await expect(mocks.create.mock.calls[0][2]([], () => [], { next: undefined, before: () => undefined })).rejects.toThrow('disk unavailable')
})
await expect(prepareAppUpdate()).rejects.toThrow('disk unavailable')
view.unmount()
Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { syncedPreferencesRepo } from '../../../data/repositories/syncedPreferen
import { createHangingNotes } from './controller'
import { useI18n } from '../../../shared/i18n/useI18n'
import { onPrepareAppUpdate } from '../../../shared/runtime/updateLifecycle'
import { normalizeHangingNotes } from './model'
import { useAuthGate } from '../../auth/AuthGateContext'
import { normalizeHangingNotes, normalizeHangingAnchor } from './model'
import './hanging-notes.css'

const account = () => String(getAuth()?.user?.id ?? 'local')

export default function HangingNotes() {
const { language } = useI18n()
const { requireAuth } = useAuthGate()
const zh = language === 'zh'
const root = useRef<HTMLDivElement>(null)
const owner = useSyncExternalStore(subscribeAuth, account, () => 'local')
Expand All @@ -26,24 +28,25 @@ export default function HangingNotes() {
next: preferences => {
if (disposed) return
const notes = normalizeHangingNotes(preferences?.hangingNotes)
if (engine) { if (!pendingSaves) engine.update(notes); return }
engine = createHangingNotes(element, notes, (next, before) => {
const anchor = normalizeHangingAnchor(preferences?.hangingNotesAnchor)
if (engine) { if (!pendingSaves) engine.update(notes, anchor); return }
engine = createHangingNotes(element, notes, (next, before, anchorChange) => {
pendingSaves++
const revision = ++saveRevision
const save = queue.catch(() => undefined).then(() => syncedPreferencesRepo.saveHangingNotes(next, before(), () => account() === owner))
const save = queue.catch(() => undefined).then(() => syncedPreferencesRepo.saveHangingNotes(next, before(), () => account() === owner, { next: anchorChange.next, previous: anchorChange.before() }))
queue = save
void save.then(() => {
pendingSaves--
if (!disposed && !pendingSaves) {
void syncedPreferencesRepo.get().then(value => { if (!disposed && !pendingSaves && revision === saveRevision) engine?.update(normalizeHangingNotes(value?.hangingNotes)) }).catch(() => undefined)
void syncedPreferencesRepo.get().then(value => { if (!disposed && !pendingSaves && revision === saveRevision) engine?.update(normalizeHangingNotes(value?.hangingNotes), normalizeHangingAnchor(value?.hangingNotesAnchor)) }).catch(() => undefined)
}
}, () => { pendingSaves-- })
return save
}, zh)
}, zh, anchor, requireAuth)
},
error: () => { if (!disposed) { engine?.destroy(); engine = undefined; element.textContent = zh ? '便签暂时无法读取,请重新打开仪表盘。' : 'Notes could not load. Please reopen Dashboard.' } },
})
return () => { disposed = true; subscription.unsubscribe(); engine?.destroy(); stopPreparing() }
}, [owner, zh])
}, [owner, zh, requireAuth])
return <div ref={root} className="dashboard-notes" aria-label={zh ? '风中便签' : 'Hanging notes'} />
}
70 changes: 70 additions & 0 deletions apps/web/src/features/dashboard/hanging-notes/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,35 @@ beforeEach(() => {
afterEach(() => { engine?.destroy(); vi.restoreAllMocks(); vi.unstubAllGlobals() })

describe('hanging note interaction state', () => {
it.each(['ArrowLeft', 'ArrowRight', 'Home', 'End', 'reset'])('requires sign-in before changing the anchor with %s', async key => {
let allowed = false
const save = vi.fn().mockResolvedValue(undefined)
const gate = vi.fn((action: () => void) => { if (allowed) action() })
engine = createHangingNotes(root, [a], save, true, .35, gate)
const pin = root.querySelector<HTMLElement>('.pin')!, original = pin.style.left
const change = () => key === 'reset' ? fireEvent.doubleClick(pin) : fireEvent.keyDown(pin, { key })
change(); await flush()
expect(gate).toHaveBeenCalledTimes(1)
expect(pin.style.left).toBe(original)
expect(save).not.toHaveBeenCalled()
allowed = true
change(); await flush()
expect(save).toHaveBeenCalledTimes(1)
expect(pin.style.left).not.toBe(original)
})
it('cannot start or save an anchor drag when sign-in is required', async () => {
const save = vi.fn(), gate = vi.fn()
engine = createHangingNotes(root, [a], save, true, .35, gate)
const pin = root.querySelector<HTMLElement>('.pin')!, original = pin.style.left
for (const [type, clientX] of [['pointerdown', 350], ['pointermove', 450], ['pointerup', 450]] as const) {
fireEvent(pin, Object.assign(new MouseEvent(type, { button: 0, clientX, bubbles: true }), { pointerId: 1, isPrimary: true }))
}
await flush()
expect(gate).toHaveBeenCalledTimes(1)
expect(root.classList.contains('is-moving-anchor')).toBe(false)
expect(pin.style.left).toBe(original)
expect(save).not.toHaveBeenCalled()
})
it('retries an earlier failed edit when saving a different sheet', async () => {
const save = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined)
engine = createHangingNotes(root, [a, b], (next, before) => save(next, before()))
Expand Down Expand Up @@ -103,3 +132,44 @@ describe('hanging note interaction state', () => {
expect(root.childElementCount).toBe(0)
})
})


describe('hanging anchor state', () => {
it('retains a failed anchor move when the next action edits a note', async () => {
const save = vi.fn().mockRejectedValueOnce(new Error('disk unavailable')).mockResolvedValue(undefined)
engine = createHangingNotes(root, [a], (next, before, anchor) => save(next, before(), anchor.next, anchor.before()), true, .5)
fireEvent.keyDown(get('pin'), { key: 'ArrowRight' }); await flush()
click('paper-stack'); fireEvent.input(get('note-text'), { target: { value: 'changed' } }); await flush()
expect(save.mock.calls[1]).toEqual([[{ ...a, text: 'changed' }], [a], .508, .5])
expect(get('save-status').textContent).toBe('已保存')
})
it('resolves queued anchor baselines after the previous write completes', async () => {
let release!: () => void
const barrier = new Promise<void>(resolve => { release = resolve })
const writes: { next: number | undefined; before: number | undefined }[] = []
let queue = Promise.resolve()
engine = createHangingNotes(root, [a], (_next, _before, anchor) => {
const save = queue.then(async () => {
writes.push({ next: anchor.next, before: anchor.before() })
if (writes.length === 1) await barrier
})
queue = save
return save
}, true, .5)
fireEvent.keyDown(get('pin'), { key: 'ArrowRight' })
fireEvent.keyDown(get('pin'), { key: 'ArrowLeft' })
await flush(); release(); await flush(); await flush()
expect(writes).toEqual([{ next: .508, before: .5 }, { next: .5, before: .508 }])
})
it('defers incoming anchor changes while reading a note and keeps pinned papers in place', async () => {
const pinned = { ...b, pin: { u: .4, y: 350, tilt: 0 } }
engine = createHangingNotes(root, [a, pinned], async () => undefined, true, .2)
expect(get('pin').style.left).toBe('200px')
const paper = root.querySelector<HTMLElement>('.loose-note')!, pose = paper.style.transform
click('paper-stack'); engine.update([a, pinned], .6)
expect(get('pin').style.left).toBe('200px')
click('close-note')
expect(get('pin').style.left).toBe('600px')
expect(paper.style.transform).toBe(pose)
})
})
Loading
Loading