From 562c59b7aa261f50009510bb39022b9d2c52b634 Mon Sep 17 00:00:00 2001 From: VelvetAbyss Date: Sat, 31 Oct 2026 01:44:08 +0800 Subject: [PATCH 1/2] feat(dashboard): make hanging note anchor draggable --- apps/web/src/data/models/hangingNotes.ts | 3 + apps/web/src/data/models/types.ts | 1 + ...syncedPreferencesRepo.hangingNotes.test.ts | 29 +++- .../repositories/syncedPreferencesRepo.ts | 7 +- .../src/features/dashboard/desk/DeskProps.tsx | 8 + .../hanging-notes/HangingNotes.test.tsx | 4 +- .../dashboard/hanging-notes/HangingNotes.tsx | 13 +- .../hanging-notes/controller.test.ts | 41 +++++ .../dashboard/hanging-notes/controller.ts | 142 +++++++++++++++--- .../features/dashboard/hanging-notes/copy.ts | 3 + .../dashboard/hanging-notes/hanging-notes.css | 6 +- .../features/dashboard/hanging-notes/model.ts | 2 +- .../dashboard/hanging-notes/template.ts | 2 +- 13 files changed, 229 insertions(+), 32 deletions(-) diff --git a/apps/web/src/data/models/hangingNotes.ts b/apps/web/src/data/models/hangingNotes.ts index 46981d4..df40e81 100644 --- a/apps/web/src/data/models/hangingNotes.ts +++ b/apps/web/src/data/models/hangingNotes.ts @@ -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() diff --git a/apps/web/src/data/models/types.ts b/apps/web/src/data/models/types.ts index 88c5b22..d15a059 100644 --- a/apps/web/src/data/models/types.ts +++ b/apps/web/src/data/models/types.ts @@ -774,6 +774,7 @@ export type HangingNote = { export type SyncedPreferences = BaseEntity & { hangingNotes?: HangingNote[] + hangingNotesAnchor?: number id: 'synced_preferences' language: LanguageCode uiAnimationsEnabled: boolean diff --git a/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts b/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts index f9d12a3..fccdc20 100644 --- a/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts +++ b/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts @@ -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() })) @@ -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([]) + }) +}) diff --git a/apps/web/src/data/repositories/syncedPreferencesRepo.ts b/apps/web/src/data/repositories/syncedPreferencesRepo.ts index 0f74844..63ac000 100644 --- a/apps/web/src/data/repositories/syncedPreferencesRepo.ts +++ b/apps/web/src/data/repositories/syncedPreferencesRepo.ts @@ -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, @@ -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() @@ -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 }) diff --git a/apps/web/src/features/dashboard/desk/DeskProps.tsx b/apps/web/src/features/dashboard/desk/DeskProps.tsx index 09b62a8..8c3e42b 100644 --- a/apps/web/src/features/dashboard/desk/DeskProps.tsx +++ b/apps/web/src/features/dashboard/desk/DeskProps.tsx @@ -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('.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. @@ -104,11 +110,13 @@ const useDeskPlacement = (headerRef: RefObject) => { 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]) diff --git a/apps/web/src/features/dashboard/hanging-notes/HangingNotes.test.tsx b/apps/web/src/features/dashboard/hanging-notes/HangingNotes.test.tsx index 12845f2..0c70a0c 100644 --- a/apps/web/src/features/dashboard/hanging-notes/HangingNotes.test.tsx +++ b/apps/web/src/features/dashboard/hanging-notes/HangingNotes.test.tsx @@ -27,7 +27,7 @@ describe('hanging note update preparation', () => { mocks.save.mockReturnValue(new Promise(resolve => { finish = resolve })) render() let save!: Promise - 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() }) @@ -40,7 +40,7 @@ describe('hanging note update preparation', () => { mocks.save.mockRejectedValue(new Error('disk unavailable')) const view = render() 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() diff --git a/apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx b/apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx index ea542d9..598673f 100644 --- a/apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx +++ b/apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx @@ -5,7 +5,7 @@ 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 { normalizeHangingNotes, normalizeHangingAnchor } from './model' import './hanging-notes.css' const account = () => String(getAuth()?.user?.id ?? 'local') @@ -26,20 +26,21 @@ 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) }, error: () => { if (!disposed) { engine?.destroy(); engine = undefined; element.textContent = zh ? '便签暂时无法读取,请重新打开仪表盘。' : 'Notes could not load. Please reopen Dashboard.' } }, }) diff --git a/apps/web/src/features/dashboard/hanging-notes/controller.test.ts b/apps/web/src/features/dashboard/hanging-notes/controller.test.ts index deb1141..62c8f27 100644 --- a/apps/web/src/features/dashboard/hanging-notes/controller.test.ts +++ b/apps/web/src/features/dashboard/hanging-notes/controller.test.ts @@ -103,3 +103,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(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('.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) + }) +}) diff --git a/apps/web/src/features/dashboard/hanging-notes/controller.ts b/apps/web/src/features/dashboard/hanging-notes/controller.ts index edfc3b6..4755fac 100644 --- a/apps/web/src/features/dashboard/hanging-notes/controller.ts +++ b/apps/web/src/features/dashboard/hanging-notes/controller.ts @@ -1,6 +1,7 @@ import type { HangingNote } from '../../../data/models/types'; import { noteTemplate } from './template'; import { noteTranslations } from './copy'; +import { normalizeHangingAnchor } from './model'; type Point = { x: number; y: number; @@ -63,7 +64,7 @@ type Drag = { originPoint: Point; }; /** A scoped imperative animation island: React owns mount/unmount, never frame state. */ -export function createHangingNotes(root: HTMLElement, initial: HangingNote[], onSave: (next: HangingNote[], before: () => HangingNote[]) => Promise, zh = true) { +export function createHangingNotes(root: HTMLElement, initial: HangingNote[], onSave: (next: HangingNote[], before: () => HangingNote[], anchor: { next: number | undefined; before: () => number | undefined }) => Promise, zh = true, initialAnchor?: number) { const copy = (value: string) => zh ? value : noteTranslations[value] ?? value; root.innerHTML = zh ? noteTemplate : Object.entries(noteTranslations).sort((a, b) => b[0].length - a[0].length).reduce((html, [from, to]) => html.replaceAll(from, to), noteTemplate); let disposed = false; @@ -82,7 +83,8 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on const COLORS = { butter: '#eee5c9', sage: '#dbe3d2', rose: '#ecd6ca' }; const clamp = (n: number, a: number, b: number) => Math.min(Math.max(n, a), Math.max(a, b)); let notes = structuredClone(initial), persisted = structuredClone(initial); - let storageOK = true, saveVersion = 0, saving = 0, pending: HangingNote[] | null = null; + let anchorU = normalizeHangingAnchor(initialAnchor), persistedAnchor = anchorU; + let storageOK = true, saveVersion = 0, saving = 0, pending: { notes: HangingNote[]; anchor?: number } | null = null; const stage = root, stack = $('#fg-notes-paper-stack'), detail = $('#fg-notes-note-detail'), input = $('#fg-notes-note-text'); const dashboard = root.parentElement!; let scroller: HTMLElement = document.scrollingElement as HTMLElement || document.documentElement; @@ -121,6 +123,9 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on let elapsed = 0, frontId: string | undefined = notes.find(n => !n.pin)?.id; let editId: string | null = null, editorSource: HTMLElement | null = null; let drag: Drag | null = null, suppressClick = false, geometry: Geometry, frame: number | undefined, lastFrame = 0; + let anchorDrag: { id: number; origin: number | undefined; offset: number; startX: number; moved: boolean } | null = null; + let defaultAnchorX = 0, publishedAnchor = ''; + const anchorPin = $('#fg-notes-pin'); let snap: Snap | null = null, undoEntry: { note: HangingNote; index: number; @@ -161,13 +166,15 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on } function persist() { const version = ++saveVersion, next = structuredClone(notes), before = () => structuredClone(persisted); + const anchor = { next: anchorU, before: () => persistedAnchor }; // Only an acknowledged write advances the baseline. Later saves retry earlier failures. pending = null; saving++; saveStatus(); - void onSave(next, before).then(() => { + void onSave(next, before, anchor).then(() => { saving--; persisted = next; + persistedAnchor = anchor.next; if (disposed) return; if (version === saveVersion) @@ -293,9 +300,9 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on if (!w || !h || !anchor) return; const slot = localBox(anchor), q = dashboard.querySelector('.dash-hero__quote'); - geometry = { w, h, scale: .4, ax: (slot.left + slot.right) / 2, ay: slot.top + 4, length: 25, quote: q ? localBox(q) : null }; - $('#fg-notes-pin').style.left = `${geometry.ax}px`; - $('#fg-notes-pin').style.top = `${geometry.ay}px`; + defaultAnchorX = (slot.left + slot.right) / 2; + geometry = { w, h, scale: .4, ax: defaultAnchorX, ay: slot.top + 4, length: 25, quote: q ? localBox(q) : null }; + placeAnchor(); for (const note of notes) { if (!note.pin) continue; @@ -313,6 +320,99 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on render(); wake(); } + function placeAnchor(impulse = false) { + const previous = geometry.ax, b = bounds(), q = geometry.quote, half = 160 * geometry.scale; + let x = clamp(anchorU === undefined ? defaultAnchorX : anchorU * geometry.w, b.left, b.right); + const top = geometry.ay + geometry.length - 27 * geometry.scale; + const bottom = geometry.ay + geometry.length + 194 * geometry.scale; + if (q && top < q.bottom && bottom > q.top && x + half > q.left && x - half < q.right) { + const choices = [q.left - half - 12, q.right + half + 12].filter(value => value >= b.left && value <= b.right); + x = choices.sort((a, c) => Math.abs(a - x) - Math.abs(c - x))[0] ?? clamp(defaultAnchorX, b.left, b.right); + } + geometry.ax = x; + anchorPin.style.left = `${x}px`; + anchorPin.style.top = `${geometry.ay}px`; + anchorPin.setAttribute('aria-valuenow', String(Math.round(x / geometry.w * 100))); + if (impulse && !reduced && !paused) + pendulum.velocity = clamp(pendulum.velocity - clamp((x - previous) * .012, -.15, .15), -1.2, 1.2); + const header = dashboard.querySelector('.dash-header'); + const slot = dashboard.querySelector('.dash-notes-anchor'); + if (header && slot) { + const center = (x - localBox(header).left).toFixed(2); + slot.dataset.noteAnchorX = center; + // Move the desk once on release, keeping it still while the pin is dragged. + if (!anchorDrag && publishedAnchor !== center) { + publishedAnchor = center; + header.dispatchEvent(new Event('focusgo:notes-anchor-moved')); + } + } + } + function moveAnchor(event: PointerPosition) { + if (!anchorDrag || event.pointerId !== anchorDrag.id) + return; + if (!anchorDrag.moved && Math.abs(event.clientX - anchorDrag.startX) < 3) + return; + anchorDrag.moved = true; + anchorU = clamp((pointerLocal(event).x - anchorDrag.offset) / geometry.w, 0, 1); + placeAnchor(true); + render(); + wake(); + } + function endAnchor(cancel = false) { + if (!anchorDrag) + return; + const { id, origin, moved } = anchorDrag; + anchorDrag = null; + root.classList.remove('is-moving-anchor'); + if (cancel) + anchorU = origin; + if (anchorPin.hasPointerCapture?.(id)) + anchorPin.releasePointerCapture(id); + placeAnchor(); + if (!cancel && moved && anchorU !== origin) { + persist(); + announce(copy('悬挂位置已调整。')); + } + else if (pending) + applyIncoming(pending.notes, pending.anchor); + render(); + wake(); + } + listen(anchorPin, 'pointerdown', event => { + if (event.button !== 0 || event.isPrimary === false || drag || anchorDrag) + return; + event.preventDefault(); + closeEditor(false); + anchorPin.focus({ preventScroll: true }); + anchorDrag = { id: event.pointerId, origin: anchorU, offset: pointerLocal(event).x - geometry.ax, startX: event.clientX, moved: false }; + anchorPin.setPointerCapture?.(event.pointerId); + root.classList.add('is-moving-anchor'); + }); + listen(anchorPin, 'pointermove', moveAnchor); + listen(anchorPin, 'pointerup', event => { if (anchorDrag?.id === event.pointerId) { moveAnchor(event); endAnchor(); } }); + listen(anchorPin, 'pointercancel', event => { if (anchorDrag?.id === event.pointerId) endAnchor(true); }); + listen(anchorPin, 'lostpointercapture', event => { if (anchorDrag?.id === event.pointerId) endAnchor(true); }); + listen(anchorPin, 'keydown', event => { + if (drag || anchorDrag || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) + return; + event.preventDefault(); + closeEditor(false); + const step = event.shiftKey ? 24 : 8; + anchorU = event.key === 'Home' ? 0 : event.key === 'End' ? 1 : clamp((geometry.ax + (event.key === 'ArrowLeft' ? -step : step)) / geometry.w, 0, 1); + placeAnchor(true); + persist(); + render(); + wake(); + }); + listen(anchorPin, 'dblclick', () => { + if (drag || anchorDrag) + return; + anchorU = undefined; + placeAnchor(true); + persist(); + render(); + wake(); + }); function positionEditor() { const body = bodies.get(editId ?? ''), point = body || { x: geometry.ax, y: geometry.ay + 34 }; const view = localViewport(); @@ -392,7 +492,7 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on stack.focus({ preventScroll: true }); editorSource = null; if (pending) - applyIncoming(pending); + applyIncoming(pending.notes, pending.anchor); wake(); } function addNote() { @@ -546,6 +646,7 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on wake(); } function pointerDown(event: PointerEvent, body: Body | null = null) { + if (anchorDrag) return; if (event.button !== 0 || drag || event.isPrimary === false) return; if (editId) @@ -669,7 +770,7 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on updateUI(); render(); if (pending) - applyIncoming(pending); + applyIncoming(pending.notes, pending.anchor); wake(); if (previous.moved) setTimeout(() => { suppressClick = false; }, 100); @@ -701,7 +802,7 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on resetPendulum(); suppressClick = true; if (pending) - applyIncoming(pending); + applyIncoming(pending.notes, pending.anchor); updateUI(); render(); wake(); @@ -915,9 +1016,11 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on listen(document, 'keydown', e => { if (e.defaultPrevented || e.isComposing) return; - if (e.key === 'Escape' && (drag || editId)) { + if (e.key === 'Escape' && (drag || editId || anchorDrag)) { e.preventDefault(); - if (drag) + if (anchorDrag) + endAnchor(true); + else if (drag) cancelDrag(); else closeEditor(); @@ -937,7 +1040,7 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on media.addEventListener('change', syncMotion, { signal: controller.signal }); const motionObserver = new MutationObserver(syncMotion); motionObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-motion'] }); - listen(window, 'blur', () => cancelDrag()); + listen(window, 'blur', () => { cancelDrag(); endAnchor(true); }); window.addEventListener('scroll', event => { if (event.target instanceof Node && detail.contains(event.target)) return; @@ -949,8 +1052,8 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on positionToast(); }, { passive: true, capture: true, signal: controller.signal }); listen(document, 'visibilitychange', () => { if (frame) - cancelAnimationFrame(frame); frame = undefined; lastFrame = 0; if (document.hidden) - cancelDrag(); + cancelAnimationFrame(frame); frame = undefined; lastFrame = 0; if (document.hidden) { + cancelDrag(); endAnchor(true); } else wake(); }); const observer = new ResizeObserver(() => { measure(); }); @@ -968,20 +1071,22 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on measure(); updateUI(); wake(); - function applyIncoming(next: HangingNote[]) { + function applyIncoming(next: HangingNote[], nextAnchor?: number) { + nextAnchor = normalizeHangingAnchor(nextAnchor); if (disposed) return; - if (JSON.stringify(next) === JSON.stringify(notes)) { + if (JSON.stringify(next) === JSON.stringify(notes) && anchorU === nextAnchor) { pending = null; return; } - if (drag || editId || saving || !storageOK) { - pending = next; + if (drag || anchorDrag || editId || saving || !storageOK) { + pending = { notes: next, anchor: nextAnchor }; return; } pending = null; notes = structuredClone(next); persisted = structuredClone(next); + anchorU = persistedAnchor = nextAnchor; for (const [id, body] of bodies) { if (!notes.find(n => n.id === id)?.pin) { body.el.remove(); @@ -1002,6 +1107,7 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on cancelAnimationFrame(frame); clearTimeout(toastTimer); root.getAnimations({ subtree: true }).forEach(a => a.cancel()); + root.classList.remove('is-moving-anchor'); root.replaceChildren(); }, }; diff --git a/apps/web/src/features/dashboard/hanging-notes/copy.ts b/apps/web/src/features/dashboard/hanging-notes/copy.ts index a6089bb..6111370 100644 --- a/apps/web/src/features/dashboard/hanging-notes/copy.ts +++ b/apps/web/src/features/dashboard/hanging-notes/copy.ts @@ -1,4 +1,7 @@ export const noteTranslations: Record = { + "移动便签悬挂位置": "Move hanging note anchor", + "拖动固定点,左右调整悬挂位置;方向键微调,双击复位。": "Drag to move along the top. Arrow keys fine-tune; double-click resets.", + "悬挂位置已调整。": "Hanging position adjusted.", "保存中…": "Saving…", "已保存": "Saved", "尚未保存,点击重试": "Not saved · Retry", diff --git a/apps/web/src/features/dashboard/hanging-notes/hanging-notes.css b/apps/web/src/features/dashboard/hanging-notes/hanging-notes.css index e1c941b..d8a849e 100644 --- a/apps/web/src/features/dashboard/hanging-notes/hanging-notes.css +++ b/apps/web/src/features/dashboard/hanging-notes/hanging-notes.css @@ -8,7 +8,11 @@ .dashboard-notes .rope-shadow{stroke:#494331;stroke-width:2;opacity:.1;filter:blur(1.3px);transform:translate(3px,3px)} .dashboard-notes .rope-base{stroke:#a59a7f;stroke-width:2.1} .dashboard-notes .rope-fiber{stroke:#ddd5bd;stroke-width:.7;stroke-dasharray:2 3} -.dashboard-notes .pin{position:absolute;width:9px;height:9px;margin:-4.5px;border-radius:50%;background:radial-gradient(circle at 32% 24%,#d4c6a8,#9e927a 60%,#786e5b);box-shadow:0 1px 2px #3a37334d;z-index:6} +.dashboard-notes .pin{position:absolute;width:32px;height:32px;margin:-16px;padding:0;border:0;background:transparent;display:grid;place-items:center;pointer-events:auto;touch-action:none;cursor:ew-resize;z-index:6;border-radius:50%;user-select:none} +.dashboard-notes .pin:before{content:'';width:9px;height:9px;border-radius:50%;background:radial-gradient(circle at 32% 24%,#d4c6a8,#9e927a 60%,#786e5b);box-shadow:0 1px 2px #3a37334d;transition:box-shadow .2s,transform .2s} +.dashboard-notes .pin:hover:before,.dashboard-notes .pin:focus-visible:before,.dashboard-notes.is-moving-anchor .pin:before{box-shadow:0 1px 2px #3a37334d,0 0 0 5px #9e927a24;transform:scale(1.12)} +.dashboard-notes .pin:focus-visible{outline:1px solid #9e927a;outline-offset:1px} +.dashboard-notes.is-moving-anchor .pin{cursor:grabbing} .dashboard-notes .paper-stack{--paper:#eee5c9;position:absolute;width:214px;height:196px;padding:0;pointer-events:auto;touch-action:none;user-select:none;transform-origin:50% 15px;cursor:grab;perspective:750px;outline-offset:15px;will-change:transform} .dashboard-notes .paper-stack:active{cursor:grabbing} .dashboard-notes .paper{display:block;position:absolute;inset:0;border-radius:1px 2px 5px 2px;transform-origin:50% 15px;transition:background .3s} diff --git a/apps/web/src/features/dashboard/hanging-notes/model.ts b/apps/web/src/features/dashboard/hanging-notes/model.ts index 737ed92..5bf6273 100644 --- a/apps/web/src/features/dashboard/hanging-notes/model.ts +++ b/apps/web/src/features/dashboard/hanging-notes/model.ts @@ -1 +1 @@ -export { normalizeHangingNotes } from '../../../data/models/hangingNotes' +export { normalizeHangingNotes, normalizeHangingAnchor } from '../../../data/models/hangingNotes' diff --git a/apps/web/src/features/dashboard/hanging-notes/template.ts b/apps/web/src/features/dashboard/hanging-notes/template.ts index d96ce3d..04edc55 100644 --- a/apps/web/src/features/dashboard/hanging-notes/template.ts +++ b/apps/web/src/features/dashboard/hanging-notes/template.ts @@ -23,7 +23,7 @@ export const noteTemplate = ` - +