/** * Notes dock — the floating entry (right edge, vertically centered), the * slide-over panel it toggles, and the text-selection capture bubble that * appears when the user selects text on the page (e.g. an AI answer) so it * can be saved as a note with one click. * * Dock interaction: drag to reposition (position persists in localStorage), * click to toggle the panel. The selection bubble FOLLOWS the selection while * the page scrolls, and hides only when the selection collapses, the user * clicks elsewhere on the page, or the window loses focus. * @module dsh-web-notes/client/NotesDock */ import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactElement, } from 'react' import type { ISessions, SessionListState, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import type { NotesApi, NoteView, NotesButtonSize } from './api.ts' import { NotesPanel, type NoteDraft } from './NotesPanel.tsx' import type { NotesUiSettings } from './settings.ts' import { deriveUiSettings } from './settings.ts' /** Split text into words for both Latin and CJK input: `Intl.Segmenter` * with word granularity when available (correct CJK word boundaries), * falling back to whitespace tokens (Latin) plus Han character runs. */ function splitWords(text: string): string[] { const fallback = text.match(/\S+/g) ?? [] if (typeof Intl === 'undefined' || typeof Intl.Segmenter === 'undefined') { // No Intl.Segmenter: group Latin words by whitespace and each CJK // character run by Han script (a contiguous Han block is one token). return text.match(/[\p{Script=Han}]+|\S+/gu) ?? fallback } const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }) const words: string[] = [] for (const segment of segmenter.segment(text)) { if (segment.isWordLike && segment.segment.trim() !== '') { words.push(segment.segment) } } return words.length > 0 ? words : fallback } /** Shorten a long selection for insertion: keep a head and a tail excerpt * joined by an ellipsis (e.g. the first and last few words), so citing a * long AI answer stays compact. Short text passes through unchanged. * CJK and Latin are both handled: word boundaries come from Intl.Segmenter * (or a Han-aware fallback). */ /** Shorten a long selection for insertion: keep a head and a tail excerpt * joined by an ellipsis, so citing a long AI answer stays compact. Short * text passes through unchanged. * - Latin: keep `excerptWords` words from each end. * - CJK: Intl.Segmenter gives per-character tokens (no spaces), so instead * keep ~2 characters per "word" (a CJK word is typically two characters); * a mixed string is treated by its Han share. */ export function summarizeForInsert(text: string, excerptWords = 3, thresholdChars = 80): string { const trimmed = text.trim() if (trimmed === '') return '' if (trimmed.length <= thresholdChars) return trimmed const hasHan = /[\p{Script=Han}]/u.test(trimmed) // CJK words have no spaces and Intl.Segmenter yields per-character tokens: // keep ~2 characters per requested "word". const keep = hasHan ? excerptWords * 2 : excerptWords const words = splitWords(trimmed) if (words.length <= keep * 2) return trimmed const head = words.slice(0, keep) const tail = words.slice(-keep) const joiner = hasHan ? '' : ' ' return `${head.join(joiner)} … ${tail.join(joiner)}` } /** How the dock places the selection bubble. */ interface SelectionState { text: string x: number y: number } /** A dragged dock position in viewport coordinates (left/top of the button). */ interface DockPosition { x: number y: number } /** Props of the notes dock. */ export interface NotesDockProps { api: NotesApi t: (key: string, params?: Record) => string /** Insert one note into the composer (or copy it when no session is open). Returns a toast copy. */ onInsert: (note: NoteView) => string /** Insert raw text into the composer (selection "insert to input"). Returns a toast copy. */ onInsertText: (text: string) => string /** Whether the selection-capture bubble is enabled (host switch). */ selectionCapture: boolean /** The sessions service, so the dock knows the current session for scoping. */ sessions: ISessions /** The bound `notes` settings scope: dock mode + button size + default scope, live. */ settingsScope: SettingsScope } /** Dock button size presets in px (large = the original 80 px). */ const DOCK_SIZES: Record = { small: 48, regular: 64, large: 80 } /** Pointer travel before a press becomes a drag (px). */ const DRAG_THRESHOLD = 5 /** localStorage key for the dragged position. */ const POS_STORAGE_KEY = 'dsh-notes.dock-position' /** Viewport inset clamp for the dragged dock (px). */ const DOCK_INSET = 6 /** Inset of the fixed dock from the conversation region's top-right corner (px). */ const FIXED_INSET = 12 /** Anchor of the fixed dock: the conversation message scroll container. */ const FIXED_ANCHOR = '[data-conversation-scroll]' /** Load the persisted dock position; tolerant of corrupt/missing entries. */ function loadDockPosition(): DockPosition | null { try { const raw = localStorage.getItem(POS_STORAGE_KEY) if (raw === null) return null const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown } if (typeof parsed.x === 'number' && typeof parsed.y === 'number' && Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { return { x: parsed.x, y: parsed.y } } } catch { // Corrupt entry: fall back to the default placement. } return null } /** Persist the dragged dock position. */ function saveDockPosition(pos: DockPosition): void { try { localStorage.setItem(POS_STORAGE_KEY, JSON.stringify(pos)) } catch { // localStorage can be unavailable (private mode); the drag still works // for the current page. } } /** Clamp a dragged coordinate so the dock stays fully inside the viewport. */ function clampDock(value: number, limit: number, size: number): number { return Math.min(Math.max(value, DOCK_INSET), Math.max(DOCK_INSET, limit - size - DOCK_INSET)) } /** * Notebook icon with a Lottie-style keyframe choreography, driven by a tiny * rAF engine (no external animation library): the notebook floats, its cover * breathes and nods, the bookmark flutters, two lines draw themselves as a * pen travels across them, then the lines fade out and the loop restarts, * with a sparkle popping at the end. Theme-adaptive (all fills ride the * --dshn-* tokens); honours prefers-reduced-motion (static pose) and pauses * on hidden tabs. */ function NotebookIcon(): ReactElement { // Static centering group (the icon content is nudged so its geometric // center sits exactly on the viewBox center — see CENTER_X/Y below). const centerRef = useRef(null) const coverRef = useRef(null) const line1Ref = useRef(null) const line2Ref = useRef(null) const bookmarkRef = useRef(null) const penRef = useRef(null) const sparkleRef = useRef(null) useEffect(() => { // Easing curves. const easeOutCubic = (x: number): number => 1 - Math.pow(1 - x, 3) const easeInOut = (x: number): number => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2) const easeOutBack = (x: number): number => { const c1 = 1.70158 const c3 = c1 + 1 return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2) } /** Piecewise keyframe sampling with easing between knots. */ const sample = ( kfs: readonly (readonly [number, number])[], t: number, ease: (x: number) => number, ): number => { if (t <= kfs[0][0]) return kfs[0][1] for (let i = 1; i < kfs.length; i += 1) { const [t0, v0] = kfs[i - 1] const [t1, v1] = kfs[i] if (t <= t1) { const span = t1 - t0 || 1 return v0 + (v1 - v0) * ease((t - t0) / span) } } return kfs[kfs.length - 1][1] } // One loop = 3200 ms. All keyframes are [time (ms), value] and return to // their start values at 3200 so the loop is seamless. const LOOP_MS = 3200 const line1: readonly (readonly [number, number])[] = [[0, 0], [1150, 0], [1650, 1], [2950, 1], [3150, 0]] const line2: readonly (readonly [number, number])[] = [[0, 0], [1400, 0], [1900, 1], [3000, 1], [3200, 0]] const penTravel: readonly (readonly [number, number])[] = [[1250, 0], [1700, 15.5]] const penOpacity: readonly (readonly [number, number])[] = [[0, 0], [1250, 0], [1320, 1], [1680, 1], [1780, 0], [3200, 0]] const coverScale: readonly (readonly [number, number])[] = [ [0, 1], [600, 1], [1800, 1.02], [2250, 1], [2400, 1], [2520, 0.965], [2660, 1], [3200, 1], ] const sparkleOpacity: readonly (readonly [number, number])[] = [[0, 0], [2100, 0], [2200, 1], [2450, 1], [2580, 0], [3200, 0]] const sparkleScale: readonly (readonly [number, number])[] = [[0, 0], [2200, 0.4], [2300, 1], [2450, 1], [2580, 0], [3200, 0]] const reduce = window.matchMedia('(prefers-reduced-motion: reduce)') if (reduce.matches) { // Static pose: lines visible, everything else at rest. line1Ref.current?.setAttribute('transform', 'scale(1 1)') line2Ref.current?.setAttribute('transform', 'scale(1 1)') return } let raf = 0 let start = 0 let running = true const tick = (now: number): void => { if (!running) return if (start === 0) start = now const t = (now - start) % LOOP_MS // Cover breathe + nod. const cover = sample(coverScale, t, easeInOut) coverRef.current?.setAttribute('transform', `scale(1 ${cover.toFixed(4)})`) // Writing lines (draw out, hold, erase back). line1Ref.current?.setAttribute('transform', `scale(${sample(line1, t, easeOutCubic).toFixed(4)} 1)`) line2Ref.current?.setAttribute('transform', `scale(${sample(line2, t, easeOutCubic).toFixed(4)} 1)`) // Bookmark flutter (constant sine). const flutter = Math.sin((t / 1200) * Math.PI * 2) * 5 bookmarkRef.current?.setAttribute('transform', `rotate(${flutter.toFixed(2)})`) // Pen: travels along the first line while it draws. penRef.current?.setAttribute('transform', `translate(${sample(penTravel, t, easeOutCubic).toFixed(2)} 0)`) penRef.current?.setAttribute('opacity', sample(penOpacity, t, easeInOut).toFixed(3)) // Sparkle pop near the end of the loop. sparkleRef.current?.setAttribute('transform', `scale(${sample(sparkleScale, t, easeOutBack).toFixed(4)})`) sparkleRef.current?.setAttribute('opacity', sample(sparkleOpacity, t, easeInOut).toFixed(3)) raf = window.requestAnimationFrame(tick) } const onVisibility = (): void => { if (document.hidden) { running = false if (raf !== 0) window.cancelAnimationFrame(raf) } else if (!running) { running = true start = 0 raf = window.requestAnimationFrame(tick) } } raf = window.requestAnimationFrame(tick) document.addEventListener('visibilitychange', onVisibility) return () => { running = false if (raf !== 0) window.cancelAnimationFrame(raf) document.removeEventListener('visibilitychange', onVisibility) } }, []) return ( ) } /** The floating notes dock. */ export function NotesDock(props: NotesDockProps): ReactElement { const { api, t, onInsert, onInsertText, selectionCapture, sessions, settingsScope } = props const [open, setOpen] = useState(false) const [count, setCount] = useState(0) const [seed, setSeed] = useState(null) const [selection, setSelection] = useState(null) const [pos, setPos] = useState(() => loadDockPosition()) const [dragging, setDragging] = useState(false) // Closing the panel routes through the panel itself whenever it is mounted, // so an in-progress editor draft is persisted before it unmounts. The raw // setter is what the panel calls back into — re-entering the registered path // from there would recurse. const panelCloseRef = useRef<(() => void) | null>(null) const handleClose = useCallback((): void => { setOpen(false) }, []) const registerPanelClose = useCallback((requestClose: (() => void) | null): void => { panelCloseRef.current = requestClose }, []) const closePanel = useCallback((): void => { if (panelCloseRef.current !== null) panelCloseRef.current() else setOpen(false) }, []) // UI preferences from the settings namespace, live (the settings card and // the dock share this scope, so a change in 设置 → 插件 applies instantly). const ui = useSyncExternalStore( useCallback((listener) => settingsScope.subscribe(listener), [settingsScope]), useCallback(() => deriveUiSettings(settingsScope), [settingsScope]), ) const dockSize = DOCK_SIZES[ui.buttonSize] const fixedMode = ui.dockMode === 'fixed' // Fixed mode: pin the dock to the conversation window's top-right corner. // The anchor is the conversation message scroll container; its geometry is // re-measured on resize/layout change and periodically (the element can be // replaced by session navigation). const [fixedPos, setFixedPos] = useState(null) useEffect(() => { if (!fixedMode) return let timer: number | undefined const measure = (): void => { const anchor = document.querySelector(FIXED_ANCHOR) if (anchor instanceof HTMLElement) { const rect = anchor.getBoundingClientRect() const size = DOCK_SIZES[ui.buttonSize] setFixedPos({ x: rect.right - size - FIXED_INSET, y: rect.top + FIXED_INSET }) } else { setFixedPos(null) } } measure() window.addEventListener('resize', measure) const observer = new ResizeObserver(measure) const anchor = document.querySelector(FIXED_ANCHOR) if (anchor instanceof HTMLElement) observer.observe(anchor) // Belt-and-suspenders: the anchor element may be replaced (session // navigation) without a resize — a slow poll keeps the corner honest. timer = window.setInterval(measure, 1500) return () => { window.removeEventListener('resize', measure) observer.disconnect() if (timer !== undefined) window.clearInterval(timer) } }, [fixedMode, ui.buttonSize]) // Current session (reactive): session-scoped notes only show while their // session is current; global notes always show. Null on the hero screen. const sessionList = useSyncExternalStore( useCallback((listener) => sessions.list.subscribe(listener), [sessions]), useCallback(() => sessions.list.getSnapshot(), [sessions]), ) const currentSessionId = sessionList.current ?? null const rootRef = useRef(null) const dockRef = useRef(null) const bubbleRef = useRef(null) const selectionRef = useRef(null) const posRef = useRef(pos) const dragRef = useRef<{ startX: number startY: number originX: number originY: number moved: boolean } | null>(null) useEffect(() => { selectionRef.current = selection }, [selection]) useEffect(() => { posRef.current = pos }, [pos]) // A persisted position is clamped only while dragging, so a position saved in // a wider (or zoomed-out) viewport — or a viewport that shrank afterwards — // strands the dock outside the visible area. Fixed positioning inside the // 0×0 root leaves no scroll that could reach it, so the button would stay // invisible AND undraggable forever. Re-clamp into the live viewport on mount // and whenever the viewport or the button size changes (zoom fires resize). useEffect(() => { if (fixedMode) return const reclamp = (): void => { const prev = posRef.current if (prev === null) return const next: DockPosition = { x: clampDock(prev.x, window.innerWidth, dockSize), y: clampDock(prev.y, window.innerHeight, dockSize), } if (next.x === prev.x && next.y === prev.y) return posRef.current = next saveDockPosition(next) setPos(next) } reclamp() window.addEventListener('resize', reclamp) return () => window.removeEventListener('resize', reclamp) }, [fixedMode, dockSize]) /** Refresh the badge count for the current context (session notes + global). */ const refreshCount = useCallback((): void => { api.list(undefined, { session: currentSessionId, scope: 'all' }).then((result) => { setCount(result.count) }, () => { // Ignore transport errors; the next refresh resyncs. }) }, [api, currentSessionId]) useEffect(() => { refreshCount() }, [refreshCount]) // ESC closes the panel; opening the panel re-reads the count. An outside // pointerdown is handled by the panel itself (see NotesPanel). useEffect(() => { if (!open) return const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') closePanel() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open, closePanel]) const openWithDraft = useCallback((draft: NoteDraft): void => { setSeed(draft) setOpen(true) }, []) const saveSelection = useCallback((text: string): void => { const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? '' const title = firstLine.length > 32 ? firstLine.slice(0, 32) + '…' : firstLine setSelection(null) openWithDraft({ title, content: text, tags: '', source: 'selection', // Selections captured while a session is current default to that // session's scope — unless "default save global" is on. sessionId: currentSessionId, global: ui.defaultGlobal || currentSessionId === null, }) }, [openWithDraft, currentSessionId, ui.defaultGlobal]) /** Insert the selection into the composer as a reference chip: the FULL * text is passed through (the quote source stores it and mints a summary * chip; on send the codec expands it back to the full text the model * reads). Returns the toast copy from the host. */ const insertSelection = useCallback((text: string): string => { setSelection(null) return onInsertText(text) }, [onInsertText]) // ---- dock dragging ---- // A press without travel toggles the panel through the button's onClick // (which also keeps keyboard activation working); a real drag sets this // flag so the click that follows a drag is swallowed. const clickSuppressed = useRef(false) const onPointerDown = (event: ReactPointerEvent): void => { if (event.button !== 0) return // A new press resets the drag-suppression: only the click that directly // follows a drag is swallowed, never a later interaction. clickSuppressed.current = false const button = dockRef.current if (button === null) return const rect = button.getBoundingClientRect() dragRef.current = { startX: event.clientX, startY: event.clientY, originX: posRef.current?.x ?? rect.left, originY: posRef.current?.y ?? rect.top, moved: false, } try { button.setPointerCapture(event.pointerId) } catch { // Synthetic pointers (tests) have no capture; dragging still works. } } const onPointerMove = (event: ReactPointerEvent): void => { if (fixedMode) return const state = dragRef.current if (state === null) return const dx = event.clientX - state.startX const dy = event.clientY - state.startY if (!state.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return state.moved = true setDragging(true) setPos({ x: clampDock(state.originX + dx, window.innerWidth, dockSize), y: clampDock(state.originY + dy, window.innerHeight, dockSize), }) } const endDrag = (event: ReactPointerEvent): void => { if (fixedMode) return const state = dragRef.current dragRef.current = null setDragging(false) if (state?.moved) { // Swallow the click the browser fires after the drag. Compute the final // position from the drag state directly (React state may not have // flushed by the time pointerup fires inside the same event batch). clickSuppressed.current = true const final: DockPosition = { x: clampDock(state.originX + (event.clientX - state.startX), window.innerWidth, dockSize), y: clampDock(state.originY + (event.clientY - state.startY), window.innerHeight, dockSize), } posRef.current = final saveDockPosition(final) } } const onClick = (): void => { if (clickSuppressed.current) { clickSuppressed.current = false return } if (open) { // Let the panel persist an in-progress draft before it unmounts. closePanel() return } setOpen(true) refreshCount() } // ---- text-selection capture (scroll-following bubble) ---- useEffect(() => { if (!selectionCapture) return let timer: number | undefined let raf: number | undefined /** Whether a node lives outside the notes UI (selections inside it never capture). */ const outsideNotesUi = (node: Node | null): boolean => { if (node === null) return true if (node.nodeType === Node.ELEMENT_NODE) { if ((node as Element).closest?.('[data-dsh-notes-root]') !== null) return false } else if (node.parentElement !== null && node.parentElement.closest('[data-dsh-notes-root]') !== null) { return false } return true } /** Live client rect of the current selection, or null when it has no visual extent. */ const selectionRect = (sel: Selection): DOMRect | null => { if (sel.rangeCount === 0) return null const rect = sel.getRangeAt(0).getBoundingClientRect() return rect.width === 0 && rect.height === 0 ? null : rect } /** Place the bubble above the selection's current viewport position. */ const placeBubble = (sel: Selection): void => { const rect = selectionRect(sel) if (rect === null) { setSelection(null) return } const x = Math.min(Math.max(rect.left, 8), window.innerWidth - 150) const y = Math.min(Math.max(rect.top - 42, 8), window.innerHeight - 44) setSelection({ text: sel.toString().trim(), x, y }) } const checkSelection = (): void => { const sel = window.getSelection() const text = sel?.toString().trim() ?? '' if (text === '' || sel === null || sel.isCollapsed || !outsideNotesUi(sel.anchorNode)) { setSelection(null) return } placeBubble(sel) } const schedule = (): void => { if (timer !== undefined) window.clearTimeout(timer) timer = window.setTimeout(checkSelection, 120) } // Scroll: the selection's range rect is live in client coordinates, so // re-reading it moves the bubble along while the page scrolls (rAF // throttled). It disappears only when the selection leaves the viewport // or collapses — never merely because the page moved. const onScroll = (): void => { if (selectionRef.current === null || raf !== undefined) return raf = window.requestAnimationFrame(() => { raf = undefined const sel = window.getSelection() if (sel === null || sel.isCollapsed || !outsideNotesUi(sel.anchorNode)) { setSelection(null) return } const rect = selectionRect(sel) if (rect === null || rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) { setSelection(null) return } const x = Math.min(Math.max(rect.left, 8), window.innerWidth - 150) const y = Math.min(Math.max(rect.top - 42, 8), window.innerHeight - 44) setSelection((prev) => (prev === null ? prev : { ...prev, x, y })) }) } // Hide when the user clicks elsewhere on the page or switches windows. const onPointerDown = (event: PointerEvent): void => { if (event.target instanceof Node && bubbleRef.current?.contains(event.target) === true) return setSelection(null) } const onBlur = (): void => { setSelection(null) } const onVisibilityChange = (): void => { if (document.hidden) setSelection(null) } document.addEventListener('mouseup', schedule) document.addEventListener('selectionchange', schedule) document.addEventListener('scroll', onScroll, true) document.addEventListener('pointerdown', onPointerDown) window.addEventListener('blur', onBlur) document.addEventListener('visibilitychange', onVisibilityChange) return () => { if (timer !== undefined) window.clearTimeout(timer) if (raf !== undefined) window.cancelAnimationFrame(raf) document.removeEventListener('mouseup', schedule) document.removeEventListener('selectionchange', schedule) document.removeEventListener('scroll', onScroll, true) document.removeEventListener('pointerdown', onPointerDown) window.removeEventListener('blur', onBlur) document.removeEventListener('visibilitychange', onVisibilityChange) } }, [selectionCapture]) const floatingStyle: CSSProperties = pos === null ? { right: 10, top: '50%' } : { left: pos.x, top: pos.y } const dockStyle: CSSProperties = fixedMode ? (fixedPos === null ? { right: FIXED_INSET, top: FIXED_INSET } : { left: fixedPos.x, top: fixedPos.y }) : floatingStyle const dockClass = [ 'dshn-dock', fixedMode ? 'dshn-dock-fixed' : (pos === null ? '' : 'dshn-dock-absolute'), dragging ? 'dshn-dock-dragging' : '', ].filter(Boolean).join(' ') return (
{open ? ( { setSeed(null) }} /> ) : null} {selection !== null ? (
) : null}
) }