背景:上一版仍沿用"每帧移动/缩放透明窗口"的做法,实机上会出现形变抖动与残影
(用户反馈"越改越烂、开合震动还在")。本次按 WinIsland 的真实做法重做架构。
主进程(packages/desktop-client/src/main/island.ts):
- 窗口一次创建为**固定包围盒**(最大形态 + ISLAND_SHADOW_PAD 阴影留白),
开合过程中不再调用 setBounds;只在"尺寸/位置设置变化"时重排一次窗口
- 新增 setInteractive():默认 setIgnoreMouseEvents(true,{forward:true}) 整块穿透,
由渲染层命中测试回调切换(照搬 WinIsland set_cursor_hittest),保证不吞桌面点击
- 移除逐帧几何代码(animateBounds/boundsFor/computeBounds/resolveAnchor 用于窗口定位的简化版保留)
- 空闲细缝不再受 Windows 36px 最小窗口高度限制(窗口固定,岛可真正缩到 6px 宽)
渲染层(IslandApp.vue + 新增 spring.ts):
- 岛是**单一元素**,宽/高/圆角由 WinIsland 弹簧逐帧逼近:
force=(target-value)*0.10*dt → velocity=(velocity+force)*0.68^dt → value+=velocity*dt
(dt 以帧计;换向保留 35% 动量、单帧位移 ≤ 剩余距离 20%),并加零过冲钳制
- 胶囊层与展开层常驻并交叉淡入(展开 progress²、胶囊 1-1.5×progress),形变全程无缩放回弹
- 形状层改为显式像素宽高 + preserveAspectRatio="none"(避免 viewBox 缩放导致形状错位/残留角)
- 修两个实机可见缺陷:① 展开层漏了 flex-direction: column(展开卡内容横排、元素散落);
② 隐藏且无细缝时不再把岛缩成细缝(否则下次弹出会"从细缝长大")
- 尺寸增量 ISLAND_SIZE_DELTA / 阴影留白移入 @classhelper/shared,主/渲染两端共用一份
验证(verify:desktop 53 → 54 项,全绿):
- 新增「开合过程中窗口全程恒定」(逐帧窗口签名恒为 476x282@482,-2,岛 386x230→…→268x44 单调收缩,
SVG 形状 bbox 与卡片框一致 ±2px)
- 新增「岛外鼠标穿透」(岛内 interactive=true / 岛外 false)
- 开合不震动改为按"岛"判定:窗口冻结 + 岛锚点 0.00px + 无过冲;截图按岛矩形裁剪并统计岛外红像素=0
- 其余能力(通知/作业/叫人/紧急、标为已读、上课隐藏、6 锚点、字号可调、设置立即生效、空闲细缝)全部保留
116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
/**
|
||
* WinIsland 同款弹簧(照搬 `src/utils/physics.rs` 与 `src/window/app/frame.rs` 的参数与更新式)。
|
||
*
|
||
* 原始实现(Rust,每帧调用一次,`dt` 以**帧**为单位):
|
||
* ```text
|
||
* force = (target - value) * stiffness * dt
|
||
* velocity = (velocity + force) * damping^dt
|
||
* value += velocity * dt
|
||
* ```
|
||
* 主岛尺寸用的参数:`stiffness = 0.10`、`damping = 0.68`(阻尼比 ζ≈0.977,接近临界阻尼,
|
||
* 理论过冲 ~1e-6,肉眼不可见)。换向时还有两个限制(`redirect_velocity_towards`):
|
||
* 反向动量只保留 35%、单帧位移不超过剩余距离的 20%。
|
||
*
|
||
* 我们在此基础上**额外加了零过冲钳制**(越过目标立刻吸附并清零速度):
|
||
* 即便某帧卡顿导致 dt 变大,也绝不会出现"超过目标再弹回来"的震动。
|
||
*/
|
||
export interface SpringOptions {
|
||
/** 刚度(WinIsland 主岛尺寸:0.10) */
|
||
stiffness?: number;
|
||
/** 速度保留率(WinIsland 主岛尺寸:0.68) */
|
||
damping?: number;
|
||
/** 吸附阈值(px) */
|
||
snapThreshold?: number;
|
||
}
|
||
|
||
const MOMENTUM_RETENTION = 0.35;
|
||
const MAX_DISTANCE_RATIO = 0.2;
|
||
const MAX_DT = 6;
|
||
/** 一帧按 60fps 折算(与 WinIsland 的 dt 单位一致) */
|
||
const FRAME_MS = 1000 / 60;
|
||
|
||
export class SpringValue {
|
||
value: number;
|
||
velocity = 0;
|
||
target: number;
|
||
|
||
private readonly stiffness: number;
|
||
private readonly damping: number;
|
||
private readonly snapThreshold: number;
|
||
|
||
constructor(initial: number, options: SpringOptions = {}) {
|
||
this.value = initial;
|
||
this.target = initial;
|
||
this.stiffness = options.stiffness ?? 0.1;
|
||
this.damping = options.damping ?? 0.68;
|
||
this.snapThreshold = options.snapThreshold ?? 0.05;
|
||
}
|
||
|
||
setTarget(target: number): void {
|
||
if (target === this.target) return;
|
||
this.redirectVelocity(target);
|
||
this.target = target;
|
||
}
|
||
|
||
/** 直接落位(关闭动画 / 首次同步时使用) */
|
||
snap(target: number): void {
|
||
this.target = target;
|
||
this.value = target;
|
||
this.velocity = 0;
|
||
}
|
||
|
||
get settled(): boolean {
|
||
return Math.abs(this.target - this.value) <= this.snapThreshold && Math.abs(this.velocity) <= 0.01;
|
||
}
|
||
|
||
/**
|
||
* 换向限速(WinIsland):反向时只保留 35% 动量,并把速度幅值限制在"剩余距离的 20%"以内,
|
||
* 保证不会因为方向切换产生突跳。
|
||
*/
|
||
private redirectVelocity(target: number): void {
|
||
const distance = target - this.value;
|
||
if (distance === 0) {
|
||
this.velocity = 0;
|
||
return;
|
||
}
|
||
const sameDirection = Math.sign(distance) === Math.sign(this.velocity);
|
||
if (!sameDirection) this.velocity *= MOMENTUM_RETENTION;
|
||
const maxVelocity = Math.abs(distance) * MAX_DISTANCE_RATIO;
|
||
if (Math.abs(this.velocity) > maxVelocity) {
|
||
this.velocity = Math.sign(this.velocity) * maxVelocity;
|
||
}
|
||
}
|
||
|
||
/** 推进一帧;返回是否仍在运动 */
|
||
step(deltaMs: number): boolean {
|
||
const dt = Math.min(MAX_DT, Math.max(0.1, deltaMs / FRAME_MS));
|
||
const distance = this.target - this.value;
|
||
if (distance === 0 && Math.abs(this.velocity) <= 0.01) {
|
||
this.velocity = 0;
|
||
this.value = this.target;
|
||
return false;
|
||
}
|
||
|
||
const force = distance * this.stiffness * dt;
|
||
this.velocity = (this.velocity + force) * Math.pow(this.damping, dt);
|
||
|
||
const previous = this.value;
|
||
this.value += this.velocity * dt;
|
||
|
||
// 零过冲钳制:越过目标(或已足够接近)就吸附到目标并停止
|
||
const crossed = (this.target - previous) * (this.target - this.value) <= 0;
|
||
if (crossed || Math.abs(this.target - this.value) <= this.snapThreshold) {
|
||
this.value = this.target;
|
||
this.velocity = 0;
|
||
return false;
|
||
}
|
||
|
||
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
|
||
this.value = this.target;
|
||
this.velocity = 0;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
}
|