fix(ui): 界面缩放改用 transform scale — 修复缩放非 100% 时图表鼠标命中偏移

This commit is contained in:
shy3130
2026-10-14 15:11:46 +08:00
parent af45efcf01
commit ca7801dc14
9 changed files with 139 additions and 47 deletions
+20 -9
View File
@@ -5,7 +5,6 @@
<script>
// 预渲染主题脚本: 首屏前设置 html.dark, 避免亮暗闪烁 (FOUC)。
// 默认暗色; 用户切过亮色后 (localStorage tf-theme=light) 保持亮色。
// 同步应用页面缩放 (tf-page-zoom, 设置→系统→界面缩放), 避免加载后尺寸跳变。
(function () {
try {
if (localStorage.getItem('tf-theme') !== 'light') {
@@ -14,14 +13,6 @@
} catch (e) {
document.documentElement.classList.add('dark')
}
try {
var raw = localStorage.getItem('tf-page-zoom')
var z = raw == null ? 0.9 : JSON.parse(raw)
if (typeof z !== 'number' || z < 0.8 || z > 1.5) z = 0.9
document.documentElement.style.zoom = String(z)
} catch (e) {
document.documentElement.style.zoom = '0.9'
}
})()
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -32,6 +23,26 @@
</head>
<body class="bg-base text-foreground">
<div id="root"></div>
<script>
// 预渲染缩放脚本: #root transform scale (方案详见 src/lib/pageZoom.ts —
// 不用 CSS zoom, zoom≠1 会让 ECharts 鼠标命中偏移)。
// 放在 #root 之后、main.tsx (module 延迟执行) 之前: 首帧绘制前生效, 无尺寸跳变。
(function () {
var z = 1
try {
var raw = localStorage.getItem('tf-page-zoom')
var v = raw == null ? 1 : JSON.parse(raw)
if (typeof v === 'number' && v >= 0.8 && v <= 1.5) z = v
} catch (e) { /* 解析失败保底不缩放 */ }
if (z !== 1) {
var root = document.getElementById('root')
root.style.transformOrigin = '0 0'
root.style.transform = 'scale(' + z + ')'
root.style.width = (100 / z) + '%'
root.style.height = (100 / z) + '%'
}
})()
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8 -3
View File
@@ -21,6 +21,7 @@ import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { getPageScale } from '@/lib/pageZoom'
import { compareByDefaultNav, defaultNavIndex, seedHiddenForNewUser } from '@/lib/navOrder'
import {
Settings,
@@ -228,10 +229,14 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine
const openPop = () => {
window.clearTimeout(closeTimer.current)
const rect = linkRef.current?.getBoundingClientRect()
if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 })
// rect 是视觉坐标, fixed 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale 换算。
if (rect) {
const z = getPageScale()
setPopPos({ left: rect.right / z, top: (rect.top + rect.height / 2) / z })
}
}
const closePop = () => {
closeTimer.current = window.setTimeout(() => setPopPos(null), 80)
window.setTimeout(() => setPopPos(null), 80)
}
useEffect(() => () => window.clearTimeout(closeTimer.current), [])
useLayoutEffect(() => {
@@ -239,7 +244,7 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine
const h = popRef.current.offsetHeight
const margin = 8
const minCenter = margin + h / 2
const maxCenter = window.innerHeight - margin - h / 2
const maxCenter = window.innerHeight / getPageScale() - margin - h / 2
const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top))
if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped })
}, [popPos])
@@ -16,7 +16,24 @@
* 非编辑状态下布局意外写回)。
*/
import { useCallback, useMemo, type ReactNode } from 'react'
import GridLayout, { useContainerWidth, type Layout } from 'react-grid-layout'
import GridLayout, { useContainerWidth, setTransform, type Layout } from 'react-grid-layout'
import { getPageScale } from '@/lib/pageZoom'
/** RGL 2.x 的「容器被 transform: scale 缩放」定位策略 (包根未导出 createScaledStrategy,
* 按 dist 内官方实现等价构造): 定位走 transform, 拖拽像素位移按 scale 换算回布局坐标。
* 界面缩放挂在 #root (见 lib/pageZoom.ts), dashboard 网格在缩放层内, 不补偿则
* 缩放≠100% 时拖拽跟手速度偏差 1/scale。 */
function scaledStrategy(scale: number) {
return {
type: 'transform' as const,
scale,
calcStyle: (pos: Parameters<typeof setTransform>[0]) => setTransform(pos),
calcDragPosition: (clientX: number, clientY: number, offsetX: number, offsetY: number) => ({
left: (clientX - offsetX) / scale,
top: (clientY - offsetY) / scale,
}),
}
}
import 'react-grid-layout/css/styles.css'
import './grid.css'
import { X } from 'lucide-react'
@@ -93,6 +110,7 @@ export function DashboardGrid({ ctx, items, onItemsChange, editing }: DashboardG
<GridLayout
width={width}
layout={rglLayout}
positionStrategy={scaledStrategy(getPageScale())}
gridConfig={{ cols: GRID_COLS, rowHeight: GRID_ROW_HEIGHT, margin: [GRID_MARGIN, GRID_MARGIN], containerPadding: null, maxRows: Infinity }}
dragConfig={{
enabled: editing,
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore'
import { getPageScale } from '@/lib/pageZoom'
import type { ActiveTask } from '@/lib/aiReportStore'
/**
@@ -42,8 +43,10 @@ export function AiReportBubble() {
}, [])
const clamp = useCallback((x: number, y: number) => {
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight - 80
// innerWidth 是视觉像素, translate3d 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale。
const z = getPageScale()
const maxX = window.innerWidth / z - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight / z - 80
return {
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
@@ -61,8 +64,10 @@ export function AiReportBubble() {
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current) return
const dx = e.clientX - dragData.current.mx
const dy = e.clientY - dragData.current.my
// clientX 是视觉像素, 位移需 ÷ scale 换算回布局坐标, 否则缩放下拖拽跟手速度偏差 1/z。
const z = getPageScale()
const dx = (e.clientX - dragData.current.mx) / z
const dy = (e.clientY - dragData.current.my) / z
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
const nx = dragData.current.ox + dx
const ny = dragData.current.oy + dy
@@ -232,8 +237,10 @@ function BubbleItem({ task, isLast, onPointerDown }: {
const POS_KEY = 'ai_bubble_pos'
function loadPos(): { x: number; y: number } {
// 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素)
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200)
// innerWidth 视觉像素 ÷ scale = #root 布局坐标 (缩放层)。
const z = getPageScale()
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth / z - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight / z - 200)
try {
const v = localStorage.getItem(POS_KEY)
if (v) {
@@ -241,8 +248,8 @@ function loadPos(): { x: number; y: number } {
if (typeof p.x === 'number' && typeof p.y === 'number') {
// 钳制到当前视口(防止保存的位置在缩小后的窗口外)
return {
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth / z - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight / z - 80, p.y)),
}
}
}
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
import type { ActiveTask } from '@/lib/stockAnalysisStore'
import { getPageScale } from '@/lib/pageZoom'
/**
* AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
@@ -30,8 +31,10 @@ export function StockAnalysisBubble() {
}, [])
const clamp = useCallback((x: number, y: number) => {
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight - 80
// innerWidth 是视觉像素, translate3d 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale。
const z = getPageScale()
const maxX = window.innerWidth / z - BUBBLE_W - EDGE_MARGIN
const maxY = window.innerHeight / z - 80
return {
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
@@ -49,8 +52,10 @@ export function StockAnalysisBubble() {
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!draggingRef.current) return
const dx = e.clientX - dragData.current.mx
const dy = e.clientY - dragData.current.my
// clientX 是视觉像素, 位移需 ÷ scale 换算回布局坐标, 否则缩放下拖拽跟手速度偏差 1/z。
const z = getPageScale()
const dx = (e.clientX - dragData.current.mx) / z
const dy = (e.clientY - dragData.current.my) / z
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
applyTransform(c.x, c.y)
@@ -186,16 +191,18 @@ function BubbleItem({ task, isLast, onPointerDown }: {
const POS_KEY = 'sa_bubble_pos'
function loadPos(): { x: number; y: number } {
// 默认右下,偏上一点(避开财务胶囊的右下位置)
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
// innerWidth 视觉像素 ÷ scale = #root 布局坐标 (缩放层)。
const z = getPageScale()
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth / z - BUBBLE_W - EDGE_MARGIN)
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight / z - 320)
try {
const v = localStorage.getItem(POS_KEY)
if (v) {
const p = JSON.parse(v)
if (typeof p.x === 'number' && typeof p.y === 'number') {
return {
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth / z - BUBBLE_W - EDGE_MARGIN, p.x)),
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight / z - 80, p.y)),
}
}
}
@@ -90,7 +90,8 @@ export function AssistantDrawer() {
<DrawerPanel key="assistant-drawer" sending={sending} messages={messages} />
)}
</AnimatePresence>,
document.body,
// #root (缩放层): 抽屉随界面缩放, 且与 Modal 同层叠上下文保持 z 序设计。
document.getElementById('root') ?? document.body,
)
}
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import { Sparkles } from 'lucide-react'
import { cn } from '@/lib/cn'
import { getPageScale } from '@/lib/pageZoom'
import { toggleAssistant, useAssistantStore } from '../store'
const POS_STORAGE_KEY = 'assistant.fab.pos.v1'
@@ -21,12 +22,11 @@ interface BallPos {
y: number
}
/** 页面 CSS zoom (html 上): 视觉坐标(getBoundingClientRect/clientX) 与
* 布局坐标(style.left) 在 zoom≠1 时相差 zoom 倍, 拖拽换算必须经过它,
/** 页面缩放 (#root transform scale): 视觉坐标(getBoundingClientRect/clientX) 与
* 布局坐标(style.left) 在 scale≠1 时相差 scale 倍, 拖拽换算必须经过它,
* 否则 90% 缩放下每次拖动球会向左上跳约 11%。 */
function pageZoom(): number {
const v = parseFloat(getComputedStyle(document.documentElement).zoom)
return Number.isFinite(v) && v > 0 ? v : 1
return getPageScale()
}
function clampPos(pos: BallPos): BallPos {
@@ -124,8 +124,9 @@ export function AssistantFloatingButton() {
toggleAssistant()
}
// Portal 到 body: 挂载点在侧栏 aside 内, 移动端 aside 带 transform,
// 会让 fixed 相对 aside 定位并被 overflow-hidden 裁剪。
// Portal 到 #root (缩放层): 与 Modal 同层叠上下文, z 序不变 (弹窗 z-50 盖住球 z-30)。
// 挂载点在侧栏 aside 内, 移动端 aside 带 transform 会让 fixed 相对 aside 定位并被
// overflow-hidden 裁剪; #root 的 transform 同理, 但 #root 占满视口且不裁剪, 等价于视口。
return createPortal(
<motion.button
ref={ballRef}
@@ -161,6 +162,6 @@ export function AssistantFloatingButton() {
/>
)}
</motion.button>,
document.body,
document.getElementById('root') ?? document.body,
)
}
+9 -3
View File
@@ -68,9 +68,9 @@ html.dark {
/* 默认 1px 细分隔线(暗色用边框区分层次,不用 shadow) */
* { border-color: hsl(var(--border)); }
/* 页面缩放 (设置→系统→界面缩放) 用 CSS zoom 作用于 <html>:
* vh 单位不随 zoom 缩放 (zoom<1 底部露底 / zoom>1 溢出), 百分比高度链才会 —
* 根链路统一 % 高度, 主布局与加载占位用 h-full/min-h-full 而非 h-screen。 */
/* 页面缩放 (设置→系统→界面缩放) 用 #root transform: scale 实现 (见 lib/pageZoom.ts):
* #root 宽高按 1/z 百分比补偿, 根链路必须是 % 高度链 (vh 不参与换算) —
* 主布局与加载占位用 h-full/min-h-full 而非 h-screen。 */
html, body, #root { height: 100%; }
body {
@@ -84,6 +84,12 @@ body {
}
/* 选区用主色 (默认蓝底破坏暗色氛围) */
/* 联想下拉 (SuggestInput portal) 进入动画: 2px 上浮 + 淡入, 120ms 快进快出 */
@keyframes sg-drop-in {
from { opacity: 0; transform: translateY(-2px) scale(0.995); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
::selection {
background: hsl(var(--accent) / 0.3);
}
+43 -7
View File
@@ -1,10 +1,19 @@
/**
* 页面整体缩放 — 桌面客户端 (pywebview) 里没有浏览器缩放快捷键,
* 由 设置→系统→界面缩放 控制。CSS zoom 作用于 <html>, 全组件等比缩放。
* 由 设置→系统→界面缩放 控制。
*
* - 值域 0.8–1.5, 步进 0.05, localStorage 持久化 (storage.pageZoom); 默认 0.9。
* - index.html 的预渲染脚本会在首屏前应用, 避免加载后尺寸跳变。
* 实现: #root 上 transform: scale(z) + origin 0 0 + 宽高按 1/z 补偿。
* 不用 CSS zoom: zoom≠1 时视觉坐标 (clientX/rect) 与布局坐标分离,
* ECharts/zrender 的鼠标命中不做换算 (echarts#15103), 十字光标/tooltip
* 会偏移约 (1-1/z); 而 transform 下浏览器规范对 offsetX 自动逆变换回
* 元素本地坐标系, 图表与拖拽类库零改动即正确。
*
* - 值域 0.8–1.5, 步进 0.05, localStorage 持久化 (storage.pageZoom); 默认 1。
* - JS 定位的浮层 (#root 内 fixed/absolute) 需把视觉坐标换算回布局坐标:
* 统一用 getPageScale() 相除 (÷ scale)。
* - index.html 在 #root 之后有预渲染内联脚本, 首帧绘制前应用, 无跳变。
* - 应用后派发 window resize, echarts 等监听 resize 的图表随之重排。
* - z=1 时清空 transform/宽高, 与未开启缩放的状态完全一致。
*/
import { storage } from '@/lib/storage'
@@ -12,22 +21,49 @@ export const ZOOM_MIN = 0.8
export const ZOOM_MAX = 1.5
export const ZOOM_STEP = 0.05
export const ZOOM_DEFAULT = 0.9
export const ZOOM_DEFAULT = 1
function clamp(v: number): number {
if (!Number.isFinite(v)) return ZOOM_DEFAULT
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(v * 100) / 100))
}
/** 把缩放应用到 <html> 并通知图表重排, 返回 clamp 后的实际值。 */
/** 把缩放应用到 #root 并通知图表重排, 返回 clamp 后的实际值。 */
export function applyPageZoom(v: number): number {
const z = clamp(v)
document.documentElement.style.zoom = String(z)
const root = document.getElementById('root')
if (root) {
if (z === 1) {
root.style.transform = ''
root.style.width = ''
root.style.height = ''
} else {
root.style.transformOrigin = '0 0'
root.style.transform = `scale(${z})`
// 布局占位 = 视口/z, 缩放后视觉正好铺满视口; 不补偿会留白 (z<1) 或溢出 (z>1)。
root.style.width = `${100 / z}%`
root.style.height = `${100 / z}%`
}
}
window.dispatchEvent(new Event('resize'))
return z
}
/** 读取持久化的缩放值 (越界/缺失回落默认 0.9)。 */
/** 当前生效的缩放 (读 #root transform 矩阵, 未缩放时 1)。
* 浮层定位/拖拽做视觉→布局坐标换算时用。 */
export function getPageScale(): number {
const root = document.getElementById('root')
const m = root ? getComputedStyle(root).transform : ''
if (m && m !== 'none') {
try {
const s = new DOMMatrixReadOnly(m).a
if (Number.isFinite(s) && s > 0) return s
} catch { /* matrix 解析失败按未缩放处理 */ }
}
return 1
}
/** 读取持久化的缩放值 (越界/缺失回落默认 1)。 */
export function getPageZoom(): number {
return clamp(storage.pageZoom.get(ZOOM_DEFAULT))
}