dsh-calendar 0.8.0 → 0.8.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 +14 -0
- package/README.md +2 -0
- package/lib/client.js +157 -28
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2(2026-09-20)
|
|
4
|
+
|
|
5
|
+
- **修主题色误用**:此前把宿主遮罩 token `--dsw-alias-bg-mask-3` 当淡色 hover/底纹用;DSH 0.1.6-alpha.2 里它实测亮/暗主题都是 `rgba(0,0,0,.48)`,导致周视图「今天」整列、月视图格子 hover、加载骨架屏、详情 tag 全部发深灰(浏览器视觉验收抓到,视觉上像一块无字灰板)。现改为 `color-mix`(label-primary 5%~7% / business-primary 7%),mask-3 只保留在 `.dshc-overlay` / `.dshc-modalwrap` 两处真正的遮罩上。
|
|
6
|
+
- **修 Esc 关错层**:详情抽屉/新建表单打开时按 Esc 会连整个日历浮层面板一起关掉。现在 Esc 只关最上面一层,第二次才关面板;焦点在上层宿主 `PRIM.Modal` 里时让行给宿主;输入法组合状态(`isComposing`)不误关;设置页与对话页两个面板并存时一次也只关一层,并按实际打开顺序关。
|
|
7
|
+
- **测试**:render 测试 7 → 15 条(新增抽屉/宿主 Modal 真假路径/宿主高层浮层让行/普通宿主元素兜底/层序/IME 等用例),全量 154 pass。该版经两位独立评审:首轮抓到「宿主 Portal 抢 Esc」「双面板一次连关两层」「IME 误关」,二轮抓到「宿主高层 Modal 盖上来时关错下面那层」,均已修。
|
|
8
|
+
|
|
9
|
+
## 0.8.1(2026-09-19)
|
|
10
|
+
|
|
11
|
+
- **新增真 DOM 行为测试层**(`test/render.test.mjs`,devDependencies 里加 `react` / `react-dom` / `jsdom`,不进发布包):用 jsdom + 真 React 渲染周视图,然后真派发 `pointerdown/move/up`、`click`、`keydown`。7 条覆盖:渲染不抛、点事件不会顺手弹「新建」、拖动只提交一次且按视口坐标换算、拖动中 Esc 零请求、卸载后不留监听器且之后的 pointerup 不提交、拖到边缘 rAF 持续滚动、重复日程被面板挡住。
|
|
12
|
+
- **它的第一批产出就是三个真问题**:
|
|
13
|
+
① **同一批次内拖动不提交**:提交前的「有没有落点」判断读的是 React state(`ghost`),而 `pointermove` 与 `pointerup` 落在同一批次时 state 还没刷新 → 明明拖了却不落盘。改为在 `dragRef` 上记同步标记。
|
|
14
|
+
② `requestAnimationFrame` / `cancelAnimationFrame` 按全局调用:浏览器里有,但模块作用域下应当显式走 `window.*`。
|
|
15
|
+
③ 三处 React key 警告(日期头、事件块、幽灵块、日列的子节点用了数组却没给 key)——dev 模式控制台一直在吵。
|
|
16
|
+
|
|
3
17
|
## 0.8.0(2026-09-19)
|
|
4
18
|
|
|
5
19
|
- **新增:周视图拖拽改期**。拖动事件块上下改时间、左右换天(15 分钟吸附);拖块底部 6px 改时长;拖动中显示幽灵落点,与已有日程重叠时描成黄色(允许重叠,只是提醒);拖到容器上下边缘自动滚动,长会议不用先滚再拖。
|
package/README.md
CHANGED
|
@@ -230,6 +230,8 @@ Nextcloud / 自定义 Basic 服务:检查账号、密码或服务要求的应
|
|
|
230
230
|
|
|
231
231
|
## 开发
|
|
232
232
|
|
|
233
|
+
- **UI 行为测试**:`test/render.test.mjs` 用 jsdom + 真 React 渲染周视图并派发真实事件(点击 / 拖动 / Esc / 卸载 / 边缘滚动)。改前端交互时它会替你点一遍;`pnpm test` 会一起跑。
|
|
234
|
+
|
|
233
235
|
```bash
|
|
234
236
|
pnpm install
|
|
235
237
|
pnpm test # 构建 + node --test
|
package/lib/client.js
CHANGED
|
@@ -47,7 +47,7 @@ const CSS = [
|
|
|
47
47
|
".dshc-sub{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}",
|
|
48
48
|
".dshc-spacer{flex:1}",
|
|
49
49
|
".dshc-btn{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);border-radius:9px;padding:5px 11px;font-size:12px;cursor:pointer;transition:background .12s,border-color .12s}",
|
|
50
|
-
".dshc-btn:hover:not(:disabled){background:var(--dsw-alias-
|
|
50
|
+
".dshc-btn:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 6%,transparent)}",
|
|
51
51
|
".dshc-btn:disabled{opacity:.5;cursor:not-allowed}",
|
|
52
52
|
".dshc-btn.primary{background:var(--dsw-alias-button-primary-fill,#2563eb);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
53
53
|
".dshc-btn.primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#1d4ed8)}",
|
|
@@ -60,7 +60,7 @@ const CSS = [
|
|
|
60
60
|
".dshc-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}",
|
|
61
61
|
".dshc-dow{text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding:2px 0 4px}",
|
|
62
62
|
".dshc-cell{display:block;width:100%;min-height:88px;box-sizing:border-box;border-radius:10px;padding:5px 5px 6px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));border:1px solid transparent;cursor:pointer;text-align:left;font:inherit;color:inherit;transition:background .12s,border-color .12s;overflow:hidden}",
|
|
63
|
-
".dshc-cell:hover{background:var(--dsw-alias-
|
|
63
|
+
".dshc-cell:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 7%,transparent)}",
|
|
64
64
|
".dshc-cell.out{opacity:.45}",
|
|
65
65
|
".dshc-cell.today{border-color:var(--dsw-alias-state-business-primary,#2563eb)}",
|
|
66
66
|
".dshc-daynum{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);margin:0 0 3px;display:flex;justify-content:space-between}",
|
|
@@ -75,7 +75,7 @@ const CSS = [
|
|
|
75
75
|
".dshc-week{display:grid;grid-template-columns:56px repeat(7,minmax(0,1fr));gap:4px;max-height:520px;overflow:auto}",
|
|
76
76
|
".dshc-hour{font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);text-align:right;padding-right:4px;height:44px}",
|
|
77
77
|
".dshc-daycol{position:relative;border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.07));min-height:44px}",
|
|
78
|
-
".dshc-daycol.today{background:var(--dsw-alias-
|
|
78
|
+
".dshc-daycol.today{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#2563eb) 7%,transparent)}",
|
|
79
79
|
".dshc-dayhead{position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,#fff);z-index:2;text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding-bottom:4px}",
|
|
80
80
|
".dshc-dayhead b{color:var(--dsw-alias-label-primary,#111);font-weight:600;font-size:13px}",
|
|
81
81
|
".dshc-slot{position:absolute;left:2px;right:2px;border-radius:7px;padding:3px 5px;font-size:11px;line-height:1.3;overflow:hidden;cursor:grab;background:var(--dsw-alias-state-business-primary,#2563eb);color:var(--dsw-alias-label-primary-foreground,#fff);border:0;text-align:left;touch-action:none;user-select:none}",
|
|
@@ -98,22 +98,22 @@ const CSS = [
|
|
|
98
98
|
".dshc-daylabel{display:flex;align-items:baseline;gap:8px;position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,#fff);padding:2px 0}",
|
|
99
99
|
".dshc-daylabel b{font-size:13px}",
|
|
100
100
|
".dshc-row{display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));cursor:pointer;border-left:3px solid var(--dsw-alias-state-business-primary,#2563eb)}",
|
|
101
|
-
".dshc-row:hover{background:var(--dsw-alias-
|
|
101
|
+
".dshc-row:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 7%,transparent)}",
|
|
102
102
|
".dshc-row.allday{border-left-color:var(--dsw-alias-state-success-primary,#16a34a)}",
|
|
103
103
|
".dshc-row.recurring{border-left-color:var(--dsw-alias-state-warn-primary,#f59e0b)}",
|
|
104
104
|
".dshc-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#6b7280);min-width:96px;font-size:12px;padding-top:1px}",
|
|
105
105
|
".dshc-main{flex:1;min-width:0}",
|
|
106
106
|
".dshc-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
107
107
|
".dshc-meta{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
108
|
-
".dshc-tag{display:inline-block;font-size:11px;padding:1px 6px;border-radius:999px;background:var(--dsw-alias-
|
|
108
|
+
".dshc-tag{display:inline-block;font-size:11px;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 7%,transparent);color:var(--dsw-alias-label-tertiary,#6b7280);margin-left:6px}",
|
|
109
109
|
".dshc-empty{padding:26px 18px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
110
110
|
".dshc-empty h3{color:var(--dsw-alias-label-primary,#111);margin:0 0 6px;font-size:14px}",
|
|
111
111
|
".dshc-empty code{background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));padding:1px 5px;border-radius:5px;font-size:12px}",
|
|
112
|
-
".dshc-alert{display:flex;gap:8px;align-items:flex-start;border-radius:10px;padding:8px 10px;font-size:12px;margin-bottom:10px;background:var(--dsw-alias-
|
|
112
|
+
".dshc-alert{display:flex;gap:8px;align-items:flex-start;border-radius:10px;padding:8px 10px;font-size:12px;margin-bottom:10px;background:color-mix(in srgb,var(--dsw-alias-label-primary,#111) 5%,transparent)}",
|
|
113
113
|
".dshc-alert.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 12%,transparent);color:var(--dsw-alias-state-error-primary,#dc2626)}",
|
|
114
114
|
".dshc-alert.warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 16%,transparent)}",
|
|
115
115
|
".dshc-alert.demo{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#2563eb) 12%,transparent)}",
|
|
116
|
-
".dshc-skel{height:88px;border-radius:10px;background:linear-gradient(90deg,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 25%,var(--dsw-alias-
|
|
116
|
+
".dshc-skel{height:88px;border-radius:10px;background:linear-gradient(90deg,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 25%,color-mix(in srgb,var(--dsw-alias-label-primary,#111) 10%,transparent) 37%,var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)) 63%);background-size:400% 100%;animation:dshc-shimmer 1.4s ease infinite}",
|
|
117
117
|
"@keyframes dshc-shimmer{0%{background-position:100% 0}100%{background-position:0 0}}",
|
|
118
118
|
".dshc-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-3,rgba(15,23,42,.42));display:flex;justify-content:flex-end;z-index:60}",
|
|
119
119
|
".dshc-drawer{width:min(420px,92vw);height:100%;background:var(--dsw-alias-bg-layer-1,#fff);border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08));padding:18px;overflow:auto;box-shadow:var(--dsw-elevation-prominent,0 10px 30px rgba(0,0,0,.2))}",
|
|
@@ -557,7 +557,7 @@ function Dlg(props) {
|
|
|
557
557
|
onClose: props.onClose,
|
|
558
558
|
title: props.title,
|
|
559
559
|
closeLabel: props.closeLabel || props.title,
|
|
560
|
-
children: props.children,
|
|
560
|
+
children: h("div", { "data-dshc-scope": "1" }, props.children),
|
|
561
561
|
footer: props.footer
|
|
562
562
|
});
|
|
563
563
|
}
|
|
@@ -834,9 +834,9 @@ function WeekView(props) {
|
|
|
834
834
|
}
|
|
835
835
|
if (commit !== false && moved === true && canEdit === true) {
|
|
836
836
|
const slot = drag.previewSlot === undefined ? drag.slot : drag.previewSlot;
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
if (
|
|
837
|
+
// 没有落点就不提交:比如纵向拖出本周(跨到下周)时幽灵没地方画,这时盲提交会让事件从视图消失。
|
|
838
|
+
// 用 dragRef 上的同步标记,而不是 React state(见 applyPreviewAt 的注释)。
|
|
839
|
+
if (drag.previewVisible === true) onReschedule(drag.event, rangeOfSlot(slot), drag.mode);
|
|
840
840
|
}
|
|
841
841
|
};
|
|
842
842
|
|
|
@@ -858,6 +858,9 @@ function WeekView(props) {
|
|
|
858
858
|
? resizeSlot(drag.slot, (dy / HOUR_PX) * 60, SNAP_MINUTES)
|
|
859
859
|
: slotFromDrag(drag.slot, { columns, pixelsY: dy, hourPx: HOUR_PX }, SNAP_MINUTES);
|
|
860
860
|
drag.previewSlot = slot;
|
|
861
|
+
// 可见性判断必须同步落在 dragRef 上:pointermove 与 pointerup 可能在同一批次里,
|
|
862
|
+
// 此时读 React state(ghost)拿到的是旧值,会导致「拖了却不提交」。
|
|
863
|
+
drag.previewVisible = days.some((day) => dayKey(day) === slot.dayKey);
|
|
861
864
|
setGhost({ uid: drag.uid, slot, conflicts: conflictsForSlot(events, slot, drag.uid).length });
|
|
862
865
|
};
|
|
863
866
|
|
|
@@ -868,7 +871,7 @@ function WeekView(props) {
|
|
|
868
871
|
*/
|
|
869
872
|
const autoScroll = useRef({ raf: 0, x: 0, y: 0 });
|
|
870
873
|
const stopAutoScroll = () => {
|
|
871
|
-
if (autoScroll.current.raf !== 0) { cancelAnimationFrame(autoScroll.current.raf); autoScroll.current.raf = 0; }
|
|
874
|
+
if (autoScroll.current.raf !== 0) { window.cancelAnimationFrame(autoScroll.current.raf); autoScroll.current.raf = 0; }
|
|
872
875
|
};
|
|
873
876
|
const stepAutoScroll = () => {
|
|
874
877
|
autoScroll.current.raf = 0;
|
|
@@ -883,7 +886,7 @@ function WeekView(props) {
|
|
|
883
886
|
if (delta === 0) return; // 指针离开边缘:循环自然停下,下次移动会再起
|
|
884
887
|
node.scrollTop += delta;
|
|
885
888
|
applyPreviewAt(autoScroll.current.x, y, drag); // 滚动后重算落点(含滚动补偿)
|
|
886
|
-
autoScroll.current.raf = requestAnimationFrame(stepAutoScroll);
|
|
889
|
+
autoScroll.current.raf = window.requestAnimationFrame(stepAutoScroll);
|
|
887
890
|
};
|
|
888
891
|
const ensureAutoScroll = (pointerEvent) => {
|
|
889
892
|
autoScroll.current.x = pointerEvent.clientX;
|
|
@@ -892,7 +895,7 @@ function WeekView(props) {
|
|
|
892
895
|
if (node === null || node === undefined) return;
|
|
893
896
|
const rect = node.getBoundingClientRect();
|
|
894
897
|
const nearEdge = pointerEvent.clientY < rect.top + 40 || pointerEvent.clientY > rect.bottom - 40;
|
|
895
|
-
if (nearEdge === true && autoScroll.current.raf === 0) autoScroll.current.raf = requestAnimationFrame(stepAutoScroll);
|
|
898
|
+
if (nearEdge === true && autoScroll.current.raf === 0) autoScroll.current.raf = window.requestAnimationFrame(stepAutoScroll);
|
|
896
899
|
};
|
|
897
900
|
|
|
898
901
|
const updatePreview = (pointerEvent) => {
|
|
@@ -929,7 +932,7 @@ function WeekView(props) {
|
|
|
929
932
|
window.removeEventListener("pointerup", onUp);
|
|
930
933
|
window.removeEventListener("pointercancel", onCancel);
|
|
931
934
|
window.removeEventListener("keydown", onKey, true);
|
|
932
|
-
if (autoScroll.current.raf !== 0) { cancelAnimationFrame(autoScroll.current.raf); autoScroll.current.raf = 0; }
|
|
935
|
+
if (autoScroll.current.raf !== 0) { window.cancelAnimationFrame(autoScroll.current.raf); autoScroll.current.raf = 0; }
|
|
933
936
|
window.removeEventListener("blur", onBlur);
|
|
934
937
|
dragRef.current = null; // 卸载时不能留着半截拖拽状态
|
|
935
938
|
};
|
|
@@ -1009,24 +1012,25 @@ function WeekView(props) {
|
|
|
1009
1012
|
if (suppressClick.current === true) { suppressClick.current = false; return; }
|
|
1010
1013
|
onPick(event);
|
|
1011
1014
|
}
|
|
1012
|
-
},
|
|
1015
|
+
},
|
|
1013
1016
|
h("div", { className: "dshc-slot-title" }, event.summary),
|
|
1014
1017
|
h("div", { className: "dshc-slot-time" }, hhmm(from) + "\u2013" + hhmm(to)),
|
|
1015
1018
|
canEdit === true ? h("div", { className: "dshc-resize", title: t("drag.resizeTitle"), onPointerDown: (e) => { e.stopPropagation(); beginDrag(event, e, "resize"); } }) : null
|
|
1016
|
-
|
|
1019
|
+
);
|
|
1017
1020
|
});
|
|
1018
1021
|
const ghostBlock = ghost !== null && ghost.slot.dayKey === key
|
|
1019
1022
|
? h("div", {
|
|
1020
1023
|
className: "dshc-slot dshc-ghost" + (ghost.conflicts > 0 ? " conflict" : ""),
|
|
1021
1024
|
style: Object.assign({ top: (ghost.slot.minutes * (HOUR_PX / 60)) + "px", height: Math.max(18, ghost.slot.durationMinutes * (HOUR_PX / 60)) + "px" }, laneStyle(0)),
|
|
1022
1025
|
key: "__ghost"
|
|
1023
|
-
},
|
|
1026
|
+
},
|
|
1024
1027
|
h("div", { className: "dshc-slot-title" }, ghost.conflicts > 0 ? t("drag.conflict", { n: ghost.conflicts }) + " · " + t("drag.keep") : t("drag.drop")),
|
|
1025
1028
|
h("div", { className: "dshc-slot-time" }, pad2(Math.floor(ghost.slot.minutes / 60)) + ":" + pad2(ghost.slot.minutes % 60) + " \u2192 " + pad2(Math.floor((ghost.slot.minutes + ghost.slot.durationMinutes) / 60)) + ":" + pad2((ghost.slot.minutes + ghost.slot.durationMinutes) % 60))
|
|
1026
|
-
|
|
1029
|
+
)
|
|
1027
1030
|
: null;
|
|
1028
|
-
|
|
1029
|
-
|
|
1031
|
+
// 可变参数:数组子节点要求 key,而这两块在这里各只出现一次。
|
|
1032
|
+
return h("div", { key, style: { minWidth: 0 } },
|
|
1033
|
+
h("div", { className: "dshc-dayhead" }, h("b", null, String(day.getDate())), " ", relativeDay(day)),
|
|
1030
1034
|
h("div", {
|
|
1031
1035
|
className: "dshc-daycol" + (key === today ? " today" : ""),
|
|
1032
1036
|
"data-day": key,
|
|
@@ -1039,16 +1043,17 @@ function WeekView(props) {
|
|
|
1039
1043
|
},
|
|
1040
1044
|
title: t("action.new")
|
|
1041
1045
|
}, blocks.concat(ghostBlock === null ? [] : [ghostBlock]))
|
|
1042
|
-
|
|
1046
|
+
);
|
|
1043
1047
|
});
|
|
1044
1048
|
|
|
1045
|
-
|
|
1049
|
+
// 可变参数而不是数组:数组子节点要求 key,而这三个都只在 WeekView 里出现一次。
|
|
1050
|
+
return h("div", null,
|
|
1046
1051
|
allDay.length > 0
|
|
1047
1052
|
? h("div", { style: { marginBottom: "8px" } }, allDay.map((event) => h(Row, { key: event.uid, event, onClick: onPick })))
|
|
1048
1053
|
: null,
|
|
1049
1054
|
h("div", { className: "dshc-week", ref: scroller }, [hoursColumn].concat(dayColumns)),
|
|
1050
1055
|
canEdit === true ? h("div", { className: "dshc-draghint" }, t("drag.hint")) : null
|
|
1051
|
-
|
|
1056
|
+
);
|
|
1052
1057
|
}
|
|
1053
1058
|
|
|
1054
1059
|
/** 议程视图:从今天起按天分组,最像「接下来要干什么」。 */
|
|
@@ -1500,6 +1505,82 @@ function ConnectionForm(props) {
|
|
|
1500
1505
|
|
|
1501
1506
|
// ───────────────────────────── 主面板 ─────────────────────────────
|
|
1502
1507
|
|
|
1508
|
+
// ───────────────────────────── 浮层 Esc 的模块级栈 ─────────────────────────────
|
|
1509
|
+
// 一个页面可能同时有多个 CalendarPanel(设置页一节 + 对话页悬浮面板)。如果各自往
|
|
1510
|
+
// document 挂捕获监听,stopPropagation 拦不住同阶段的其他监听 —— 一次 Esc 会连关
|
|
1511
|
+
// 两层;用全局 querySelector 兜底也会被另一个面板的浮层误触发。这里共用一个捕获
|
|
1512
|
+
// 监听,按「焦点所在 root / 最近打开的层」只关一层。
|
|
1513
|
+
const LAYER_ESCAPE_STACK = [];
|
|
1514
|
+
let layerEscapeListener = null;
|
|
1515
|
+
|
|
1516
|
+
function panelRootOf(node) {
|
|
1517
|
+
if (node === null || node === undefined || typeof node.closest !== "function") return null;
|
|
1518
|
+
return node.closest(".dshc-root");
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** 目标是否位于别的宿主浮层内(那类浮层会自己处理 Esc)。 */
|
|
1522
|
+
function isForeignHostLayer(node) {
|
|
1523
|
+
if (node === null || node === undefined || typeof node.closest !== "function") return false;
|
|
1524
|
+
const layer = node.closest('[role="dialog"], [role="alertdialog"]');
|
|
1525
|
+
if (layer === null) return false;
|
|
1526
|
+
if (layer.closest(".dshc-root") !== null) return false;
|
|
1527
|
+
if (layer.querySelector('[data-dshc-scope="1"]') !== null) return false;
|
|
1528
|
+
return true;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** 是否有别的宿主浮层盖在上面:有就把 Esc 让给宿主,别关下面的日历层。 */
|
|
1532
|
+
function hasForeignHostDialog() {
|
|
1533
|
+
const dialogs = document.querySelectorAll('[role="dialog"], [role="alertdialog"]');
|
|
1534
|
+
for (let i = 0; i < dialogs.length; i++) {
|
|
1535
|
+
const layer = dialogs[i];
|
|
1536
|
+
if (layer.closest(".dshc-root") !== null) continue;
|
|
1537
|
+
if (layer.querySelector('[data-dshc-scope="1"]') !== null) continue;
|
|
1538
|
+
if (layer.hidden === true || layer.getAttribute("aria-hidden") === "true") continue;
|
|
1539
|
+
if (typeof window !== "undefined" && window !== null && typeof window.getComputedStyle === "function") {
|
|
1540
|
+
const style = window.getComputedStyle(layer);
|
|
1541
|
+
if (style.display === "none" || style.visibility === "hidden") continue;
|
|
1542
|
+
}
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
return false;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function registerLayerEscape(root, closeTop) {
|
|
1549
|
+
const entry = { root, closeTop };
|
|
1550
|
+
LAYER_ESCAPE_STACK.push(entry);
|
|
1551
|
+
if (layerEscapeListener === null) {
|
|
1552
|
+
layerEscapeListener = (event) => {
|
|
1553
|
+
if (event.key !== "Escape" || event.isComposing === true) return;
|
|
1554
|
+
const target = event.target;
|
|
1555
|
+
// 上层有别的宿主浮层(不是本插件渲染的)时,Esc 归它;
|
|
1556
|
+
// 普通宿主元素(不在任何浮层里)仍然兜底关我们自己的层。
|
|
1557
|
+
if (hasForeignHostDialog() || isForeignHostLayer(target)) return;
|
|
1558
|
+
const root = panelRootOf(target);
|
|
1559
|
+
let owner = null;
|
|
1560
|
+
if (root !== null) {
|
|
1561
|
+
for (let i = LAYER_ESCAPE_STACK.length - 1; i >= 0; i--) {
|
|
1562
|
+
const candidate = LAYER_ESCAPE_STACK[i];
|
|
1563
|
+
if (candidate.root !== null && candidate.root.contains(target)) { owner = candidate; break; }
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
if (owner === null) owner = LAYER_ESCAPE_STACK[LAYER_ESCAPE_STACK.length - 1] || null;
|
|
1567
|
+
if (owner === null) return;
|
|
1568
|
+
event.preventDefault();
|
|
1569
|
+
event.stopPropagation();
|
|
1570
|
+
owner.closeTop();
|
|
1571
|
+
};
|
|
1572
|
+
document.addEventListener("keydown", layerEscapeListener, true);
|
|
1573
|
+
}
|
|
1574
|
+
return () => {
|
|
1575
|
+
const index = LAYER_ESCAPE_STACK.indexOf(entry);
|
|
1576
|
+
if (index >= 0) LAYER_ESCAPE_STACK.splice(index, 1);
|
|
1577
|
+
if (LAYER_ESCAPE_STACK.length === 0 && layerEscapeListener !== null) {
|
|
1578
|
+
document.removeEventListener("keydown", layerEscapeListener, true);
|
|
1579
|
+
layerEscapeListener = null;
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1503
1584
|
function CalendarPanel(props) {
|
|
1504
1585
|
const compact = props.compact === true;
|
|
1505
1586
|
const [view, setView] = useState(loadStoredView);
|
|
@@ -1516,6 +1597,48 @@ function CalendarPanel(props) {
|
|
|
1516
1597
|
const [configured, setConfigured] = useState(null);
|
|
1517
1598
|
const [conn, setConn] = useState(null);
|
|
1518
1599
|
const [connOpen, setConnOpen] = useState(false);
|
|
1600
|
+
|
|
1601
|
+
const layerOpen = draft !== null || selected !== null || connOpen === true;
|
|
1602
|
+
const rootRef = useRef(null);
|
|
1603
|
+
|
|
1604
|
+
/** 让 Launcher 知道本面板里有没有浮层:宿主 Portal 的 Esc 不能被面板级监听抢走。 */
|
|
1605
|
+
useEffect(() => {
|
|
1606
|
+
const notify = typeof props.onLayerChange === "function" ? props.onLayerChange : null;
|
|
1607
|
+
if (notify !== null) notify(layerOpen);
|
|
1608
|
+
return () => { if (notify !== null) notify(false); };
|
|
1609
|
+
}, [layerOpen]);
|
|
1610
|
+
|
|
1611
|
+
/** 面板内浮层的打开顺序:Esc 关最后打开的那个,而不是按类型硬编码。 */
|
|
1612
|
+
const layerOrderRef = useRef([]);
|
|
1613
|
+
useEffect(() => {
|
|
1614
|
+
const openKeys = [];
|
|
1615
|
+
if (selected !== null) openKeys.push("selected");
|
|
1616
|
+
if (draft !== null) openKeys.push("draft");
|
|
1617
|
+
if (connOpen === true) openKeys.push("conn");
|
|
1618
|
+
const kept = layerOrderRef.current.filter((key) => openKeys.indexOf(key) !== -1);
|
|
1619
|
+
const added = openKeys.filter((key) => kept.indexOf(key) === -1);
|
|
1620
|
+
layerOrderRef.current = kept.concat(added);
|
|
1621
|
+
}, [draft, connOpen, selected]);
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Esc 分层关闭:有浮层时只关最上面一层(表单/连接设置 > 详情抽屉)。
|
|
1625
|
+
* 监听走模块级的层栈 —— 多个 CalendarPanel 并存时一次 Esc 只关一层;
|
|
1626
|
+
* 焦点在上层宿主 Portal 里时直接让行,交给宿主自己的 Esc 语义。
|
|
1627
|
+
*/
|
|
1628
|
+
useEffect(() => {
|
|
1629
|
+
if (layerOpen !== true) return () => {};
|
|
1630
|
+
const root = rootRef.current;
|
|
1631
|
+
if (root === null) return () => {};
|
|
1632
|
+
const closeTop = () => {
|
|
1633
|
+
const order = layerOrderRef.current;
|
|
1634
|
+
const top = order.length === 0 ? null : order[order.length - 1];
|
|
1635
|
+
if (top === "draft") { setDraft(null); setFormError(null); }
|
|
1636
|
+
else if (top === "conn") setConnOpen(false);
|
|
1637
|
+
else if (top === "selected") { setSelected(null); setConfirmDelete(false); }
|
|
1638
|
+
};
|
|
1639
|
+
return registerLayerEscape(root, closeTop);
|
|
1640
|
+
}, [draft, selected, connOpen]);
|
|
1641
|
+
|
|
1519
1642
|
/** 乐观改期:uid → { start, end }。等服务器回读到同样的时间再撤掉,避免闪回。 */
|
|
1520
1643
|
const [overrides, setOverrides] = useState({});
|
|
1521
1644
|
const [undo, setUndo] = useState(null);
|
|
@@ -1737,7 +1860,7 @@ function CalendarPanel(props) {
|
|
|
1737
1860
|
const openEvent = (event) => { setConfirmDelete(false); setSelected(event); };
|
|
1738
1861
|
const createAt = (day, hour) => setDraft(draftAt(day, hour));
|
|
1739
1862
|
|
|
1740
|
-
return h("div", { className: "dshc-root" }, [
|
|
1863
|
+
return h("div", { ref: rootRef, className: "dshc-root" }, [
|
|
1741
1864
|
h("div", { className: "dshc-shell" }, [
|
|
1742
1865
|
banner !== null ? h("div", { className: "dshc-alert demo", role: "status", "aria-live": "polite" }, banner) : null,
|
|
1743
1866
|
undo !== null
|
|
@@ -1812,12 +1935,18 @@ function CalendarSection() {
|
|
|
1812
1935
|
/** 对话页右下角的小按钮 + 悬浮面板(自己挂 root,不占用宿主槽位)。 */
|
|
1813
1936
|
function Launcher() {
|
|
1814
1937
|
const [open, setOpen] = useState(false);
|
|
1938
|
+
const [layerOpen, setLayerOpen] = useState(false);
|
|
1815
1939
|
useEffect(() => {
|
|
1816
1940
|
if (open !== true) return () => {};
|
|
1817
|
-
const onKey = (event) => {
|
|
1941
|
+
const onKey = (event) => {
|
|
1942
|
+
if (event.key !== "Escape" || event.isComposing === true) return;
|
|
1943
|
+
// 浮层交给 CalendarPanel 的层栈;宿主 Portal 的 Esc 交给宿主组件。
|
|
1944
|
+
if (layerOpen === true) return;
|
|
1945
|
+
setOpen(false);
|
|
1946
|
+
};
|
|
1818
1947
|
document.addEventListener("keydown", onKey);
|
|
1819
1948
|
return () => document.removeEventListener("keydown", onKey);
|
|
1820
|
-
}, [open]);
|
|
1949
|
+
}, [open, layerOpen]);
|
|
1821
1950
|
const calendarIcon = h("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" }, [
|
|
1822
1951
|
h("rect", { key: "r", x: 3, y: 5, width: 18, height: 16, rx: 3 }),
|
|
1823
1952
|
h("path", { key: "l", d: "M3 10h18M8 3v4M16 3v4" })
|
|
@@ -1830,7 +1959,7 @@ function Launcher() {
|
|
|
1830
1959
|
"aria-label": t("nav.title"),
|
|
1831
1960
|
onClick: () => setOpen((value) => !value)
|
|
1832
1961
|
}, calendarIcon),
|
|
1833
|
-
open === true ? h("div", { className: "dshc-float" }, h(CalendarPanel, { compact: true })) : null
|
|
1962
|
+
open === true ? h("div", { className: "dshc-float" }, h(CalendarPanel, { compact: true, onLayerChange: setLayerOpen })) : null
|
|
1834
1963
|
]);
|
|
1835
1964
|
}
|
|
1836
1965
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-calendar",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "DSH 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth2、iCloud、Nextcloud 与自建服务。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -60,6 +60,9 @@
|
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^24.0.0",
|
|
63
|
+
"jsdom": "^30.1.0",
|
|
64
|
+
"react": "^19.3.0",
|
|
65
|
+
"react-dom": "^19.3.0",
|
|
63
66
|
"typescript": "^5.6.0"
|
|
64
67
|
},
|
|
65
68
|
"repository": {
|