@yfwu2020/dsh-selection-explain 0.4.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -27,6 +27,8 @@ window.__ModuleLoader__.load({
27
27
  var API = '/selection-explain/api/analyze'
28
28
  var PING = '/selection-explain/api/ping'
29
29
  var PROMOTE = '/selection-explain/api/promote'
30
+ /** 胶囊拖到别处后的位置(记在 host 上:拖到别处→存,吸附回右下角→清,挂载→读回来)。 */
31
+ var PILL_POS = '/selection-explain/api/pill'
30
32
  var HISTORY = '/selection-explain/api/history'
31
33
  /** 模型清单(带每个模型支持的推理等级)。 */
32
34
  var MODELS = '/selection-explain/api/models'
@@ -102,6 +104,88 @@ window.__ModuleLoader__.load({
102
104
  var FONT =
103
105
  '-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif'
104
106
 
107
+ // —— 悬浮状态胶囊的几何/手感参数(CSS 与 placePill 都要用,所以放这一层) ——
108
+ /**
109
+ * **兜底**最大宽度:只有"量不到费用胶囊"时才生效(见 placePill)。
110
+ *
111
+ * 有费用胶囊时宽度写死成它的实测宽度 —— 两者共用 CSS 里那条 `max-width:280px`,
112
+ * 所以并排时同宽同位置;而单独一枚浮在右下角时 280 显得"横着一条"(旁边什么都没有,
113
+ * 分量感全在这条胶囊上),收到 200。取值依据(真浏览器 8x 截图 + 量墨迹):
114
+ * · 短内容「划词解读 · 就绪」自然宽度 146.6px → 不受影响;
115
+ * · 英文选中「the migratio… · 完成 · 3 轮」214px → 会截一点;
116
+ * · 最坏组合「12 字中文 + · 追问中 12.3s」名字那格还剩 ~54px ≈ 3 个字。
117
+ * 状态格 `flex:0 0 auto` 永不缩 —— 宽度不够时只牺牲选中文字(省略号收尾)。
118
+ */
119
+ var PILL_FALLBACK_MAX = 200
120
+ /** 收成小球后的直径(px)。收球 = 把 max-width 收到它(能过渡,所以收/放都是动画)。 */
121
+ var PILL_BALL_SIZE = 32
122
+ /** 静置多久收成小球(毫秒)——"长时间没有点击或者小窗活动"的那个"长时间"。 */
123
+ var PILL_BALL_IDLE_MS = 10000
124
+ /** 松手时离"贴角位置"多近就吸附回去(px,按左上角比)。 */
125
+ var PILL_SNAP_RADIUS = 96
126
+ /** 位移超过多少像素才算"拖"而不是"点"(px,曼哈顿距离)。 */
127
+ var PILL_DRAG_SLOP = 4
128
+ /**
129
+ * 收球 / 展开那条过渡(时长 + 曲线),CSS 里多处引用。
130
+ *
131
+ * 曲线选 `cubic-bezier(.5,0,.5,1)` 是因为它**时间对称**:控制点关于中心对称,
132
+ * 于是 p(t) = 1 - p(1-t) —— 把收球的动画倒过来放,恰好就是展开的动画。
133
+ * 用户报的收放不对称就是这么来的:之前用 easeOutQuint,前后半程不镜像,
134
+ * 收是「快起慢收」、放也是「快起慢收」,两个方向看着像两件事。
135
+ * 时长给 .34s:所有会变的属性共用它(任何一条不一致,中间帧的宽度就由另一条曲线决定,对称性就破了)。
136
+ */
137
+ var PILL_MORPH = '.28s cubic-bezier(.4,0,.6,1)'
138
+ /**
139
+ * 收球后球心里的星芒 —— 与「✦ 解读」浮标那颗星**逐点同形**。
140
+ *
141
+ * 按钮里那颗星是 16 画布上的八边形:外半径 5.6、内半径 2.1213(内外比 .3788)、四条边是直线
142
+ * (path `M8 2.4l1.5 4.1 4.1 1.5-4.1 1.5L8 13.6 6.5 9.5 2.4 8l4.1-1.5L8 2.4z`)。
143
+ * 球里的尺寸:外半径 6.0 → 墨迹 12.0px —— 和胶囊里文字(字号 12px)同高(用户要求)。
144
+ * (原来按 1.5 倍放大到 16.8px,在 32px 球里显得比文字壮太多。)
145
+ *
146
+ * 形变方式:把"圆点"和"星"都看成 **32 边形**逐顶点插值 ——
147
+ * · PILL_STAR_DOT_D:32 个顶点同半径 4 → 8px 圆点(8px 下与正圆无差,最大偏差 0.02px)
148
+ * · PILL_STAR_D:32 个顶点正好落在星边界上(尖与谷仍是精确顶点)→ 与按钮那条 path 逐点相同
149
+ * 两条 path 命令结构一致(M + 31×L + Z),所以 Chrome 能对 `d` 做插值。
150
+ * 于是"圆点长成星芒"是**同一个形状在长**,而不是"淡出一个、淡入另一个"(那样中间帧会掉分量)。
151
+ *
152
+ * ⚠️ 别改成"八个顶点一起插值":数学上更短,但中间帧会经过一个**正菱形**(谷还没收、边是直的),
153
+ * 读起来像"方块",和圆点/星芒都不是一路。必须取样到 32 个顶点(星那条边上的取样点精确落在线段上)。
154
+ * 顶点由 `node scripts/gen-star-path.mjs` 生成,可重跑核对。
155
+ */
156
+ /**
157
+ * 收放时长(毫秒)—— 必须与 PILL_MORPH 里的 .28s 一致:CSS 管胶囊、JS 管球心图形,
158
+ * 两者同拍才是"同一件事"。见 animatePillStar。
159
+ */
160
+ var PILL_MORPH_MS = 280
161
+ /** 形变采样点数(圆与星都按 32 边形逐顶点插值)。 */
162
+ var PILL_STAR_SAMPLES = 32
163
+ /** 尖/身两段的交界:尖先抽出来(0→.62),身再收成星(.38→1),重叠 .24 段保证无速度断点。 */
164
+ var PILL_STAR_TIP_END = 0.62
165
+ var PILL_STAR_BODY_START = 0.38
166
+ /**
167
+ * 球心图形那条曲线(时间对称:.45+.55=1、0+1=1 → 倒放即另一个方向)。
168
+ * 比胶囊那条 (.4,0,.6,1) 略收一点,于是两层不是同一个节拍:胶囊滑、图形落定得干脆些。
169
+ * ⚠️ 试过给曲线加"过冲"(y 越出 [0,1])——**不行**:时间对称的 cubic-bezier 只要 y1+y2=1,
170
+ * 曲线就必是"上-下-上"的波浪(实测 (.4,1.12,.6,-.12) 在 0.25 处已经 0.40、0.75 处才 0.60),
171
+ * 看着是一顿一顿的。过冲要放在**形状空间**里(PILL_STAR_POP),时间轴上始终是干净的 S。
172
+ */
173
+ var PILL_STAR_EASE = [0.45, 0, 0.55, 1]
174
+ /**
175
+ * 形状空间的过冲:尖长到位后再"冲出去一点点"再收回(0.075 = 尖最多到 8.73,落定回 8.4)。
176
+ * 包络在 p=.55 起、p=.8 附近到顶、p=1 归零 —— 两端为 0 所以不影响端点。
177
+ * 它是 p 的函数,所以收/放仍然严格互为倒放。
178
+ */
179
+ var PILL_STAR_POP = 0.075
180
+ /** 微转(度):圆点态 -8°、星芒态 0°;过冲时会略过 0 再落定。圆点旋转不变,所以起手看不出来。 */
181
+ var PILL_STAR_TWIST = 8
182
+
183
+ var PILL_STAR_DOT_D = 'M4 0L3.92 0.78L3.7 1.53L3.33 2.22L2.83 2.83L2.22 3.33L1.53 3.7L0.78 3.92L0 4L-0.78 3.92L-1.53 3.7L-2.22 3.33L-2.83 2.83L-3.33 2.22L-3.7 1.53L-3.92 0.78L-4 0L-3.92 -0.78L-3.7 -1.53L-3.33 -2.22L-2.83 -2.83L-2.22 -3.33L-1.53 -3.7L-0.78 -3.92L0 -4L0.78 -3.92L1.53 -3.7L2.22 -3.33L2.83 -2.83L3.33 -2.22L3.7 -1.53L3.92 -0.78Z'
184
+ var PILL_STAR_D = 'M6 0L3.89 0.77L2.81 1.17L2.12 1.42L1.61 1.61L1.42 2.12L1.17 2.81L0.77 3.89L0 6L-0.77 3.89L-1.17 2.81L-1.42 2.12L-1.61 1.61L-2.12 1.42L-2.81 1.17L-3.89 0.77L-6 0L-3.89 -0.77L-2.81 -1.17L-2.12 -1.42L-1.61 -1.61L-1.42 -2.12L-1.17 -2.81L-0.77 -3.89L0 -6L0.77 -3.89L1.17 -2.81L1.42 -2.12L1.61 -1.61L2.12 -1.42L2.81 -1.17L3.89 -0.77Z'
185
+
186
+ /** 吸附滑行的时长(毫秒):比过渡时长多一点,走完就把定位交还给 right/bottom。 */
187
+ var PILL_SNAP_MS = 360
188
+
105
189
  // ────────────────────────────── 小工具 ──────────────────────────────
106
190
 
107
191
  function el(tag, cls, text) {
@@ -218,7 +302,13 @@ window.__ModuleLoader__.load({
218
302
  '.dsh-sel-btn[data-pop="1"]{animation:dsh-sel-pop .24s cubic-bezier(.34,1.56,.64,1) both}',
219
303
  '.dsh-sel-btn:hover{filter:brightness(1.08)}',
220
304
  '.dsh-sel-btn:active{transform:scale(.97)}',
221
- '.dsh-sel-btn svg{width:13px;height:13px;display:block}',
305
+ // 图标:13px 画布 + **左移 1px**。
306
+ // 为什么要有这个负外边距:画布是 16×16、图形墨迹只占中间 ~11px,左右各留 ~2px 空白,
307
+ // 而右边「解读 / 引用」两个字的墨迹几乎没有右边距(0.4~1.2px)—— 按盒子居中的话,
308
+ // 视觉上左边比右边宽 ~1.7px,整组看着偏右(实测墨迹中心偏 +0.88px)。
309
+ // 负外边距把"墨迹"而不是"画布"摆正;用 1px 而不是精确的 0.87px:两个浮标的文字
310
+ // 边距不同(「解读」0.4 / 「引用」1.2),取整后两枚浮标的偏差都在 0.5px 内。
311
+ '.dsh-sel-btn svg{width:13px;height:13px;display:block;margin-left:-1px}',
222
312
  // 全局一个主色:**青**(两节同色,和「详解」一致)。
223
313
  // --sel-a1/a2 装饰:边条 / 呼吸点 / 淡底(不承载文字,不需要 AA)
224
314
  // --sel-a*-text 文字:术语、标题、符号、链接(实测 AA 达标)
@@ -576,18 +666,62 @@ window.__ModuleLoader__.load({
576
666
  // 悬浮状态胶囊:样式对齐右下角那枚费用胶囊(dsh-spend),摆在它上方
577
667
  // 逐项对齐 dsh-spend 的 .dsu-pill:同样的 padding / 字号 / 行高 / 边框 / 圆角 / 阴影,
578
668
  // 连小三角都用同一个字符 ▴ ▾(以前用 ▲ ▼,比它大一圈、也不一样淡)
669
+ // max-width 280 是"跟随费用胶囊"那条路的封顶(和 .dsu-pill 同值,并排时才能同宽);
670
+ // **没有**费用胶囊可跟随时,placePill() 会用内联样式收到 PILL_FALLBACK_MAX(200)。
671
+ // 过渡(收球 / 拖动吸附 / 跟随位置变化)挂在 [data-ready="1"] 上:插件挂载那一下是先贴好
672
+ // 位置再开过渡的,否则会从 CSS 默认的 bottom:64 一路滑到实测位置。
673
+ //
674
+ // 收球 / 展开**共用这一条**(时长 + 曲线),而且曲线是**时间对称**的:
675
+ // cubic-bezier(.5,0,.5,1) 的控制点关于中心对称 → p(t) = 1 - p(1-t),
676
+ // 也就是"把收球的动画倒过来放"恰好等于展开(用户要的对称性)。
677
+ // 之前用 easeOutQuint(.22,1,.36,1):前后半程不镜像,收快放慢,两个方向看着是两回事。
678
+ // 所有会变的属性都必须挂同一条(时长/曲线一致),否则中间某一帧的宽度由**另一条曲线**决定,
679
+ // 对称性就破了 —— 这也是下面 min-width / padding / gap / 文字 max-width 都要一起过渡的原因。
579
680
  '.dsh-sel-pill{position:fixed;right:20px;bottom:64px;z-index:' + String(Z_BTN) + ';display:flex;align-items:center;gap:8px;',
580
- 'box-sizing:border-box;padding:6px 14px;border-radius:999px;cursor:pointer;user-select:none;max-width:280px;white-space:nowrap;',
681
+ // height 钉死 32:收球时 padding 要归零,若高度由 padding 撑,展开那一下会先被压扁再长回来
682
+ // (实测的"不流畅"之一)。min-width 给数值 0,好让"球→胶囊"能从 32 平滑过渡回 0。
683
+ 'box-sizing:border-box;height:' + String(PILL_BALL_SIZE) + 'px;min-width:0;padding:6px 14px;border-radius:999px;cursor:pointer;user-select:none;max-width:280px;white-space:nowrap;',
581
684
  'background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#24292f);',
582
685
  'border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.22));box-shadow:0 4px 16px rgba(0,0,0,.12);',
583
- 'font-family:inherit;font-size:12px;line-height:18px;font-weight:400;transition:box-shadow .15s ease}',
686
+ 'font-family:inherit;font-size:12px;line-height:18px;font-weight:400}',
687
+ '.dsh-sel-pill[data-ready="1"]{transition:box-shadow .15s ease,width ' + PILL_MORPH + ',',
688
+ 'max-width ' + PILL_MORPH + ',min-width ' + PILL_MORPH + ',padding ' + PILL_MORPH + ',gap ' + PILL_MORPH + ',',
689
+ 'left ' + PILL_MORPH + ',top ' + PILL_MORPH + ',right ' + PILL_MORPH + ',bottom ' + PILL_MORPH + '}',
690
+ // 鼠标正按着拖:位置每帧都在变,有过渡就"黏手"
691
+ '.dsh-sel-pill[data-drag="1"]{transition:none;cursor:grabbing}',
692
+ // 收成小球:只改**能过渡的属性** —— 宽度由内联 max-width/min-width 收到 32(见 placePill),
693
+ // 这里把 padding/gap 归零。刻意不用 justify-content:center 让圆点居中:那是离散属性、
694
+ // 一翻就跳(圆点会瞬移),改用圆点自己的 margin-left(可过渡)把它挪到圆的中心。
695
+ '.dsh-sel-pill[data-ball="1"]{padding:0;gap:0}',
696
+ // 三段文字(名字 / 状态 / 箭头)收起来:max-width 过渡 + 淡出,而不是 display:none(那个没法过渡)。
697
+ // ⚠️ 基础态必须给一个**数值** max-width(320px,比任何内容都宽 = 等于不限制):
698
+ // 否则它是从 none 过渡到 0,而 none 不可插值 —— 文字会瞬间塌掉。
699
+ // 实测(真时间轴逐帧采样宽度):漏了这句时整枚胶囊 60ms 就缩完了,看着像啪一下拍扁,
700
+ // 而不是收进去。
701
+ // overflow:hidden 常驻(不收球时也留着):它是离散属性,若只在球态给,展开第一帧文字会先溢出来。
702
+ '.dsh-sel-pill .dsh-sel-pillname,.dsh-sel-pill .dsh-sel-pillmeta,.dsh-sel-pill .dsh-sel-pillcaret{max-width:320px;overflow:hidden;transition:opacity ' + PILL_MORPH + ',max-width ' + PILL_MORPH + '}',
703
+ '.dsh-sel-pill[data-ball="1"] .dsh-sel-pillname,.dsh-sel-pill[data-ball="1"] .dsh-sel-pillmeta,.dsh-sel-pill[data-ball="1"] .dsh-sel-pillcaret{max-width:0;opacity:0;pointer-events:none}',
584
704
  '.dsh-sel-pill:hover{box-shadow:0 6px 22px rgba(0,0,0,.18)}',
585
705
  '.dsh-sel-pill[data-open="1"]{border-color:color-mix(in srgb,var(--sel-a1,#0d9488) 55%,transparent)}',
586
- '.dsh-sel-pilldot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#8c959f)}',
587
- '.dsh-sel-pill[data-tone=busy] .dsh-sel-pilldot{background:var(--sel-a1,#0d9488);animation:dsh-sel-pillpulse 1.5s ease-in-out infinite}',
588
- '.dsh-sel-pill[data-tone=done] .dsh-sel-pilldot{background:var(--sel-a1,#0d9488)}',
589
- '.dsh-sel-pill[data-tone=error] .dsh-sel-pilldot{background:var(--dsw-alias-state-error-primary,#e5484d)}',
590
- '.dsh-sel-pill[data-tone=paused] .dsh-sel-pilldot{background:#d97706}',
706
+ // 球心里的图形:一枚 32×32 的 SVG 画布,两侧 -12px 负边距把**布局占位**压成 8px ——
707
+ // 和原来那枚 8px 圆点占位完全相同,所以胶囊的排版与宽度一个像素都不用改;
708
+ // 而画布本身是 32px,于是 viewBox 的 1 单位 = 1px,设计稿上的数字可以照抄。
709
+ '.dsh-sel-pillicon{flex:none;width:32px;height:32px;margin:0 -12px;display:block;overflow:visible;transition:margin ' + PILL_MORPH + '}',
710
+ '.dsh-sel-pillstar{fill:var(--dsw-alias-label-tertiary,#8c959f)}',
711
+ // 图形本身不做 CSS 过渡:两条静态 path 之间 CSS 只能线性插值(中途会出现正菱形),
712
+ // 所以路径由 animatePillStar 按 p 现算(见那里的注释)。
713
+ // path 的坐标中心是 (0,0)。百分比原点在 SVG 中却算成 (16px,16px),
714
+ // 旋转 -8° 会把圆点墨迹推到画布右上方;必须绕路径真正的中心转。
715
+ '.dsh-sel-pillstar{transform-origin:0 0}',
716
+ // 球态:画布在 32px 的圆里居中 —— 内宽 32-2(边框)=30,两侧各 -1px 负边距正好摆正(中心 16)
717
+ '.dsh-sel-pill[data-ball="1"] .dsh-sel-pillicon{margin:0 -1px}',
718
+ // 状态色照旧,只是从圆点的 background 挪到星的 fill;
719
+ // "进行中"的呼吸动画挂在画布上(transform 绕自身中心缩放,和原来的圆点脉冲同一条 keyframes)
720
+ '.dsh-sel-pill[data-tone=busy] .dsh-sel-pillstar{fill:var(--sel-a1,#0d9488)}',
721
+ '.dsh-sel-pill[data-tone=busy] .dsh-sel-pillicon{animation:dsh-sel-pillpulse 1.5s ease-in-out infinite}',
722
+ '.dsh-sel-pill[data-tone=done] .dsh-sel-pillstar{fill:var(--sel-a1,#0d9488)}',
723
+ '.dsh-sel-pill[data-tone=error] .dsh-sel-pillstar{fill:var(--dsw-alias-state-error-primary,#e5484d)}',
724
+ '.dsh-sel-pill[data-tone=paused] .dsh-sel-pillstar{fill:#d97706}',
591
725
  '@keyframes dsh-sel-pillpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.82)}}',
592
726
  // 三段的明暗节奏也照抄:主体加粗、次要信息 --secondary、箭头 --tertiary
593
727
  // 宽度由 JS 固定成下面那枚费用胶囊的宽度,所以文字必须自己让位:
@@ -1971,12 +2105,23 @@ window.__ModuleLoader__.load({
1971
2105
  pill.setAttribute('role', 'button')
1972
2106
  pill.setAttribute('tabindex', '0')
1973
2107
  pill.style.pointerEvents = 'auto'
1974
- var pillDot = el('span', 'dsh-sel-pilldot')
2108
+ // 球心里的图形:一枚 32×32 的 SVG(viewBox -16..16,1 单位 = 1px),里面只有一条 path。
2109
+ // 展开态它画成 8px 圆点(PILL_STAR_DOT_D),收球时把 d 换成星芒(PILL_STAR_D)——
2110
+ // 两条 path 命令结构一致,Chrome 对 d 插值,于是"圆点长成星芒"是一条连续形变。
2111
+ var pillIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
2112
+ pillIcon.setAttribute('class', 'dsh-sel-pillicon')
2113
+ pillIcon.setAttribute('viewBox', '-16 -16 32 32')
2114
+ pillIcon.setAttribute('aria-hidden', 'true')
2115
+ pillIcon.setAttribute('focusable', 'false')
2116
+ var pillStar = document.createElementNS('http://www.w3.org/2000/svg', 'path')
2117
+ pillStar.setAttribute('class', 'dsh-sel-pillstar')
2118
+ pillStar.setAttribute('d', PILL_STAR_DOT_D)
2119
+ pillIcon.appendChild(pillStar)
1975
2120
  // 和费用胶囊同构:主体一段(加粗)、次要一段(淡)、箭头一段(更淡)
1976
2121
  var pillName = el('span', 'dsh-sel-pillname', '划词解读')
1977
2122
  var pillMeta = el('span', 'dsh-sel-pillmeta', '· 就绪')
1978
2123
  var pillCaret = el('span', 'dsh-sel-pillcaret', '▴')
1979
- pill.appendChild(pillDot)
2124
+ pill.appendChild(pillIcon)
1980
2125
  pill.appendChild(pillName)
1981
2126
  pill.appendChild(pillMeta)
1982
2127
  pill.appendChild(pillCaret)
@@ -2199,6 +2344,32 @@ window.__ModuleLoader__.load({
2199
2344
  var PILL_POLL_MIN = 2000
2200
2345
  var PILL_POLL_MAX = 60000
2201
2346
  var pillPollDelay = PILL_POLL_MIN
2347
+ /**
2348
+ * 胶囊的拖动 / 吸附 / 收球状态(几何参数 PILL_* 在文件上方,CSS 也要用)。
2349
+ *
2350
+ * · pillDock:'anchor' = 贴费用胶囊(量不到就贴右下角,由 placePill 管);
2351
+ * 'free' = 用户拖到别处了 —— 位置归用户,轮询不许再动它。
2352
+ * · pillFreeRight:自由摆放时记的是**右边缘**(收球/展开都以右侧为锚点:
2353
+ * 胶囊向左展开、向右收成球,右边缘一个像素都不动 —— 用户要的就是这个)。
2354
+ * 记中心是不行的:收球时右边缘会往左跑,球看起来"飘"了一下。
2355
+ * · pillAnchorWidth:本次量到的费用胶囊宽度(0 = 没量到)—— 决定"跟随"还是"兜底",
2356
+ * 也是"能不能收球"的前提(用户定的:只有没有费用胶囊时才收成小球)。
2357
+ */
2358
+ var pillDock = 'anchor'
2359
+ var pillFreeRight = null
2360
+ var pillBall = false
2361
+ var pillHover = false
2362
+ var pillDrag = null
2363
+ /** 刚拖完的那一下 click 要吞掉(拖动不该顺手开关小窗)。 */
2364
+ var pillDragMoved = false
2365
+ /** 静置计时器 id(0 = 没有)。用裸 setTimeout:它每帧都可能被重置,塞进 timers 数组会一直长。 */
2366
+ var pillIdleId = 0
2367
+ /**
2368
+ * 吸附滑行窗口的截止时刻(见 settlePillDrop / placePill):
2369
+ * 这段时间内位置按 left/top 维持(能过渡),之后再交还给 right/bottom 的稳态定位。
2370
+ */
2371
+ var pillSnapUntil = 0
2372
+ var pillAnchorWidth = 0
2202
2373
  var pillPlacerId = 0
2203
2374
  /** 上一次量到的费用胶囊容器 / 正在观察它的 ResizeObserver(浏览器没有这个 API 时为 null)。 */
2204
2375
  var pillHost = null
@@ -3303,7 +3474,10 @@ window.__ModuleLoader__.load({
3303
3474
  snippet ? '最近一次划词:' + snippet : '还没有划过词',
3304
3475
  status.text,
3305
3476
  panelOpen ? '点击收起小窗' : '点击回到这个小窗',
3477
+ '可拖动(拖到右下角附近会吸附回去)',
3306
3478
  ].join('|')
3479
+ // 画一次就算一次活动:状态在变(翻译中/生成中/完成)时不该被收成小球,也顺便重置静置计时
3480
+ notePillActivity()
3307
3481
  }
3308
3482
 
3309
3483
  /**
@@ -3329,10 +3503,12 @@ window.__ModuleLoader__.load({
3329
3503
  }
3330
3504
 
3331
3505
  /**
3332
- * 把胶囊摆到右下角"费用胶囊"上方。
3506
+ * 量一次"贴哪儿、多宽"。
3333
3507
  * 那个插件不一定装着、也不一定什么时候出现,所以每次都用实测位置,取不到才用兜底偏移。
3508
+ * 返回 { right, bottom, width }:right/bottom 是视口右/下边距;width 是那枚胶囊的实测宽度
3509
+ * (0 = 没量到 —— 调用方据此决定"跟随它"还是"走兜底")。
3334
3510
  */
3335
- function placePill() {
3511
+ function measurePillAnchor() {
3336
3512
  // 兜底位置:**贴页面底部**(和费用胶囊自己的 right:20/bottom:20 同高)。
3337
3513
  // 以前兜底是 64,等于永远给"下面那枚胶囊"留位置——没装那个插件时就悬在半空(实测被吐槽)。
3338
3514
  var right = 20
@@ -3361,25 +3537,490 @@ window.__ModuleLoader__.load({
3361
3537
  } catch (error) {
3362
3538
  /* noop:量不到就留在兜底位置 */
3363
3539
  }
3540
+ return { right: right, bottom: bottom, width: width }
3541
+ }
3542
+
3543
+ /* ── 位置记忆(host 存储):拖到别处就存,吸附回右下角就清,挂载时读回来 ── */
3544
+
3545
+ function savePillPos() {
3546
+ if (!pillFreeRight) return
3547
+ fetch(PILL_POS, {
3548
+ method: 'POST',
3549
+ headers: { 'content-type': 'application/json' },
3550
+ body: JSON.stringify({ right: Math.round(pillFreeRight.right), y: Math.round(pillFreeRight.y) }),
3551
+ }).catch(function () {})
3552
+ }
3553
+
3554
+ function clearPillPos() {
3555
+ fetch(PILL_POS, {
3556
+ method: 'POST',
3557
+ headers: { 'content-type': 'application/json' },
3558
+ body: JSON.stringify({ clear: true }),
3559
+ }).catch(function () {})
3560
+ }
3561
+
3562
+ var pillPosAsked = false
3563
+ /** 用户自己拖过位置(拖过就不再用 host 上的存档覆盖) */
3564
+ var pillUserPlaced = false
3565
+
3566
+ /** 挂载时读回上次拖到的位置:有就切成自由摆放、按右边缘摆回去(host 不可达时静默忽略)。 */
3567
+ function loadPillPos() {
3568
+ if (pillPosAsked) return
3569
+ pillPosAsked = true
3570
+ fetch(PILL_POS)
3571
+ .then(function (res) {
3572
+ return res.json()
3573
+ })
3574
+ .then(function (data) {
3575
+ var pos = data && data.position
3576
+ if (!pos || typeof pos.right !== 'number' || typeof pos.y !== 'number') return
3577
+ if (pillUserPlaced) return // 用户已经自己拖过了,别用存档覆盖他
3578
+ pillDock = 'free'
3579
+ pillFreeRight = { right: pos.right, y: pos.y }
3580
+ applyFreePill()
3581
+ clampFreePill()
3582
+ placePill()
3583
+ })
3584
+ .catch(function () {})
3585
+ }
3586
+
3587
+ /** 自由摆放:用 CSS right 钉住右边缘,宽度过渡的每一帧都往左长/往左收。 */
3588
+ function applyFreePill() {
3589
+ if (!pillFreeRight) return
3590
+ var h = pill.offsetHeight || 0
3591
+ pill.style.left = 'auto'
3592
+ pill.style.right = Math.round(window.innerWidth - pillFreeRight.right) + 'px'
3593
+ pill.style.top = Math.round(pillFreeRight.y - h / 2) + 'px'
3594
+ pill.style.bottom = 'auto'
3595
+ }
3596
+
3597
+ /**
3598
+ * 自由摆放时也必须还在视口里(用户拖出去过 / 窗口变小了)。
3599
+ * 右边缘为锚:先把右边缘夹进视口,再保证左边缘不出界(展开变宽时右边缘可能被推右一点)。
3600
+ * 位置没变就一个像素都不写。
3601
+ */
3602
+ function clampFreePill() {
3603
+ if (!pillFreeRight) return false
3604
+ var w = pill.offsetWidth || 0
3605
+ var h = pill.offsetHeight || 0
3606
+ var right = clamp(pillFreeRight.right, w + 4, Math.max(w + 4, window.innerWidth - 4))
3607
+ var top = clamp(pillFreeRight.y - h / 2, 4, Math.max(4, window.innerHeight - h - 4))
3608
+ var y = top + h / 2
3609
+ if (Math.abs(right - pillFreeRight.right) < 0.5 && Math.abs(y - pillFreeRight.y) < 0.5) return false
3610
+ pillFreeRight = { right: right, y: y }
3611
+ applyFreePill()
3612
+ return true
3613
+ }
3614
+
3615
+ /** 贴角/跟随时那枚胶囊会落在哪(视口坐标)—— 松手时拿它判"要不要吸附回去"。 */
3616
+ function pillAnchorRect() {
3617
+ var anchor = measurePillAnchor()
3618
+ var w = anchor.width > 0 ? anchor.width : pill.offsetWidth || 0
3619
+ var h = pill.offsetHeight || 0
3620
+ return {
3621
+ left: window.innerWidth - anchor.right - w,
3622
+ top: window.innerHeight - anchor.bottom - h,
3623
+ width: w,
3624
+ height: h,
3625
+ }
3626
+ }
3627
+
3628
+ /**
3629
+ * 把胶囊摆到右下角"费用胶囊"上方。
3630
+ *
3631
+ * 三种处境:
3632
+ * · anchor(默认):贴费用胶囊;量不到就贴右下角 20/20,宽度收到 PILL_FALLBACK_MAX;
3633
+ * · free:用户把它拖到别处了 —— 位置归用户(**右边缘**为锚,见 applyFreePill),
3634
+ * 这里只保证"别掉出视口";
3635
+ * · ball:静置太久收成小球 —— 上限收到 PILL_BALL_SIZE(见 collapsePill)。
3636
+ * 宽度和上限三种处境都一样算(拖动不该顺手改宽度),只有位置分家。
3637
+ */
3638
+ function placePill() {
3639
+ var anchor = measurePillAnchor()
3640
+ pillAnchorWidth = anchor.width
3641
+ // 费用胶囊**出现**了:小球没有存在的意义(收球的前提就是"没有可跟随的胶囊")→ 立刻展开
3642
+ if (pillAnchorWidth > 0 && pillBall) expandPill()
3364
3643
  // 只在真的变了才写 style(每帧无脑赋值会让浏览器反复标脏样式)
3365
3644
  var changed = false
3366
- var wantedRight = right + 'px'
3367
- var wantedBottom = bottom + 'px'
3645
+ function write(prop, value) {
3646
+ if (pill.style[prop] !== value) {
3647
+ pill.style[prop] = value
3648
+ changed = true
3649
+ }
3650
+ }
3368
3651
  // 宽度**写死**成费用胶囊的宽度:文字长短不再让它伸缩(长文字自己省略号收尾)
3369
- var wantedWidth = width > 0 ? width + 'px' : ''
3370
- if (pill.style.right !== wantedRight) {
3371
- pill.style.right = wantedRight
3372
- changed = true
3652
+ write('width', anchor.width > 0 ? anchor.width + 'px' : '')
3653
+ // 上限分三种情况(前两条是用户定的规则):
3654
+ // · 收成小球 → 球的尺寸(max-width 参与过渡,收/放才是动画而不是跳变);
3655
+ // · 量到了费用胶囊 → 清掉内联上限,回到 CSS 那条 280 —— 和它并排时同宽;
3656
+ // · 量不到(没装 dsh-spend / 它的胶囊此刻宽高为 0)→ 收到 PILL_FALLBACK_MAX。
3657
+ // 判据是**本次量到的宽度**而不是"有没有找到容器":容器在但胶囊被折叠/隐藏时同样跟不了,
3658
+ // 那种情况也该走兜底。
3659
+ write('maxWidth', pillBall ? PILL_BALL_SIZE + 'px' : anchor.width > 0 ? '' : PILL_FALLBACK_MAX + 'px')
3660
+ // min-width 也一起过渡:球是 32×32 的圆,展开时要**平滑回到 0**。
3661
+ // 之前它挂在 [data-ball="1"] 上,一摘属性就瞬间失效 → 展开第一帧宽度塌到内容宽(10px)
3662
+ // 再长回来,看着就是先抽一下(真时间轴采样到的 0:10)。
3663
+ write('minWidth', pillBall ? PILL_BALL_SIZE + 'px' : '0px')
3664
+ if (pillDock === 'free') {
3665
+ // 位置归用户:CSS right 持续钉住右边缘,只保证胶囊还在视口里。
3666
+ clampFreePill()
3667
+ return changed
3668
+ }
3669
+ if (Date.now() < pillSnapUntil) {
3670
+ // 吸附那一下的"滑回去"窗口(见 settlePillDrop):这期间位置仍按 left/top 维持 ——
3671
+ // left/top 与 right/bottom 之间**不可插值**(left:124px → auto 会直接瞬移),
3672
+ // 所以滑行必须靠 left/top 走完,之后再交还给 right/bottom 那套稳态定位。
3673
+ var snapWidth = anchor.width > 0 ? anchor.width : pill.offsetWidth || 0
3674
+ write('left', Math.round(window.innerWidth - anchor.right - snapWidth) + 'px')
3675
+ write('top', Math.round(window.innerHeight - anchor.bottom - (pill.offsetHeight || 0)) + 'px')
3676
+ write('right', 'auto')
3677
+ write('bottom', 'auto')
3678
+ return changed
3679
+ }
3680
+ // 稳态:right/bottom 定位(宽度一变,右边缘仍和费用胶囊对齐)
3681
+ write('left', '')
3682
+ write('top', '')
3683
+ write('right', anchor.right + 'px')
3684
+ write('bottom', anchor.bottom + 'px')
3685
+ return changed
3686
+ }
3687
+
3688
+ /**
3689
+ * 静置收球。
3690
+ *
3691
+ * 规则(用户定的):**只有量不到费用胶囊时**才收球 —— 有费用胶囊时它是要跟人家并排的,
3692
+ * 收成球反而突兀。下面几种情况一律不收:小窗开着、正在翻译/追问、鼠标停在上面、正在拖。
3693
+ * 动画靠 max-width 过渡(收球 = 把上限收到 PILL_BALL_SIZE),所以收/放是同一条平滑曲线,
3694
+ * 不需要预先量宽度,也不会有跳变。
3695
+ */
3696
+ function pillCanBall() {
3697
+ return (
3698
+ pillAnchorWidth === 0 &&
3699
+ !pillBall &&
3700
+ !panelOpen &&
3701
+ !pillHover &&
3702
+ !pillDrag &&
3703
+ pillStatus().tone !== 'busy'
3704
+ )
3705
+ }
3706
+
3707
+ /* ── 球心图形的形变:一个纯函数 f(p) + 一条时间对称曲线 ──────────────────
3708
+ ⚠️ 这里**不能**用 CSS 的 `transition:d`:两条静态 path 之间 CSS 只能**线性**插值,
3709
+ 于是中途会经过一个正菱形(谷跟着尖一起动)——正是设计阶段否掉的那版。
3710
+ 必须在 JS 里按 p 现算路径,才能让"尖先抽出来、身再收进去"这个非线性轨迹成立。
3711
+ 另:f(p) 只依赖 p,p 又由时间对称曲线给出,所以收/放天然互为倒放(镜像)。 */
3712
+
3713
+ /** 时间对称曲线的 y(x):和 CSS 的 cubic-bezier 同一套解法(二分 24 次足够)。 */
3714
+ function pillStarEase(x) {
3715
+ var c = PILL_STAR_EASE
3716
+ var cx = 3 * c[0]
3717
+ var bx = 3 * (c[2] - c[0]) - cx
3718
+ var ax = 1 - cx - bx
3719
+ var cy = 3 * c[1]
3720
+ var by = 3 * (c[3] - c[1]) - cy
3721
+ var ay = 1 - cy - by
3722
+ var lo = 0
3723
+ var hi = 1
3724
+ var t = x
3725
+ for (var i = 0; i < 24; i += 1) {
3726
+ t = (lo + hi) / 2
3727
+ if (((ax * t + bx) * t + cx) * t < x) lo = t
3728
+ else hi = t
3729
+ }
3730
+ t = (lo + hi) / 2
3731
+ return ((ay * t + by) * t + cy) * t
3732
+ }
3733
+
3734
+ /** smoothstep:两端速度都为 0 —— 段与段交界处不会有速度断点(那正是"生硬"的来源)。 */
3735
+ function pillStarSmooth(x) {
3736
+ var c = x < 0 ? 0 : x > 1 ? 1 : x
3737
+ return c * c * (3 - 2 * c)
3738
+ }
3739
+
3740
+ /** 星的多边形顶点(8 个:尖谷交替,与「✦ 解读」那颗星同一条几何)。 */
3741
+ function pillStarPolygon() {
3742
+ var tip = 6.0 // 墨迹 12.0px:与胶囊里 12px 的文字同高
3743
+ var valley = tip * 0.3788
3744
+ var pts = []
3745
+ for (var k = 0; k < 4; k += 1) {
3746
+ pts.push([tip * Math.cos(k * Math.PI / 2), tip * Math.sin(k * Math.PI / 2)])
3747
+ pts.push([valley * Math.cos((k * 90 + 45) * Math.PI / 180), valley * Math.sin((k * 90 + 45) * Math.PI / 180)])
3748
+ }
3749
+ return pts
3750
+ }
3751
+
3752
+ /** 从原点沿 angDeg 打射线,和多边形求交 → 该角度的边界半径。 */
3753
+ function pillStarBoundaryRadius(poly, angDeg) {
3754
+ var a = angDeg * Math.PI / 180
3755
+ var dx = Math.cos(a)
3756
+ var dy = Math.sin(a)
3757
+ var best = Infinity
3758
+ for (var i = 0; i < poly.length; i += 1) {
3759
+ var p0 = poly[i]
3760
+ var p1 = poly[(i + 1) % poly.length]
3761
+ var ex = p1[0] - p0[0]
3762
+ var ey = p1[1] - p0[1]
3763
+ var den = dx * ey - dy * ex
3764
+ if (Math.abs(den) < 1e-9) continue
3765
+ var t = (p0[0] * ey - p0[1] * ex) / den
3766
+ var u = (p0[0] * dy - p0[1] * dx) / den
3767
+ if (t > 0 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
3768
+ }
3769
+ return best === Infinity ? 4 : best
3770
+ }
3771
+
3772
+ var pillStarPoly = pillStarPolygon()
3773
+
3774
+ /**
3775
+ * p=0 → 32 个顶点同半径 4(8px 圆点,与正圆最大偏差 0.02px);
3776
+ * p=1 → 32 个顶点正好落在按钮那颗星的边界上(尖与谷仍是精确顶点)。
3777
+ * 尖走 p/.62 的 smoothstep、身走 (p-.38)/.62 的 smoothstep —— 两条都两端速度为 0,
3778
+ * 于是整个 280ms 里图形一直在动、且没有速度断点(实测:旧版尖在 160ms 速度从 4.98 直接掉到 0)。
3779
+ * p 允许略微越界(过冲曲线会给到 -0.03…1.06),半径按同一式子外推 —— 于是"冲出去一点再收回"。
3780
+ */
3781
+ function pillStarPathAt(p) {
3782
+ var pTip = pillStarSmooth(p / PILL_STAR_TIP_END)
3783
+ var pBody = pillStarSmooth((p - PILL_STAR_BODY_START) / (1 - PILL_STAR_BODY_START))
3784
+ var popEnv = pillStarPopEnvelope(p)
3785
+ var out = []
3786
+ for (var i = 0; i < PILL_STAR_SAMPLES; i += 1) {
3787
+ var ang = i * 360 / PILL_STAR_SAMPLES
3788
+ var rStar = pillStarBoundaryRadius(pillStarPoly, ang)
3789
+ // 顶点按"要往外长还是要往里收"分流:
3790
+ // 目标半径 ≥ 4(尖和紧邻的顶点)跟"尖"的进度 pTip;< 4(往内收的顶点)跟"身"的进度 pBody。
3791
+ // 为什么不能按"离尖的角度权重 w"混合(试过):22.5° 那个顶点目标半径已 < 4(要往里收),却因为
3792
+ // w 不小而跟着尖的进度**提前**内收 → 比谷还深,边上出现一道波浪(缩小尺寸后 22.5° 比 33.75°
3793
+ // 还深 0.11px,被冒烟抓到)。分流之后单调性可证:
3794
+ // 外扩顶点 r = 4 + (rStar-4)·pTip ≥ 4 ≥ 4 + (rStar-4)·pBody = 内收顶点。
3795
+ var prog = rStar >= 4 ? pTip : pBody
3796
+ // 过冲只加在"尖"上(外扩顶点):尖先冲出去一点、谷按部就班收 —— 呼吸感来自这里
3797
+ if (rStar >= 4) prog += PILL_STAR_POP * popEnv
3798
+ var r = 4 + (rStar - 4) * prog
3799
+ var rad = ang * Math.PI / 180
3800
+ var x = Math.round(r * Math.cos(rad) * 100) / 100
3801
+ var y = Math.round(r * Math.sin(rad) * 100) / 100
3802
+ out.push((i ? 'L' : 'M') + x + ' ' + y)
3803
+ }
3804
+ return out.join('') + 'Z'
3805
+ }
3806
+
3807
+ /** 过冲的包络:p≤.55 为 0,p≈.8 到顶,p=1 归零(两端为 0 → 不破坏端点)。 */
3808
+ function pillStarPopEnvelope(p) {
3809
+ if (p <= 0.55 || p >= 1) return 0
3810
+ return Math.sin(Math.PI * (p - 0.55) / 0.45)
3811
+ }
3812
+
3813
+ /** 微转:圆点态 -8° → 星芒态 0°。 */
3814
+ function pillStarTwistAt(p) {
3815
+ return PILL_STAR_TWIST * (1 - p)
3816
+ }
3817
+
3818
+ var pillStarP = 0
3819
+ var pillStarTargetP = 0
3820
+ var pillStarTimer = 0
3821
+ var pillStarStarts = 0
3822
+ var pillStarLog = ''
3823
+
3824
+ /** 按 p 画一帧(路径 + 微转)。 */
3825
+ function paintPillStar(p) {
3826
+ pillStarP = p
3827
+ pillStar.setAttribute('d', pillStarPathAt(p))
3828
+ pillStar.style.transform = 'rotate(' + (Math.round(pillStarTwistAt(p) * 100) / 100) + 'deg)'
3829
+ }
3830
+
3831
+ /**
3832
+ * 从当前 p 走到 to(1=收球成星,0=展开回圆点)。
3833
+ * 时长与胶囊那条 CSS 过渡一致(PILL_MORPH_MS / PILL_MORPH),两者同拍;
3834
+ * 被打断(比如收球途中鼠标移上去)就从当前 p 接着走,不会跳。
3835
+ *
3836
+ * 为什么用 setTimeout 而不是 requestAnimationFrame:
3837
+ * · 进度是**按时间算的**(Date.now()),不是按帧累加的 —— 所以节奏用什么驱动不影响保真度;
3838
+ * · 冒烟脚本跑在 --virtual-time-budget 下,那个环境**不派发 rAF**(定时器照走),
3839
+ * 用 rAF 的话整段动画在测试里根本观察不到(也就会漏掉"中途形状不对"这类 bug);
3840
+ * · 用 Date.now() 而不是 performance.now():虚拟时钟下后者是冻住的。
3841
+ * 16ms ≈ 60fps,和 rAF 同档;后台标签里被节流也只是直接跳到终值,无副作用。
3842
+ */
3843
+ function animatePillStar(to) {
3844
+ // ① 同目标的重复调用 = 空操作。**不要**重置已经跑了一半的动画 ——
3845
+ // 否则"反复收到同一条指令"会让它永远停在半路(实测踩过:序列 10101010、进度卡在 0.158)。
3846
+ if (to === pillStarTargetP && (pillStarTimer || pillStarP === to)) return
3847
+ if (pillStarTimer) {
3848
+ clearTimeout(pillStarTimer)
3849
+ pillStarTimer = 0
3850
+ }
3851
+ pillStarTargetP = to
3852
+ pillStarStarts += 1
3853
+ pillStarLog = (pillStarLog + (to ? '1' : '0')).slice(-40)
3854
+ var from = pillStarP
3855
+ if (from === to) {
3856
+ paintPillStar(to)
3857
+ return
3373
3858
  }
3374
- if (pill.style.bottom !== wantedBottom) {
3375
- pill.style.bottom = wantedBottom
3376
- changed = true
3859
+ // ② 打断后接着走:时长按**剩余距离**等比缩短 → 速度不变,而且一定会走到终点。
3860
+ // 否则"收球途中被展开"这种来回打断会让每一段都跑满 280ms,永远追不上目标。
3861
+ var span = Math.max(60, PILL_MORPH_MS * Math.abs(to - from))
3862
+ var done = 0
3863
+ var last = Date.now()
3864
+ function step() {
3865
+ var now = Date.now()
3866
+ var elapsed = now - last
3867
+ // 时钟被冻住时(--virtual-time-budget 那种虚拟时钟:定时器照走、Date.now() 不动)
3868
+ // 按"一拍 16ms"推进 —— 否则动画永远停在第一帧,测试也就看不到中途形状
3869
+ //(而"中途形状不对"正是上一版漏掉的那个 bug)。真实环境里 elapsed 就是真实间隔。
3870
+ if (elapsed <= 0) elapsed = 16
3871
+ last = now
3872
+ done += elapsed
3873
+ var x = done / span
3874
+ if (x >= 1) {
3875
+ pillStarTimer = 0
3876
+ paintPillStar(to)
3877
+ return
3878
+ }
3879
+ paintPillStar(from + (to - from) * pillStarEase(x))
3880
+ pillStarTimer = setTimeout(step, 16)
3377
3881
  }
3378
- if (pill.style.width !== wantedWidth) {
3379
- pill.style.width = wantedWidth
3380
- changed = true
3882
+ step()
3883
+ }
3884
+
3885
+ /** 球心里的图形切到"圆点"(展开)或"星芒"(收球)。 */
3886
+ function setPillStar(ball) {
3887
+ animatePillStar(ball ? 1 : 0)
3888
+ }
3889
+
3890
+ function collapsePill() {
3891
+ if (pillBall) return false
3892
+ pillBall = true
3893
+ pill.setAttribute('data-ball', '1')
3894
+ setPillStar(true)
3895
+ placePill() // 上限 → 球的尺寸(过渡动画交给 CSS 的 max-width)
3896
+ if (pillDock === 'free') applyFreePill()
3897
+ return true
3898
+ }
3899
+
3900
+ function expandPill() {
3901
+ if (!pillBall) return false
3902
+ pillBall = false
3903
+ pill.removeAttribute('data-ball')
3904
+ setPillStar(false)
3905
+ placePill() // 上限交回 200 / 费用胶囊那条
3906
+ if (pillDock === 'free') applyFreePill()
3907
+ return true
3908
+ }
3909
+
3910
+ /**
3911
+ * 静置计时:任何"活动"都把它重新拨满(见 notePillActivity)。
3912
+ * 用裸 setTimeout 而不是 later():这个计时器每帧都可能被重置,塞进 timers 数组会一直长。
3913
+ */
3914
+ function schedulePillIdle() {
3915
+ if (pillIdleId) clearTimeout(pillIdleId)
3916
+ pillIdleId = setTimeout(function () {
3917
+ pillIdleId = 0
3918
+ if (pillCanBall()) collapsePill()
3919
+ }, PILL_BALL_IDLE_MS)
3920
+ }
3921
+
3922
+ /** 有活动(点/划过/小窗在动/状态在变)→ 收着球就展开,并把静置计时重新拨满。 */
3923
+ function notePillActivity() {
3924
+ if (pillBall) expandPill()
3925
+ schedulePillIdle()
3926
+ }
3927
+
3928
+ /**
3929
+ * 开始拖胶囊(鼠标;和面板拖动同一套写法)。
3930
+ *
3931
+ * 三个要点:
3932
+ * · 位移超过 PILL_DRAG_SLOP 才算"拖",否则仍按点击处理(点一下要能开关小窗);
3933
+ * · 一开拖就切到 free —— 贴角的轮询从此不许再动它(否则拖到一半会被拽回去);
3934
+ * · 拖动期间关掉过渡(data-drag),松手再打开:吸附那一下才有动画,拖的时候不黏手。
3935
+ */
3936
+ function beginPillDrag(event) {
3937
+ if (event.button !== undefined && event.button !== 0) return
3938
+ if (pillBall) expandPill() // 球也能直接拖:先展开(hover 那条路通常已经展开了)
3939
+ var rect = pill.getBoundingClientRect()
3940
+ var drag = {
3941
+ offsetX: event.clientX - rect.left,
3942
+ offsetY: event.clientY - rect.top,
3943
+ startX: event.clientX,
3944
+ startY: event.clientY,
3945
+ moved: false,
3946
+ }
3947
+ pillDrag = drag
3948
+ pillDragMoved = false
3949
+ pillSnapUntil = 0
3950
+ var move = function (moveEvent) {
3951
+ if (pillDrag !== drag) return
3952
+ if (!drag.moved) {
3953
+ var travelled = Math.abs(moveEvent.clientX - drag.startX) + Math.abs(moveEvent.clientY - drag.startY)
3954
+ if (travelled < PILL_DRAG_SLOP) return
3955
+ drag.moved = true
3956
+ pillDragMoved = true
3957
+ pill.setAttribute('data-drag', '1')
3958
+ }
3959
+ var w = pill.offsetWidth
3960
+ var h = pill.offsetHeight
3961
+ var left = clamp(moveEvent.clientX - drag.offsetX, 4, Math.max(4, window.innerWidth - w - 4))
3962
+ var top = clamp(moveEvent.clientY - drag.offsetY, 4, Math.max(4, window.innerHeight - h - 4))
3963
+ // 真的拖了 → 从"贴角/跟随"切成自由摆放:位置归用户,轮询不再动它
3964
+ pillDock = 'free'
3965
+ pillFreeRight = { right: left + w, y: top + h / 2 }
3966
+ applyFreePill()
3381
3967
  }
3382
- return changed
3968
+ var up = function () {
3969
+ window.removeEventListener('mousemove', move, true)
3970
+ window.removeEventListener('mouseup', up, true)
3971
+ if (pillDrag !== drag) return
3972
+ var dropRect = drag.moved ? pill.getBoundingClientRect() : null
3973
+ pillDrag = null
3974
+ if (!drag.moved) {
3975
+ pill.removeAttribute('data-drag')
3976
+ return // 没动 = 点击:交给 click 处理(不在这里 reopen)
3977
+ }
3978
+ settlePillDrop(dropRect)
3979
+ notePillActivity()
3980
+ }
3981
+ window.addEventListener('mousemove', move, true)
3982
+ window.addEventListener('mouseup', up, true)
3983
+ }
3984
+
3985
+ /**
3986
+ * 松手:拖到贴角位置附近就吸附回去 —— 有费用胶囊就吸附到"跟随它的位置",
3987
+ * 没有就吸附到右下角 20/20;离得远就留在原地(位置归用户)。
3988
+ */
3989
+ function settlePillDrop(rect) {
3990
+ var target = pillAnchorRect()
3991
+ var near =
3992
+ Math.abs(rect.left - target.left) <= PILL_SNAP_RADIUS && Math.abs(rect.top - target.top) <= PILL_SNAP_RADIUS
3993
+ if (!near) {
3994
+ pillDock = 'free'
3995
+ pillUserPlaced = true
3996
+ clampFreePill()
3997
+ savePillPos() // 位置归用户:记住它(存 host 上)
3998
+ pill.removeAttribute('data-drag')
3999
+ return false
4000
+ }
4001
+ // 吸附:要"滑回去"而不是瞬移。
4002
+ // left/top 与 right/bottom 之间不可插值(left:124px → auto 会直接跳),所以先把目标位置
4003
+ // 写成 left/top 让过渡跑完,窗口结束(PILL_SNAP_MS)再交还给 right/bottom 那套稳态定位。
4004
+ var snappedTo = pillAnchorRect()
4005
+ pillDock = 'anchor'
4006
+ pillFreeRight = null
4007
+ clearPillPos() // 吸附回右下角 = 默认位置,不用记了
4008
+ pillSnapUntil = Date.now() + PILL_SNAP_MS
4009
+ // 拖动时位置由 right 控制;吸附滑行由 left 控制。先在禁用过渡时
4010
+ // 把当前几何位置交给 left,再启用过渡写目标位置,避免 auto 属性瞬跳。
4011
+ pill.style.left = Math.round(rect.left) + 'px'
4012
+ pill.style.top = Math.round(rect.top) + 'px'
4013
+ pill.style.right = 'auto'
4014
+ pill.style.bottom = 'auto'
4015
+ void pill.offsetWidth
4016
+ pill.removeAttribute('data-drag')
4017
+ pill.style.left = Math.round(snappedTo.left) + 'px'
4018
+ pill.style.top = Math.round(snappedTo.top) + 'px'
4019
+ later(function () {
4020
+ pillSnapUntil = 0
4021
+ if (pillDock === 'anchor') placePill()
4022
+ }, PILL_SNAP_MS)
4023
+ return true
3383
4024
  }
3384
4025
 
3385
4026
  /**
@@ -6070,18 +6711,36 @@ window.__ModuleLoader__.load({
6070
6711
  // 拖拽/划词的手势不该被胶囊接走
6071
6712
  event.preventDefault()
6072
6713
  event.stopPropagation()
6714
+ beginPillDrag(event)
6073
6715
  })
6074
6716
 
6075
6717
  var offPillClick = listen(pill, 'click', function (event) {
6076
6718
  event.preventDefault()
6077
6719
  event.stopPropagation()
6720
+ // 刚拖完的那一下不算点击(否则拖到别处会顺手把抽屉开开关关)
6721
+ if (pillDragMoved) {
6722
+ pillDragMoved = false
6723
+ return
6724
+ }
6078
6725
  reopenLast()
6726
+ notePillActivity()
6727
+ })
6728
+
6729
+ // 鼠标停在上面就不收球(要"点得到",不是"藏起来");离开时重新开始静置计时
6730
+ var offPillEnter = listen(pill, 'mouseenter', function () {
6731
+ pillHover = true
6732
+ notePillActivity()
6733
+ })
6734
+ var offPillLeave = listen(pill, 'mouseleave', function () {
6735
+ pillHover = false
6736
+ notePillActivity()
6079
6737
  })
6080
6738
 
6081
6739
  var offPillKey = listen(pill, 'keydown', function (event) {
6082
6740
  if (event.key !== 'Enter' && event.key !== ' ') return
6083
6741
  event.preventDefault()
6084
6742
  reopenLast()
6743
+ notePillActivity()
6085
6744
  })
6086
6745
 
6087
6746
  /**
@@ -6111,10 +6770,20 @@ window.__ModuleLoader__.load({
6111
6770
  })
6112
6771
 
6113
6772
  // 费用胶囊的位置/尺寸会变(展开、缩放、数字变长、插件迟到挂载)→ 自适应频率地贴上去
6773
+ loadPillPos() // 先问一句上次拖到哪儿(异步,回来再摆)
6114
6774
  placePill()
6115
6775
  ensureResizeWatch()
6116
6776
  schedulePillPlacer()
6117
6777
  paintPill()
6778
+ // 过渡要等"第一帧已经贴好位置"之后再打开:否则挂载那一下会从 CSS 默认的 bottom:64
6779
+ // 一路滑到实测位置(右下角看着像自己飞过去)
6780
+ if (typeof requestAnimationFrame === 'function') {
6781
+ requestAnimationFrame(function () {
6782
+ pill.setAttribute('data-ready', '1')
6783
+ })
6784
+ } else {
6785
+ pill.setAttribute('data-ready', '1')
6786
+ }
6118
6787
 
6119
6788
  var offResize = listen(window, 'resize', function () {
6120
6789
  hideButton()
@@ -7470,6 +8139,60 @@ window.__ModuleLoader__.load({
7470
8139
 
7471
8140
  // —— 调试钩子(自动化验证用) ——
7472
8141
  window.__dshSelectionExplain = {
8142
+ /** 重新读一次 host 上的胶囊位置(测试用;正常路径只在挂载时读一次)。 */
8143
+ loadPillPos: function () {
8144
+ pillPosAsked = false
8145
+ pillUserPlaced = false // 测试要确定性地验"读回来"这条路径
8146
+ loadPillPos()
8147
+ return true
8148
+ },
8149
+ /** 球心图形在进度 p 处的 path(p 可略越界)—— 测试按它核对整条形变轨迹。 */
8150
+ pillStarAt: function (p) {
8151
+ return pillStarPathAt(p)
8152
+ },
8153
+ /** 当前进度与目标(测试核对收/放确实把图形推向了正确的一端)。 */
8154
+ pillStarProgress: function () {
8155
+ return pillStarP
8156
+ },
8157
+ pillStarTarget: function () {
8158
+ return pillStarTargetP
8159
+ },
8160
+ /** 形变动画被起跑了几次 + 目标序列(诊断用:一直重新起跑就永远到不了终点)。 */
8161
+ pillStarStarts: function () {
8162
+ return pillStarStarts
8163
+ },
8164
+ pillStarLog: function () {
8165
+ return pillStarLog
8166
+ },
8167
+ /** 直接跑一次形变动画(与胶囊状态解耦):测试用它确定性地验"计时器在推进"。 */
8168
+ pillStarAnimate: function (to) {
8169
+ animatePillStar(to ? 1 : 0)
8170
+ return true
8171
+ },
8172
+ /** 同步画到某个进度(几何断言/截图用,绕开计时器与异步活动)。 */
8173
+ pillStarTo: function (p) {
8174
+ if (pillStarTimer) {
8175
+ clearTimeout(pillStarTimer)
8176
+ pillStarTimer = 0
8177
+ }
8178
+ pillStarTargetP = p
8179
+ paintPillStar(p)
8180
+ return true
8181
+ },
8182
+ pillStarEaseAt: function (x) {
8183
+ return pillStarEase(x)
8184
+ },
8185
+ /** JS 那条时长(毫秒)—— 测试核对它与 CSS 那条 .28s 是否同拍。 */
8186
+ pillMorphMs: function () {
8187
+ return PILL_MORPH_MS
8188
+ },
8189
+ /** 球心里那两条 path(圆点态 / 星芒态)—— 自检与截图模式用它,避免在测试里抄一遍几何。 */
8190
+ pillStarD: function () {
8191
+ return PILL_STAR_D
8192
+ },
8193
+ pillStarDotD: function () {
8194
+ return PILL_STAR_DOT_D
8195
+ },
7473
8196
  open: function (text, context, label, keyContext) {
7474
8197
  state.selection = { text: text, range: null, rect: null }
7475
8198
  // 和真实划词完全同一条路径(含本地缓存命中);keyContext 省略时与 context 相同
@@ -7733,6 +8456,54 @@ window.__ModuleLoader__.load({
7733
8456
  place: function () {
7734
8457
  return placePill()
7735
8458
  },
8459
+ /**
8460
+ * 自检用:胶囊的停靠 / 拖动 / 收球状态。
8461
+ * 拖动、吸附、收球那几条断言都读它(真浏览器里跑,量的是真布局)。
8462
+ */
8463
+ pillState: function () {
8464
+ var rect = pill.getBoundingClientRect()
8465
+ return {
8466
+ dock: pillDock,
8467
+ ball: pillBall,
8468
+ hover: pillHover,
8469
+ dragging: !!pillDrag,
8470
+ ready: pill.getAttribute('data-ready') === '1',
8471
+ anchorWidth: pillAnchorWidth,
8472
+ freeRight: pillFreeRight ? { right: Math.round(pillFreeRight.right), y: Math.round(pillFreeRight.y) } : null,
8473
+ rect: {
8474
+ left: Math.round(rect.left),
8475
+ top: Math.round(rect.top),
8476
+ width: Math.round(rect.width),
8477
+ height: Math.round(rect.height),
8478
+ right: Math.round(window.innerWidth - rect.right),
8479
+ bottom: Math.round(window.innerHeight - rect.bottom),
8480
+ },
8481
+ style: {
8482
+ left: pill.style.left,
8483
+ top: pill.style.top,
8484
+ right: pill.style.right,
8485
+ bottom: pill.style.bottom,
8486
+ width: pill.style.width,
8487
+ maxWidth: pill.style.maxWidth,
8488
+ },
8489
+ ballSize: PILL_BALL_SIZE,
8490
+ idleMs: PILL_BALL_IDLE_MS,
8491
+ snapRadius: PILL_SNAP_RADIUS,
8492
+ }
8493
+ },
8494
+ /**
8495
+ * 自检用:立刻跑一次"静置该不该收球"(测试里等不起 PILL_BALL_IDLE_MS 那 10 秒)。
8496
+ * 返回 true = 这次真的收成球了。
8497
+ */
8498
+ pillIdle: function () {
8499
+ if (pillIdleId) {
8500
+ clearTimeout(pillIdleId)
8501
+ pillIdleId = 0
8502
+ }
8503
+ if (!pillCanBall()) return false
8504
+ collapsePill()
8505
+ return true
8506
+ },
7736
8507
  /** 自检用:当前的自适应间隔(毫秒)。 */
7737
8508
  pollDelay: function () {
7738
8509
  return pillPollDelay
@@ -7859,6 +8630,8 @@ window.__ModuleLoader__.load({
7859
8630
  offDrag()
7860
8631
  offPillDown()
7861
8632
  offPillClick()
8633
+ offPillEnter()
8634
+ offPillLeave()
7862
8635
  offPillKey()
7863
8636
  offPreviewHeight()
7864
8637
  offBridgeMessage()
@@ -7906,6 +8679,9 @@ window.__ModuleLoader__.load({
7906
8679
  state.quotes = []
7907
8680
  if (state.request) state.request.abort()
7908
8681
  for (var i = 0; i < timers.length; i++) clearTimeout(timers[i])
8682
+ // 静置计时器是裸 setTimeout(不进 timers 数组,见 schedulePillIdle),单独清
8683
+ if (pillIdleId) clearTimeout(pillIdleId)
8684
+ if (pillStarTimer) clearTimeout(pillStarTimer)
7909
8685
  cache.clear()
7910
8686
  if (layer.parentNode) layer.parentNode.removeChild(layer)
7911
8687
  if (button.parentNode) button.parentNode.removeChild(button)
@@ -8148,13 +8924,22 @@ window.__ModuleLoader__.load({
8148
8924
  return svg
8149
8925
  }
8150
8926
 
8927
+ /**
8928
+ * 解读浮标上的四角星(✦)。
8929
+ *
8930
+ * 路径必须在 16×16 画布里**墨迹居中**(上下各留 2.4,墨迹中心 = 8):
8931
+ * 浮动按钮是 `align-items:center` 的 flex,居中的是**画布盒子**,
8932
+ * 画布内偏一点,图标就整体偏一点。老路径 y 占 1.2~12.4(中心 6.8),
8933
+ * 星形于是比右边「解读」两个字高了 1.1px(用户报的"✦ 和 解读 没对齐")。
8934
+ * 同理 x 占 2.4~13.6(中心 8,本来就对称)—— 横向那点偏差交给 CSS 的负外边距。
8935
+ */
8151
8936
  function sparkleIcon() {
8152
8937
  var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
8153
8938
  svg.setAttribute('viewBox', '0 0 16 16')
8154
8939
  svg.setAttribute('fill', 'currentColor')
8155
8940
  svg.setAttribute('aria-hidden', 'true')
8156
8941
  var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
8157
- path.setAttribute('d', 'M8 1.2l1.5 4.1 4.1 1.5-4.1 1.5L8 12.4 6.5 8.3 2.4 6.8l4.1-1.5L8 1.2z')
8942
+ path.setAttribute('d', 'M8 2.4l1.5 4.1 4.1 1.5-4.1 1.5L8 13.6 6.5 9.5 2.4 8l4.1-1.5L8 2.4z')
8158
8943
  svg.appendChild(path)
8159
8944
  return svg
8160
8945
  }