diff --git a/CHANGELOG.md b/CHANGELOG.md index ddfce2f..a197cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). 中文版见 [CHANGELOG_ZH.md](./CHANGELOG_ZH.md). +## [Unreleased] + +### Added + +- **The usage panel can be dragged out of the way**: a handle at the top of the panel moves it by pointer drag (the position is stored in `localStorage` and survives reloads); double-clicking the "Usage" button resets it right above the button. Movement is clamped to the nearest fixed-position containing block (so themed docks using `transform`/`backdrop-filter` are handled correctly) and the panel always keeps at least 48px of itself on screen. + +### Tests + +- New `tests/panel-position.test.mjs`: persistence fallbacks (missing key, broken JSON, non-finite values, no `localStorage`) and boundary clamping (out-of-range offsets, oversized panels never bounce the other way). + ## [1.1.7] - 2026-09-24 ### Fixed diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index 9d53802..bb8f5ae 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -3,6 +3,16 @@ 本文件记录本项目所有重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。英文版见 [CHANGELOG.md](./CHANGELOG.md)。 +## [未发布] + +### 新增 + +- **用量面板可以拖到不挡内容的位置**:面板顶部新增拖动把手,按住即可挪动(位置写入 `localStorage`,刷新或重启后保持);双击「用量」按钮复位到按钮正上方。拖动范围按最近的 fixed 定位包含块夹取(皮肤给 dock 加了 `transform`/`backdrop-filter` 时不会被算错),面板四条边至少保留 48px 在可视区内,拖不出屏幕。 + +### 测试 + +- 新增 `tests/panel-position.test.mjs`:持久化读取的容错(缺失/坏 JSON/非有限数值/无 `localStorage`)与边界夹取(越界夹取、超大面板不反向顶飞)。 + ## [1.1.7] - 2026-09-24 ### 修复 diff --git a/src/client/UsageIndicator.tsx b/src/client/UsageIndicator.tsx index ede727f..406d73f 100644 --- a/src/client/UsageIndicator.tsx +++ b/src/client/UsageIndicator.tsx @@ -3,12 +3,13 @@ * 一行展示:输入 / 输出 / 缓存命中率 / 成本估算 / 模型 / 余额 + 细上下文压力条, * 点击展开可视化面板。成本只消费 /pricing 快照(ADR 2),快照未就绪时隐藏成本位。 */ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import type { TokenUsageBuckets } from '../pricing/calc.ts' import { billedInputTokens, cacheHitPercent, formatMoney, formatTokens } from '../pricing/calc.ts' import { currencySymbol, useBalance } from './balance.ts' import { useDisplayCurrency } from './currency.ts' import { getUiCopy, useUiLocale } from './i18n.ts' +import { clampPanelOffset, PANEL_POS_KEY, readPanelOffset, type PanelOffset, type PanelRect } from './panel-position.ts' import { resolveCost, usePricing } from './pricing-api.ts' import { useHistoryRounds } from './rounds/history.ts' import { useObservedRounds } from './rounds/observed.ts' @@ -82,6 +83,12 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { const toggleRef = useRef(null) // 悬浮面板的锚点坐标(fixed 定位,始终在可视区内) const [anchor, setAnchor] = useState<{ left: number; width: number; bottom: number } | null>(null) + // 面板可拖动:偏移叠加在锚点上(transform,不动包含块坐标换算),持久化到 localStorage。 + const [offset, setOffset] = useState(() => readPanelOffset()) + const offsetRef = useRef(offset) + offsetRef.current = offset + const panelRef = useRef(null) + const dragRef = useRef<{ pointerX: number; pointerY: number; offset: PanelOffset } | null>(null) const totals = useProjection('tokenUsage') as TokenUsageBuckets | undefined const pressure = useProjection('contextPressure') as { pressureTokens?: number; projectedTokens?: number; contextWindow?: number } | undefined @@ -195,6 +202,76 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { } }, [expanded]) + // 卸载时兜底恢复:拖动中途组件被卸载不该把整页文字设成不可选。 + useEffect(() => () => { document.body.style.userSelect = '' }, []) + + /** 当前包含块(祖先无 transform/filter/contain 时即视口)的视口矩形。 */ + const dragBounds = (): PanelRect => { + const el = rootRef.current + const rect = el === null ? undefined : containingBlock(el)?.getBoundingClientRect() + return rect === undefined + ? { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight } + : { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + /** 夹取并落盘一个候选偏移(面板矩形扣掉当前偏移 = 未位移基准)。 */ + const applyOffset = (next: PanelOffset): void => { + const panel = panelRef.current + let final = next + if (panel !== null) { + const rect = panel.getBoundingClientRect() + const base = { + left: rect.left - offsetRef.current.x, + top: rect.top - offsetRef.current.y, + right: rect.right - offsetRef.current.x, + bottom: rect.bottom - offsetRef.current.y, + } + final = clampPanelOffset(next, base, dragBounds()) + } + offsetRef.current = final // pointermove 是连续的,ref 必须立刻跟上(setState 异步) + setOffset(final) + try { + localStorage.setItem(PANEL_POS_KEY, JSON.stringify(final)) + } catch { + // 隐私模式 / 配额满:位置不持久化,拖动本身照常可用。 + } + } + + /** 双击「用量」复位到按钮正上方(并清掉已存的位置)。 */ + const clearOffset = (): void => { + offsetRef.current = { x: 0, y: 0 } + setOffset({ x: 0, y: 0 }) + try { + localStorage.removeItem(PANEL_POS_KEY) + } catch { + // ignore + } + } + + const beginDrag = (event: ReactPointerEvent): void => { + if (event.button !== 0) return + dragRef.current = { pointerX: event.clientX, pointerY: event.clientY, offset: offsetRef.current } + event.currentTarget.setPointerCapture?.(event.pointerId) + event.preventDefault() + document.body.style.userSelect = 'none' + } + + const moveDrag = (event: ReactPointerEvent): void => { + const drag = dragRef.current + if (drag === null) return + applyOffset({ + x: drag.offset.x + event.clientX - drag.pointerX, + y: drag.offset.y + event.clientY - drag.pointerY, + }) + } + + const endDrag = (event: ReactPointerEvent): void => { + if (dragRef.current === null) return + dragRef.current = null + event.currentTarget.releasePointerCapture?.(event.pointerId) + document.body.style.userSelect = '' + } + // 全空时保持隐藏(与官方 StatsLine 的零数据策略一致)。 if (!hasTokens && model === undefined && balanceStatus !== 'ok' && balanceStatus !== 'loading' && !expanded) { return null @@ -228,6 +305,7 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { aria-expanded={expanded} title={expanded ? copy.collapseUsage : copy.expandUsage} onClick={toggle} + onDoubleClick={clearOffset} > {copy.usage} @@ -270,8 +348,17 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { {expanded && anchor !== null && (
+
= { zh: { input: '输入', output: '输出', cache: '缓存', cost: '成本', balance: '余额', usage: '用量', - expandUsage: '展开用量面板', collapseUsage: '收起用量面板', retryBalanceTitle: '点击重试余额查询', officialBalanceTitle: '余额来自官方接口', + expandUsage: '展开用量面板', collapseUsage: '收起用量面板', dragPanelTitle: '拖动可移动面板,双击「用量」复位', retryBalanceTitle: '点击重试余额查询', officialBalanceTitle: '余额来自官方接口', usageDetails: '会话用量详情', sessionUsage: '会话用量', billedInput: '计费输入', cacheHit: '缓存命中', contextUsage: '上下文占用', unavailable: '暂无', sessionEmpty: '发送消息后,这里会显示当前会话的 Token 用量。', costEstimate: '成本估算', inputCost: '输入', outputCost: '输出', @@ -205,7 +206,7 @@ const COPY: Record = { }, en: { input: 'Input', output: 'Output', cache: 'Cache', cost: 'Cost', balance: 'Balance', usage: 'Usage', - expandUsage: 'Expand usage panel', collapseUsage: 'Collapse usage panel', retryBalanceTitle: 'Retry balance query', officialBalanceTitle: 'Balance from the official API', + expandUsage: 'Expand usage panel', collapseUsage: 'Collapse usage panel', dragPanelTitle: 'Drag to move the panel; double-click "Usage" to reset', retryBalanceTitle: 'Retry balance query', officialBalanceTitle: 'Balance from the official API', usageDetails: 'Session usage details', sessionUsage: 'Session usage', billedInput: 'Billed input', cacheHit: 'Cache hit', contextUsage: 'Context used', unavailable: 'N/A', sessionEmpty: 'Token usage will appear after you send a message.', costEstimate: 'Estimated cost', inputCost: 'Input', outputCost: 'Output', diff --git a/src/client/panel-position.ts b/src/client/panel-position.ts new file mode 100644 index 0000000..0d0d2bb --- /dev/null +++ b/src/client/panel-position.ts @@ -0,0 +1,63 @@ +/** + * 悬浮面板的位置偏移:读取与夹取的纯函数(无 React / 无 DOM 依赖,便于单测)。 + * + * 面板是 `position: fixed`;祖先含 transform / filter / contain 时,坐标基准是那个 + * 祖先而不是视口(见 UsageIndicator 的 containingBlock())。拖动只做 + * `transform: translate()` 的相对位移,因此与包含块坐标换算互不干扰。 + */ + +/** 拖动偏移的持久化键(沿用既有键名,升级后已保存的位置不丢)。 */ +export const PANEL_POS_KEY = 'dsh-usage-chart:panel-pos' + +/** 拖到边界时,面板在包含块内至少保留的可见像素。 */ +export const MIN_VISIBLE_PX = 48 + +export interface PanelOffset { + x: number + y: number +} + +/** 视口坐标下的矩形(DOMRect 的最小子集)。 */ +export interface PanelRect { + left: number + top: number + right: number + bottom: number +} + +/** 读取已持久化的偏移;缺失、坏 JSON、非有限数值一律回 {0,0}。 */ +export function readPanelOffset( + storage: Pick | undefined = typeof localStorage === 'undefined' ? undefined : localStorage, +): PanelOffset { + try { + const raw = storage?.getItem(PANEL_POS_KEY) ?? null + if (raw === null) return { x: 0, y: 0 } + const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown } | null + const finite = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0) + return { x: finite(parsed?.x), y: finite(parsed?.y) } + } catch { + return { x: 0, y: 0 } + } +} + +/** + * 夹取偏移:未位移的面板矩形 panel 平移 offset 后,四条边都至少保留 + * minVisible 像素落在包含块 bounds 内——任意方向都拖不出可视区。 + * 面板本身比包含块还大(该轴 min > max)时保持原值,避免被顶到反方向。 + */ +export function clampPanelOffset( + offset: PanelOffset, + panel: PanelRect, + bounds: PanelRect, + minVisible: number = MIN_VISIBLE_PX, +): PanelOffset { + const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) + const minX = bounds.left - panel.right + minVisible + const maxX = bounds.right - panel.left - minVisible + const minY = bounds.top - panel.bottom + minVisible + const maxY = bounds.bottom - panel.top - minVisible + return { + x: minX <= maxX ? clamp(offset.x, minX, maxX) : offset.x, + y: minY <= maxY ? clamp(offset.y, minY, maxY) : offset.y, + } +} diff --git a/src/client/styles.ts b/src/client/styles.ts index 51760f6..e7969cd 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -92,6 +92,30 @@ export const PLUGIN_CSS = ` } body[data-ds-dark-theme] .duc-popover { box-shadow: 0 16px 40px rgba(0, 0, 0, 0.42); } +/* 拖动把手:sticky 在面板顶部(面板自身可滚动),横条只是视觉提示。 */ +.duc-popover-handle { + position: sticky; + top: 0; + z-index: 1; + display: block; + width: 100%; + height: 14px; + border-radius: 10px 10px 0 0; + background: var(--dsw-alias-bg-layer-2, rgba(30, 31, 34, 0.92)); + cursor: grab; + touch-action: none; +} +.duc-popover-handle::before { + content: ''; + display: block; + width: 36px; + height: 4px; + margin: 5px auto 0; + border-radius: 2px; + background: var(--dsw-alias-border-l3, rgba(127, 127, 127, 0.4)); +} +.duc-popover-handle:active { cursor: grabbing; } + .duc-panel { width: 100%; padding: 0 14px; diff --git a/tests/panel-position.test.mjs b/tests/panel-position.test.mjs new file mode 100644 index 0000000..92f09b8 --- /dev/null +++ b/tests/panel-position.test.mjs @@ -0,0 +1,59 @@ +/** + * 面板拖动偏移的纯函数回归:持久化读取容错 + 边界夹取。 + * 交互本身(pointer 拖动 / 双击复位)由运行中的 DSH Web 手工验证;这里锁住数学与容错。 + */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +/** 与 merge-regression.test.mjs 同款:esbuild 打包 src 后以 ESM 载入(同 realm)。 */ +async function loadModule(path) { + const result = await build({ + entryPoints: [fileURLToPath(new URL(path, import.meta.url))], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + }) + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].contents).toString('base64')}`) +} + +const { readPanelOffset, clampPanelOffset, PANEL_POS_KEY, MIN_VISIBLE_PX } = + await loadModule('../src/client/panel-position.ts') + +/** 只实现 getItem 的假存储。 */ +const storage = (raw) => ({ getItem: (key) => (key === PANEL_POS_KEY ? raw : null) }) + +test('readPanelOffset: missing / broken / invalid values fall back to zero', () => { + assert.deepEqual(readPanelOffset(storage(null)), { x: 0, y: 0 }) + assert.deepEqual(readPanelOffset(storage('not json')), { x: 0, y: 0 }) + assert.deepEqual(readPanelOffset(storage('null')), { x: 0, y: 0 }) + assert.deepEqual(readPanelOffset(storage('{"x":12}')), { x: 12, y: 0 }) + assert.deepEqual(readPanelOffset(storage('{"x":"12","y":null}')), { x: 0, y: 0 }) + assert.deepEqual(readPanelOffset(storage('{"x":1e999,"y":-3}')), { x: 0, y: -3 }) + assert.deepEqual(readPanelOffset(undefined), { x: 0, y: 0 }) +}) + +test('readPanelOffset: valid offsets round-trip', () => { + assert.deepEqual(readPanelOffset(storage('{"x":-40,"y":120}')), { x: -40, y: 120 }) +}) + +const BOUNDS = { left: 0, top: 0, right: 1000, bottom: 800 } +const PANEL = { left: 100, top: 100, right: 600, bottom: 500 } + +test('clampPanelOffset: keeps MIN_VISIBLE_PX of the panel inside the containing block', () => { + const right = clampPanelOffset({ x: 5000, y: 5000 }, PANEL, BOUNDS) + assert.equal(right.x, BOUNDS.right - PANEL.left - MIN_VISIBLE_PX) + assert.equal(right.y, BOUNDS.bottom - PANEL.top - MIN_VISIBLE_PX) + const left = clampPanelOffset({ x: -5000, y: -5000 }, PANEL, BOUNDS) + assert.equal(left.x, BOUNDS.left - PANEL.right + MIN_VISIBLE_PX) + assert.equal(left.y, BOUNDS.top - PANEL.bottom + MIN_VISIBLE_PX) +}) + +test('clampPanelOffset: in-range offsets and oversized panels pass through', () => { + assert.deepEqual(clampPanelOffset({ x: 12, y: -20 }, PANEL, BOUNDS), { x: 12, y: -20 }) + const huge = { left: 0, top: 0, right: 1400, bottom: 900 } + assert.deepEqual(clampPanelOffset({ x: 300, y: 300 }, huge, BOUNDS), { x: 300, y: 300 }) +})