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 +41 -0
- package/README.en.md +2 -2
- package/README.md +3 -1
- package/lib/client.js +241 -23
- package/package.json +1 -1
- package/src/runtime.js +197 -20
- package/src/styles.js +42 -1
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.
|
|
10
|
-
>
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|