dsh-sidebar-organizer 0.4.0 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -3,6 +3,47 @@
3
3
  版本格式遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
  本文件的中文条目为默认语言;英文条目见英文段落(同一版本内)。
5
5
 
6
+ ## 0.4.2 — 2026-11-02
7
+
8
+ - 拖动到「更多」按钮上时新增收纳动画:按钮呼吸高亮、图标下沉,拖影微微缩小
9
+ 变淡;松手后拖影飞向按钮并缩小消失,随后该行被收纳,按钮播放一次「吞入」
10
+ 回弹。只剩最后一行可见时不再静默失败,改为按钮左右轻抖提示不可收纳。
11
+ - 动画只使用 `transform` / `opacity`(拖影飞入用 WAAPI,其余为 CSS),拖动热路
12
+ 径不新增布局读取;同一时刻只允许一个飞入动画,连续快速收纳不会叠加拖影。
13
+ - 尊重 `prefers-reduced-motion: reduce`:关闭呼吸、回弹与抖动,飞入改为立即
14
+ 收纳。
15
+
16
+ ### English
17
+
18
+ - Added the tuck-away animation on the “More” anchor: the button breathes, its
19
+ glyph dips, and the carried ghost shrinks slightly while armed; on release the
20
+ ghost flies into the anchor and fades, the row is tucked away, and the button
21
+ plays a short “swallow” bounce. When only one row is visible the anchor shakes
22
+ instead of silently refusing to hide it.
23
+ - Only `transform` / `opacity` animate (the flight uses WAAPI, the rest is CSS),
24
+ the drag hot path gains no layout reads, and a single flight runs at a time so
25
+ rapid drops cannot pile ghosts up.
26
+ - Honors `prefers-reduced-motion: reduce`: no breathing, bounce or shake, and the
27
+ row is tucked away immediately.
28
+
29
+ ## 0.4.1 — 2026-11-02
30
+
31
+ - 修复反复拖动时排序“抽搐”:落点改为按当前视觉顺序(`layout.order` +
32
+ 内联 `style.order`)计算,并把目标位置锚定到具体某一行的 key,而不是页面里
33
+ 的 DOM 序号;长按成立时会先强制落一次布局,避免用上一帧的旧几何做判定。
34
+ - 「更多」的投放判定收窄:只有指针真正落在「更多」按钮矩形内、且位于最后一
35
+ 行下方时才收纳,拖到列表末尾不再被误判成收纳。
36
+
37
+ ### English
38
+
39
+ - Fixed jittery ordering after repeated drags: the drop index is now computed
40
+ from the visual order (`layout.order` plus inline `style.order`) and anchored
41
+ to a row key instead of the DOM index, and a layout pass is forced when the
42
+ long-press succeeds so the frozen geometry is current.
43
+ - Tightened the “More” drop zone: a row is tucked away only when the pointer is
44
+ inside the anchor's own rectangle and below the last row, so dropping at the
45
+ end of the list no longer hides it by mistake.
46
+
6
47
  ## 0.4.0 — 2026-11-02
7
48
 
8
49
  - 源码拆分为 `src/` 下的模块(constants / styles / storage / icons / slot /
package/README.en.md CHANGED
@@ -6,8 +6,8 @@ Adds **custom ordering** and **tucking away** to the "global panels" strip at th
6
6
  top-left of the DSH (DeepSeek Harness) sidebar — the column of icons for plugins,
7
7
  marketplace, skills manager, scheduled tasks, and so on.
8
8
 
9
- > Version **0.4.0**: sources split into `src/` modules with a build script;
10
- > AI-usage and code-origin disclosure added; multilingual docs.
9
+ > Version **0.4.2**: adds the tuck-away animation when dragging onto “More”
10
+ > (breathing highlight, ghost flight, receive bounce).
11
11
 
12
12
  ## What it does
13
13
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  给 DSH(DeepSeek Harness)左侧栏左上角那块「全局面板」栏——插件、市场、技能管理、定时任务……那一列图标——加上**自定义排序**与**收纳**能力。
6
6
 
7
- > 当前版本 **0.4.0**:源码拆分为 `src/` 模块并加入构建脚本;补齐 AI 使用与代码来源声明;多语言文档。
7
+ > 当前版本 **0.4.2**:拖动到「更多」时新增收纳动画(按钮呼吸高亮、拖影飞入收纳)。
8
8
 
9
9
  ## 现在能做什么
10
10
 
@@ -133,6 +133,8 @@ npm run check # 语法检查
133
133
  - [x] 0.3.5 — 指针捕获延后到拖动成立,修复收纳条目无法点击
134
134
  - [x] 0.3.6 — 点击收纳条目只打开面板,不再自动放回侧栏
135
135
  - [x] 0.4.0 — 源码模块化与构建脚本、多语言文档、AI 使用与代码来源声明
136
+ - [x] 0.4.1 — 修复反复拖动时的排序抖动与「更多」误收纳
137
+ - [x] 0.4.2 — 拖动到「更多」的收纳动画(呼吸高亮、拖影飞入、回弹与拒绝抖动)
136
138
  - [ ] 0.5.0 — 无障碍补强(菜单内上移 / 下移按钮、屏幕阅读器朗读顺序变化)
137
139
 
138
140
  ## 声明与引用
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-sidebar-organizer — client half (v0.4.0).
2
+ * dsh-sidebar-organizer — client half (v0.4.2).
3
3
  *
4
4
  * GENERATED FILE — do not edit by hand.
5
5
  * Sources live in src/; run `npm run build` to regenerate.
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
15
15
  /* Constants, locale strings and the module-level shared handles. */
16
16
 
17
17
  // Replaced at build time from package.json by scripts/build.mjs.
18
- const VERSION = "0.4.0";
18
+ const VERSION = "0.4.2";
19
19
 
20
20
  const PANEL_SLOT = "sidebar.panellist";
21
21
  const ACTIONS_SLOT = "sidebar.panellist.actions";
@@ -92,10 +92,51 @@ const CSS = `
92
92
  button[data-dso-key].dso-dragging { opacity: 0.35; }
93
93
  button[data-dso-key].dso-pressing { transform: scale(0.94); transition: transform 120ms ease; }
94
94
  button[data-dso-key] { -webkit-user-select: none; user-select: none; }
95
- button[data-dso-key].dso-drop-more {
95
+ /* The "More" anchor, armed: it breathes a ring and dips its glyph to read as
96
+ "release here and this row drops into me". */
97
+ button.dso-drop-more {
96
98
  outline: 2px dashed var(--dsw-alias-state-business-primary, #4d6bfe);
97
99
  outline-offset: -2px;
98
100
  background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08));
101
+ animation: dso-more-breathe 1100ms ease-in-out infinite;
102
+ }
103
+ button.dso-drop-more span[aria-hidden="true"] {
104
+ transform: translateY(2px) scale(0.88);
105
+ transition: transform 160ms ease;
106
+ }
107
+ @keyframes dso-more-breathe {
108
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0.38); }
109
+ 55% { box-shadow: 0 0 0 8px rgba(77, 107, 254, 0); }
110
+ 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0); }
111
+ }
112
+ @keyframes dso-more-receive {
113
+ 0% { transform: scale(1); }
114
+ 35% { transform: scale(0.86); }
115
+ 70% { transform: scale(1.06); }
116
+ 100% { transform: scale(1); }
117
+ }
118
+ button.dso-drop-receive { animation: dso-more-receive 260ms cubic-bezier(0.2, 0.7, 0.3, 1); }
119
+ @keyframes dso-drop-reject {
120
+ 0%, 100% { transform: translateX(0); }
121
+ 20% { transform: translateX(-4px); }
122
+ 40% { transform: translateX(4px); }
123
+ 60% { transform: translateX(-3px); }
124
+ 80% { transform: translateX(2px); }
125
+ }
126
+ button.dso-drop-reject { animation: dso-drop-reject 240ms ease; }
127
+ /* Carrying a row towards the anchor: the ghost dims and shrinks a little, so
128
+ its eventual absorption does not feel like a jump. */
129
+ .dso-ghost.dso-ghost-armed {
130
+ opacity: 0.82;
131
+ transform: scale(0.94);
132
+ transition: transform 140ms ease, opacity 140ms ease;
133
+ }
134
+ @media (prefers-reduced-motion: reduce) {
135
+ button.dso-drop-more,
136
+ button.dso-drop-receive,
137
+ button.dso-drop-reject { animation: none !important; }
138
+ .dso-ghost,
139
+ .dso-ghost.dso-ghost-armed { transition: none !important; }
99
140
  }
100
141
  body.dso-dragging,
101
142
  body.dso-dragging * { cursor: grabbing !important; }
@@ -312,6 +353,7 @@ function createRuntime(ctx) {
312
353
  passDeferred: false,
313
354
  menuListeners: new Set(),
314
355
  dismissMenu: null,
356
+ absorbing: null,
315
357
  };
316
358
 
317
359
  function log(...args) {
@@ -424,8 +466,24 @@ function createRuntime(ctx) {
424
466
  return { nav, rows, moreHost, moreButton };
425
467
  }
426
468
 
469
+ /**
470
+ * Keys of the rows currently visible in the strip, in the order the user sees
471
+ * them — i.e. the persisted layout order, NOT the DOM order. The sidebar keeps
472
+ * rendering rows in registration order and we only move them with CSS
473
+ * `order`, so a DOM-order list would shift every time a drag committed and
474
+ * make drops land somewhere other than where the user released the pointer.
475
+ */
427
476
  function visibleKeys(rows) {
428
- return rows.filter((row) => !row.hidden).map((row) => row.key);
477
+ const layout = readLayout();
478
+ const visible = rows.filter((row) => !row.hidden).map((row) => row.key);
479
+ const ordered = [];
480
+ for (const key of layout.order) {
481
+ if (visible.includes(key) && !ordered.includes(key)) ordered.push(key);
482
+ }
483
+ for (const key of visible) {
484
+ if (!ordered.includes(key)) ordered.push(key);
485
+ }
486
+ return ordered;
429
487
  }
430
488
 
431
489
  function applyOrder(nav, rows, moreHost, moreButton) {
@@ -500,11 +558,13 @@ function createRuntime(ctx) {
500
558
  }
501
559
  }
502
560
 
503
- function pass() {
561
+ function pass(force) {
504
562
  if (!alive()) return;
505
563
  // A pass would fight the drag ghost for layout: rows must not move while a
506
- // row is being carried. Re-run once the gesture is over.
507
- if (state.gesture) {
564
+ // row is being carried. Re-run once the gesture is over. `force` is used
565
+ // right before a drag starts to make sure the DOM already reflects the
566
+ // persisted order, so frozen rects and the committed order agree.
567
+ if (state.gesture && !force) {
508
568
  state.passDeferred = true;
509
569
  return;
510
570
  }
@@ -676,9 +736,10 @@ function createRuntime(ctx) {
676
736
 
677
737
  /* --- drag gesture ------------------------------------------------ */
678
738
 
679
- function clearGesture() {
739
+ function clearGesture(options) {
680
740
  const gesture = state.gesture;
681
741
  if (!gesture) return;
742
+ const keepGhost = Boolean(options && options.keepGhost);
682
743
  state.gesture = null;
683
744
  if (gesture.timer) window.clearTimeout(gesture.timer);
684
745
  if (gesture.pressTimer) window.clearTimeout(gesture.pressTimer);
@@ -699,7 +760,11 @@ function createRuntime(ctx) {
699
760
  /* the element may already be detached */
700
761
  }
701
762
  }
702
- if (gesture.ghost && gesture.ghost.parentNode) gesture.ghost.remove();
763
+ if (gesture.ghost) {
764
+ gesture.ghost.classList.remove("dso-ghost-armed");
765
+ // A drop on the "More" anchor keeps the ghost alive for the absorb flight.
766
+ if (!keepGhost && gesture.ghost.parentNode) gesture.ghost.remove();
767
+ }
703
768
  if (gesture.indicator && gesture.indicator.parentNode) gesture.indicator.remove();
704
769
  if (gesture.element) {
705
770
  gesture.element.classList.remove("dso-dragging");
@@ -778,6 +843,16 @@ function createRuntime(ctx) {
778
843
  clearGesture();
779
844
  return;
780
845
  }
846
+ // Flush any pending layout before freezing rects: after a previous drop the
847
+ // rAF pass may not have run yet, and stale visuals would make the next drop
848
+ // land somewhere else than where the pointer is.
849
+ try {
850
+ pass(true);
851
+ } catch (error) {
852
+ /* a failed flush must not block the drag */
853
+ }
854
+ if (!element.isConnected) element = locateRowButton(gesture.key) || element;
855
+ gesture.element = element;
781
856
  const rect = element.getBoundingClientRect();
782
857
  if (rect.width === 0 || rect.height === 0) {
783
858
  clearGesture();
@@ -909,6 +984,96 @@ function createRuntime(ctx) {
909
984
  );
910
985
  }
911
986
 
987
+ function prefersReducedMotion() {
988
+ try {
989
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
990
+ } catch (error) {
991
+ return false;
992
+ }
993
+ }
994
+
995
+ /** Replay a one-shot CSS animation class, even if it is already on. */
996
+ function pulseClass(element, className, duration) {
997
+ if (!element) return;
998
+ element.classList.remove(className);
999
+ // force a style flush so a repeated pulse restarts instead of being ignored
1000
+ void element.offsetWidth;
1001
+ element.classList.add(className);
1002
+ window.setTimeout(() => element.classList.remove(className), duration);
1003
+ }
1004
+
1005
+ /** The carried row leans towards the anchor: dim and shrink the ghost. */
1006
+ function setGhostArmed(gesture, armed) {
1007
+ const ghost = gesture.ghost;
1008
+ if (!ghost || !ghost.isConnected) return;
1009
+ if (ghost.classList.contains("dso-ghost-armed") === armed) return;
1010
+ ghost.classList.toggle("dso-ghost-armed", armed);
1011
+ }
1012
+
1013
+ /**
1014
+ * Fly the carried ghost into the "More" anchor, then tuck the row away.
1015
+ * Only transform/opacity animate, and only one flight runs at a time; a drop
1016
+ * that arrives mid-flight lands immediately so ghosts can never pile up.
1017
+ */
1018
+ function absorbIntoMore(gesture) {
1019
+ const moreButton =
1020
+ gesture.moreButton && gesture.moreButton.isConnected ? gesture.moreButton : null;
1021
+ const ghost = gesture.ghost;
1022
+ if (!ghost || !ghost.isConnected || !moreButton || prefersReducedMotion() || state.absorbing) {
1023
+ if (moreButton) pulseClass(moreButton, "dso-drop-receive", 260);
1024
+ if (ghost && ghost.parentNode) ghost.remove();
1025
+ if (alive()) hideKey(gesture.key);
1026
+ return;
1027
+ }
1028
+
1029
+ const ghostRect = ghost.getBoundingClientRect();
1030
+ const moreRect = moreButton.getBoundingClientRect();
1031
+ const dx = moreRect.left + moreRect.width / 2 - (ghostRect.left + ghostRect.width / 2);
1032
+ const dy = moreRect.top + moreRect.height / 2 - (ghostRect.top + ghostRect.height / 2);
1033
+
1034
+ let animation = null;
1035
+ try {
1036
+ animation = ghost.animate(
1037
+ [
1038
+ { transform: "translate(0px, 0px) scale(1)", opacity: 0.96, offset: 0 },
1039
+ {
1040
+ transform: `translate(${dx * 0.55}px, ${dy * 0.55}px) scale(0.7)`,
1041
+ opacity: 0.9,
1042
+ offset: 0.55,
1043
+ },
1044
+ {
1045
+ transform: `translate(${dx}px, ${dy}px) scale(0.12)`,
1046
+ opacity: 0,
1047
+ offset: 1,
1048
+ },
1049
+ ],
1050
+ { duration: 180, easing: "cubic-bezier(.2,.7,.3,1)", fill: "forwards" }
1051
+ );
1052
+ } catch (error) {
1053
+ animation = null;
1054
+ }
1055
+
1056
+ pulseClass(moreButton, "dso-drop-receive", 260);
1057
+
1058
+ if (!animation) {
1059
+ if (ghost.parentNode) ghost.remove();
1060
+ if (alive()) hideKey(gesture.key);
1061
+ return;
1062
+ }
1063
+
1064
+ state.absorbing = animation;
1065
+ const finish = () => {
1066
+ if (state.absorbing === animation) state.absorbing = null;
1067
+ if (ghost.parentNode) ghost.remove();
1068
+ if (alive()) hideKey(gesture.key);
1069
+ };
1070
+ if (animation.finished && typeof animation.finished.then === "function") {
1071
+ animation.finished.then(finish, finish);
1072
+ } else {
1073
+ window.setTimeout(finish, 200);
1074
+ }
1075
+ }
1076
+
912
1077
  function updateTarget(gesture) {
913
1078
  const indicator = gesture.indicator;
914
1079
  if (!indicator) return;
@@ -955,21 +1120,32 @@ function createRuntime(ctx) {
955
1120
  gesture.target = null;
956
1121
  }
957
1122
 
958
- // Dropping a sidebar row on the "More" anchor tucks it away.
959
- if (gesture.source === "nav" && snapshot.moreRect && insideRect(snapshot.moreRect, x, y, 6)) {
960
- if (gesture.moreButton !== snapshot.moreButton) {
961
- if (gesture.moreButton) gesture.moreButton.classList.remove("dso-drop-more");
962
- gesture.moreButton = snapshot.moreButton;
963
- if (gesture.moreButton) gesture.moreButton.classList.add("dso-drop-more");
1123
+ // Dropping a sidebar row on the "More" anchor tucks it away. The anchor is
1124
+ // a small target: require the pointer to be inside its own rectangle
1125
+ // (no padding) and strictly below the last row, otherwise a drop meant for
1126
+ // "end of the strip" would quietly hide the row instead of reordering it.
1127
+ if (gesture.source === "nav" && snapshot.moreRect) {
1128
+ const lastBottom = others.length
1129
+ ? others[others.length - 1].rect.top + others[others.length - 1].rect.height
1130
+ : -Infinity;
1131
+ const overMore = insideRect(snapshot.moreRect, x, y, 0) && y > lastBottom + 1;
1132
+ if (overMore) {
1133
+ if (gesture.moreButton !== snapshot.moreButton) {
1134
+ if (gesture.moreButton) gesture.moreButton.classList.remove("dso-drop-more");
1135
+ gesture.moreButton = snapshot.moreButton;
1136
+ if (gesture.moreButton) gesture.moreButton.classList.add("dso-drop-more");
1137
+ }
1138
+ setGhostArmed(gesture, true);
1139
+ if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
1140
+ gesture.target = { kind: "hide" };
1141
+ return;
964
1142
  }
965
- if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
966
- gesture.target = { kind: "hide" };
967
- return;
968
1143
  }
969
1144
  if (gesture.moreButton) {
970
1145
  gesture.moreButton.classList.remove("dso-drop-more");
971
1146
  gesture.moreButton = null;
972
1147
  }
1148
+ setGhostArmed(gesture, false);
973
1149
 
974
1150
  if (!insideRect(snapshot.navRect, x, y, 24) || others.length === 0) {
975
1151
  if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
@@ -985,7 +1161,15 @@ function createRuntime(ctx) {
985
1161
  break;
986
1162
  }
987
1163
  }
988
- gesture.target = { kind: "place", index };
1164
+ // Remember which row the drop line sits in front of. Indices are computed
1165
+ // from frozen rects (the visual order at drag start) while commits apply to
1166
+ // the persisted order, so carrying the neighbour key keeps the two aligned
1167
+ // even when another drop has just rewritten the layout.
1168
+ gesture.target = {
1169
+ kind: "place",
1170
+ index,
1171
+ beforeKey: index < others.length ? others[index].key : null,
1172
+ };
989
1173
 
990
1174
  if (index === snapshot.lastIndex) {
991
1175
  if (indicator.dataset.visible !== "1") indicator.dataset.visible = "1";
@@ -1010,7 +1194,15 @@ function createRuntime(ctx) {
1010
1194
  const { key, target, source } = gesture;
1011
1195
  if (!target) return;
1012
1196
  if (target.kind === "hide") {
1013
- hideKey(key);
1197
+ // Last-visible-row protection lives in hideKey; bounce the anchor instead
1198
+ // of starting an absorb flight that would end in nothing.
1199
+ const { rows } = visibleRows();
1200
+ const visible = visibleKeys(rows);
1201
+ if (!visible.includes(key) || visible.length <= 1) {
1202
+ pulseClass(gesture.moreButton, "dso-drop-reject", 240);
1203
+ return;
1204
+ }
1205
+ absorbIntoMore(gesture);
1014
1206
  return;
1015
1207
  }
1016
1208
  if (target.kind === "place-menu") {
@@ -1018,8 +1210,16 @@ function createRuntime(ctx) {
1018
1210
  return;
1019
1211
  }
1020
1212
  if (target.kind === "place") {
1213
+ // Map the drop position onto the persisted order (see visibleKeys): the
1214
+ // anchor row is the key we resolved from the frozen rects.
1215
+ const { rows } = visibleRows();
1216
+ const others = visibleKeys(rows).filter((item) => item !== key);
1217
+ let index = Math.max(0, Math.min(target.index, others.length));
1218
+ if (target.beforeKey && others.includes(target.beforeKey)) {
1219
+ index = others.indexOf(target.beforeKey);
1220
+ }
1021
1221
  if (source === "menu") {
1022
- revealKey(key, target.index);
1222
+ revealKey(key, index);
1023
1223
  if (state.dismissMenu) {
1024
1224
  try {
1025
1225
  state.dismissMenu();
@@ -1028,7 +1228,7 @@ function createRuntime(ctx) {
1028
1228
  }
1029
1229
  }
1030
1230
  } else {
1031
- reorderKey(key, target.index);
1231
+ reorderKey(key, index);
1032
1232
  }
1033
1233
  }
1034
1234
  }
@@ -1062,7 +1262,10 @@ function createRuntime(ctx) {
1062
1262
  if (!gesture) return;
1063
1263
  if (gesture.phase === "dragging") {
1064
1264
  const pending = gesture;
1065
- clearGesture();
1265
+ // A drop on the anchor keeps the ghost for the absorb flight; every other
1266
+ // drop tears the drag down first so commits never see stale classes.
1267
+ const absorbing = Boolean(pending.target && pending.target.kind === "hide");
1268
+ clearGesture({ keepGhost: absorbing });
1066
1269
  commitDrag(pending);
1067
1270
  state.guardUntil = Date.now() + CLICK_GUARD_MS;
1068
1271
  return;
@@ -1201,6 +1404,21 @@ function createRuntime(ctx) {
1201
1404
  function stop() {
1202
1405
  state.stopped = true;
1203
1406
  clearGesture();
1407
+ if (state.absorbing) {
1408
+ try {
1409
+ state.absorbing.cancel();
1410
+ } catch (error) {
1411
+ /* ignore */
1412
+ }
1413
+ state.absorbing = null;
1414
+ }
1415
+ // the absorb flight may have left a ghost or a one-shot class behind
1416
+ for (const ghost of document.querySelectorAll(".dso-ghost")) ghost.remove();
1417
+ for (const node of document.querySelectorAll(
1418
+ ".dso-drop-more, .dso-drop-receive, .dso-drop-reject"
1419
+ )) {
1420
+ node.classList.remove("dso-drop-more", "dso-drop-receive", "dso-drop-reject");
1421
+ }
1204
1422
  if (state.pollTimer) {
1205
1423
  window.clearInterval(state.pollTimer);
1206
1424
  state.pollTimer = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-organizer",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "description": "DSH 侧栏面板整理器:长按拖动排序、拖入/拖出「更多」收纳、键盘排序",
6
6
  "type": "module",
package/src/runtime.js CHANGED
@@ -17,6 +17,7 @@ function createRuntime(ctx) {
17
17
  passDeferred: false,
18
18
  menuListeners: new Set(),
19
19
  dismissMenu: null,
20
+ absorbing: null,
20
21
  };
21
22
 
22
23
  function log(...args) {
@@ -129,8 +130,24 @@ function createRuntime(ctx) {
129
130
  return { nav, rows, moreHost, moreButton };
130
131
  }
131
132
 
133
+ /**
134
+ * Keys of the rows currently visible in the strip, in the order the user sees
135
+ * them — i.e. the persisted layout order, NOT the DOM order. The sidebar keeps
136
+ * rendering rows in registration order and we only move them with CSS
137
+ * `order`, so a DOM-order list would shift every time a drag committed and
138
+ * make drops land somewhere other than where the user released the pointer.
139
+ */
132
140
  function visibleKeys(rows) {
133
- return rows.filter((row) => !row.hidden).map((row) => row.key);
141
+ const layout = readLayout();
142
+ const visible = rows.filter((row) => !row.hidden).map((row) => row.key);
143
+ const ordered = [];
144
+ for (const key of layout.order) {
145
+ if (visible.includes(key) && !ordered.includes(key)) ordered.push(key);
146
+ }
147
+ for (const key of visible) {
148
+ if (!ordered.includes(key)) ordered.push(key);
149
+ }
150
+ return ordered;
134
151
  }
135
152
 
136
153
  function applyOrder(nav, rows, moreHost, moreButton) {
@@ -205,11 +222,13 @@ function createRuntime(ctx) {
205
222
  }
206
223
  }
207
224
 
208
- function pass() {
225
+ function pass(force) {
209
226
  if (!alive()) return;
210
227
  // A pass would fight the drag ghost for layout: rows must not move while a
211
- // row is being carried. Re-run once the gesture is over.
212
- if (state.gesture) {
228
+ // row is being carried. Re-run once the gesture is over. `force` is used
229
+ // right before a drag starts to make sure the DOM already reflects the
230
+ // persisted order, so frozen rects and the committed order agree.
231
+ if (state.gesture && !force) {
213
232
  state.passDeferred = true;
214
233
  return;
215
234
  }
@@ -381,9 +400,10 @@ function createRuntime(ctx) {
381
400
 
382
401
  /* --- drag gesture ------------------------------------------------ */
383
402
 
384
- function clearGesture() {
403
+ function clearGesture(options) {
385
404
  const gesture = state.gesture;
386
405
  if (!gesture) return;
406
+ const keepGhost = Boolean(options && options.keepGhost);
387
407
  state.gesture = null;
388
408
  if (gesture.timer) window.clearTimeout(gesture.timer);
389
409
  if (gesture.pressTimer) window.clearTimeout(gesture.pressTimer);
@@ -404,7 +424,11 @@ function createRuntime(ctx) {
404
424
  /* the element may already be detached */
405
425
  }
406
426
  }
407
- if (gesture.ghost && gesture.ghost.parentNode) gesture.ghost.remove();
427
+ if (gesture.ghost) {
428
+ gesture.ghost.classList.remove("dso-ghost-armed");
429
+ // A drop on the "More" anchor keeps the ghost alive for the absorb flight.
430
+ if (!keepGhost && gesture.ghost.parentNode) gesture.ghost.remove();
431
+ }
408
432
  if (gesture.indicator && gesture.indicator.parentNode) gesture.indicator.remove();
409
433
  if (gesture.element) {
410
434
  gesture.element.classList.remove("dso-dragging");
@@ -483,6 +507,16 @@ function createRuntime(ctx) {
483
507
  clearGesture();
484
508
  return;
485
509
  }
510
+ // Flush any pending layout before freezing rects: after a previous drop the
511
+ // rAF pass may not have run yet, and stale visuals would make the next drop
512
+ // land somewhere else than where the pointer is.
513
+ try {
514
+ pass(true);
515
+ } catch (error) {
516
+ /* a failed flush must not block the drag */
517
+ }
518
+ if (!element.isConnected) element = locateRowButton(gesture.key) || element;
519
+ gesture.element = element;
486
520
  const rect = element.getBoundingClientRect();
487
521
  if (rect.width === 0 || rect.height === 0) {
488
522
  clearGesture();
@@ -614,6 +648,96 @@ function createRuntime(ctx) {
614
648
  );
615
649
  }
616
650
 
651
+ function prefersReducedMotion() {
652
+ try {
653
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
654
+ } catch (error) {
655
+ return false;
656
+ }
657
+ }
658
+
659
+ /** Replay a one-shot CSS animation class, even if it is already on. */
660
+ function pulseClass(element, className, duration) {
661
+ if (!element) return;
662
+ element.classList.remove(className);
663
+ // force a style flush so a repeated pulse restarts instead of being ignored
664
+ void element.offsetWidth;
665
+ element.classList.add(className);
666
+ window.setTimeout(() => element.classList.remove(className), duration);
667
+ }
668
+
669
+ /** The carried row leans towards the anchor: dim and shrink the ghost. */
670
+ function setGhostArmed(gesture, armed) {
671
+ const ghost = gesture.ghost;
672
+ if (!ghost || !ghost.isConnected) return;
673
+ if (ghost.classList.contains("dso-ghost-armed") === armed) return;
674
+ ghost.classList.toggle("dso-ghost-armed", armed);
675
+ }
676
+
677
+ /**
678
+ * Fly the carried ghost into the "More" anchor, then tuck the row away.
679
+ * Only transform/opacity animate, and only one flight runs at a time; a drop
680
+ * that arrives mid-flight lands immediately so ghosts can never pile up.
681
+ */
682
+ function absorbIntoMore(gesture) {
683
+ const moreButton =
684
+ gesture.moreButton && gesture.moreButton.isConnected ? gesture.moreButton : null;
685
+ const ghost = gesture.ghost;
686
+ if (!ghost || !ghost.isConnected || !moreButton || prefersReducedMotion() || state.absorbing) {
687
+ if (moreButton) pulseClass(moreButton, "dso-drop-receive", 260);
688
+ if (ghost && ghost.parentNode) ghost.remove();
689
+ if (alive()) hideKey(gesture.key);
690
+ return;
691
+ }
692
+
693
+ const ghostRect = ghost.getBoundingClientRect();
694
+ const moreRect = moreButton.getBoundingClientRect();
695
+ const dx = moreRect.left + moreRect.width / 2 - (ghostRect.left + ghostRect.width / 2);
696
+ const dy = moreRect.top + moreRect.height / 2 - (ghostRect.top + ghostRect.height / 2);
697
+
698
+ let animation = null;
699
+ try {
700
+ animation = ghost.animate(
701
+ [
702
+ { transform: "translate(0px, 0px) scale(1)", opacity: 0.96, offset: 0 },
703
+ {
704
+ transform: `translate(${dx * 0.55}px, ${dy * 0.55}px) scale(0.7)`,
705
+ opacity: 0.9,
706
+ offset: 0.55,
707
+ },
708
+ {
709
+ transform: `translate(${dx}px, ${dy}px) scale(0.12)`,
710
+ opacity: 0,
711
+ offset: 1,
712
+ },
713
+ ],
714
+ { duration: 180, easing: "cubic-bezier(.2,.7,.3,1)", fill: "forwards" }
715
+ );
716
+ } catch (error) {
717
+ animation = null;
718
+ }
719
+
720
+ pulseClass(moreButton, "dso-drop-receive", 260);
721
+
722
+ if (!animation) {
723
+ if (ghost.parentNode) ghost.remove();
724
+ if (alive()) hideKey(gesture.key);
725
+ return;
726
+ }
727
+
728
+ state.absorbing = animation;
729
+ const finish = () => {
730
+ if (state.absorbing === animation) state.absorbing = null;
731
+ if (ghost.parentNode) ghost.remove();
732
+ if (alive()) hideKey(gesture.key);
733
+ };
734
+ if (animation.finished && typeof animation.finished.then === "function") {
735
+ animation.finished.then(finish, finish);
736
+ } else {
737
+ window.setTimeout(finish, 200);
738
+ }
739
+ }
740
+
617
741
  function updateTarget(gesture) {
618
742
  const indicator = gesture.indicator;
619
743
  if (!indicator) return;
@@ -660,21 +784,32 @@ function createRuntime(ctx) {
660
784
  gesture.target = null;
661
785
  }
662
786
 
663
- // Dropping a sidebar row on the "More" anchor tucks it away.
664
- if (gesture.source === "nav" && snapshot.moreRect && insideRect(snapshot.moreRect, x, y, 6)) {
665
- if (gesture.moreButton !== snapshot.moreButton) {
666
- if (gesture.moreButton) gesture.moreButton.classList.remove("dso-drop-more");
667
- gesture.moreButton = snapshot.moreButton;
668
- if (gesture.moreButton) gesture.moreButton.classList.add("dso-drop-more");
787
+ // Dropping a sidebar row on the "More" anchor tucks it away. The anchor is
788
+ // a small target: require the pointer to be inside its own rectangle
789
+ // (no padding) and strictly below the last row, otherwise a drop meant for
790
+ // "end of the strip" would quietly hide the row instead of reordering it.
791
+ if (gesture.source === "nav" && snapshot.moreRect) {
792
+ const lastBottom = others.length
793
+ ? others[others.length - 1].rect.top + others[others.length - 1].rect.height
794
+ : -Infinity;
795
+ const overMore = insideRect(snapshot.moreRect, x, y, 0) && y > lastBottom + 1;
796
+ if (overMore) {
797
+ if (gesture.moreButton !== snapshot.moreButton) {
798
+ if (gesture.moreButton) gesture.moreButton.classList.remove("dso-drop-more");
799
+ gesture.moreButton = snapshot.moreButton;
800
+ if (gesture.moreButton) gesture.moreButton.classList.add("dso-drop-more");
801
+ }
802
+ setGhostArmed(gesture, true);
803
+ if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
804
+ gesture.target = { kind: "hide" };
805
+ return;
669
806
  }
670
- if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
671
- gesture.target = { kind: "hide" };
672
- return;
673
807
  }
674
808
  if (gesture.moreButton) {
675
809
  gesture.moreButton.classList.remove("dso-drop-more");
676
810
  gesture.moreButton = null;
677
811
  }
812
+ setGhostArmed(gesture, false);
678
813
 
679
814
  if (!insideRect(snapshot.navRect, x, y, 24) || others.length === 0) {
680
815
  if (indicator.dataset.visible !== "0") indicator.dataset.visible = "0";
@@ -690,7 +825,15 @@ function createRuntime(ctx) {
690
825
  break;
691
826
  }
692
827
  }
693
- gesture.target = { kind: "place", index };
828
+ // Remember which row the drop line sits in front of. Indices are computed
829
+ // from frozen rects (the visual order at drag start) while commits apply to
830
+ // the persisted order, so carrying the neighbour key keeps the two aligned
831
+ // even when another drop has just rewritten the layout.
832
+ gesture.target = {
833
+ kind: "place",
834
+ index,
835
+ beforeKey: index < others.length ? others[index].key : null,
836
+ };
694
837
 
695
838
  if (index === snapshot.lastIndex) {
696
839
  if (indicator.dataset.visible !== "1") indicator.dataset.visible = "1";
@@ -715,7 +858,15 @@ function createRuntime(ctx) {
715
858
  const { key, target, source } = gesture;
716
859
  if (!target) return;
717
860
  if (target.kind === "hide") {
718
- hideKey(key);
861
+ // Last-visible-row protection lives in hideKey; bounce the anchor instead
862
+ // of starting an absorb flight that would end in nothing.
863
+ const { rows } = visibleRows();
864
+ const visible = visibleKeys(rows);
865
+ if (!visible.includes(key) || visible.length <= 1) {
866
+ pulseClass(gesture.moreButton, "dso-drop-reject", 240);
867
+ return;
868
+ }
869
+ absorbIntoMore(gesture);
719
870
  return;
720
871
  }
721
872
  if (target.kind === "place-menu") {
@@ -723,8 +874,16 @@ function createRuntime(ctx) {
723
874
  return;
724
875
  }
725
876
  if (target.kind === "place") {
877
+ // Map the drop position onto the persisted order (see visibleKeys): the
878
+ // anchor row is the key we resolved from the frozen rects.
879
+ const { rows } = visibleRows();
880
+ const others = visibleKeys(rows).filter((item) => item !== key);
881
+ let index = Math.max(0, Math.min(target.index, others.length));
882
+ if (target.beforeKey && others.includes(target.beforeKey)) {
883
+ index = others.indexOf(target.beforeKey);
884
+ }
726
885
  if (source === "menu") {
727
- revealKey(key, target.index);
886
+ revealKey(key, index);
728
887
  if (state.dismissMenu) {
729
888
  try {
730
889
  state.dismissMenu();
@@ -733,7 +892,7 @@ function createRuntime(ctx) {
733
892
  }
734
893
  }
735
894
  } else {
736
- reorderKey(key, target.index);
895
+ reorderKey(key, index);
737
896
  }
738
897
  }
739
898
  }
@@ -767,7 +926,10 @@ function createRuntime(ctx) {
767
926
  if (!gesture) return;
768
927
  if (gesture.phase === "dragging") {
769
928
  const pending = gesture;
770
- clearGesture();
929
+ // A drop on the anchor keeps the ghost for the absorb flight; every other
930
+ // drop tears the drag down first so commits never see stale classes.
931
+ const absorbing = Boolean(pending.target && pending.target.kind === "hide");
932
+ clearGesture({ keepGhost: absorbing });
771
933
  commitDrag(pending);
772
934
  state.guardUntil = Date.now() + CLICK_GUARD_MS;
773
935
  return;
@@ -906,6 +1068,21 @@ function createRuntime(ctx) {
906
1068
  function stop() {
907
1069
  state.stopped = true;
908
1070
  clearGesture();
1071
+ if (state.absorbing) {
1072
+ try {
1073
+ state.absorbing.cancel();
1074
+ } catch (error) {
1075
+ /* ignore */
1076
+ }
1077
+ state.absorbing = null;
1078
+ }
1079
+ // the absorb flight may have left a ghost or a one-shot class behind
1080
+ for (const ghost of document.querySelectorAll(".dso-ghost")) ghost.remove();
1081
+ for (const node of document.querySelectorAll(
1082
+ ".dso-drop-more, .dso-drop-receive, .dso-drop-reject"
1083
+ )) {
1084
+ node.classList.remove("dso-drop-more", "dso-drop-receive", "dso-drop-reject");
1085
+ }
909
1086
  if (state.pollTimer) {
910
1087
  window.clearInterval(state.pollTimer);
911
1088
  state.pollTimer = 0;
package/src/styles.js CHANGED
@@ -31,10 +31,51 @@ const CSS = `
31
31
  button[data-dso-key].dso-dragging { opacity: 0.35; }
32
32
  button[data-dso-key].dso-pressing { transform: scale(0.94); transition: transform 120ms ease; }
33
33
  button[data-dso-key] { -webkit-user-select: none; user-select: none; }
34
- button[data-dso-key].dso-drop-more {
34
+ /* The "More" anchor, armed: it breathes a ring and dips its glyph to read as
35
+ "release here and this row drops into me". */
36
+ button.dso-drop-more {
35
37
  outline: 2px dashed var(--dsw-alias-state-business-primary, #4d6bfe);
36
38
  outline-offset: -2px;
37
39
  background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08));
40
+ animation: dso-more-breathe 1100ms ease-in-out infinite;
41
+ }
42
+ button.dso-drop-more span[aria-hidden="true"] {
43
+ transform: translateY(2px) scale(0.88);
44
+ transition: transform 160ms ease;
45
+ }
46
+ @keyframes dso-more-breathe {
47
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0.38); }
48
+ 55% { box-shadow: 0 0 0 8px rgba(77, 107, 254, 0); }
49
+ 100% { box-shadow: 0 0 0 0 rgba(77, 107, 254, 0); }
50
+ }
51
+ @keyframes dso-more-receive {
52
+ 0% { transform: scale(1); }
53
+ 35% { transform: scale(0.86); }
54
+ 70% { transform: scale(1.06); }
55
+ 100% { transform: scale(1); }
56
+ }
57
+ button.dso-drop-receive { animation: dso-more-receive 260ms cubic-bezier(0.2, 0.7, 0.3, 1); }
58
+ @keyframes dso-drop-reject {
59
+ 0%, 100% { transform: translateX(0); }
60
+ 20% { transform: translateX(-4px); }
61
+ 40% { transform: translateX(4px); }
62
+ 60% { transform: translateX(-3px); }
63
+ 80% { transform: translateX(2px); }
64
+ }
65
+ button.dso-drop-reject { animation: dso-drop-reject 240ms ease; }
66
+ /* Carrying a row towards the anchor: the ghost dims and shrinks a little, so
67
+ its eventual absorption does not feel like a jump. */
68
+ .dso-ghost.dso-ghost-armed {
69
+ opacity: 0.82;
70
+ transform: scale(0.94);
71
+ transition: transform 140ms ease, opacity 140ms ease;
72
+ }
73
+ @media (prefers-reduced-motion: reduce) {
74
+ button.dso-drop-more,
75
+ button.dso-drop-receive,
76
+ button.dso-drop-reject { animation: none !important; }
77
+ .dso-ghost,
78
+ .dso-ghost.dso-ghost-armed { transition: none !important; }
38
79
  }
39
80
  body.dso-dragging,
40
81
  body.dso-dragging * { cursor: grabbing !important; }