dsh-calendar 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0(2026-09-19)
4
+
5
+ - **新增:周视图拖拽改期**。拖动事件块上下改时间、左右换天(15 分钟吸附);拖块底部 6px 改时长;拖动中显示幽灵落点,与已有日程重叠时描成黄色(允许重叠,只是提醒);拖到容器上下边缘自动滚动,长会议不用先滚再拖。
6
+ - **新增:键盘改期**。Tab 聚焦后 ↑/↓ 挪 15 分钟、Shift+↑/↓ 挪 1 小时、←/→ 挪一天、Enter 打开详情、拖动中 Esc 取消 —— 没有鼠标也能完成一次改期。
7
+ - **新增:乐观更新 + 失败回滚 + 5 秒撤销**。本地先动画面再落盘,失败自动回滚并把服务器原话弹出来;成功后提示「已改期 · 撤销」。
8
+ - **重复日程与超过 24 小时的日程暂不支持拖动改期**(提示去详情里编辑):CalDAV 里整个系列是一个对象,而 update 只能改 master 的 DTSTART —— 独立评审实测「把某次实例的目标时间发过去」等于**重锚系列起点**(早于它的实例全丢、COUNT/UNTIL 语义被改、撤销也回不到原系列)。宁可不做,也不做半个数据事故。
9
+ - 几何算法全部抽成纯函数(`snapMinutes` / `slotFromEvent` / `moveSlot` / `slotFromDrag` / `resizeSlot` / `rangeOfSlot` / `conflictsForSlot` / `isRecurring`),新增 12 条单测**直接测发布文件里的实现**(跨天、跨月、越午夜、吸附、钳制、冲突判定、拖拽接线契约)。
10
+ - 交互细节(两轮独立评审后补的洞):5px 阈值区分点击与拖动;**点事件不再冒泡去触发「新建日程」**(slot 是日列的子节点,此前点一下会同时弹详情与新建表单,拖完松手也会弹);只认发起拖动的 `pointerId`;window 监听器**只在挂载时注册一次**并通过 ref 调最新实现(此前按渲染闭包挂/摘,卸载后残留的 `pointerup` 会替用户提交一次改期);`buttons === 0` / 窗口失焦 / `Esc`(捕获阶段,不顺手关掉浮层面板)都回到原位;边缘自动滚动改用 **rAF 持续滚**并在滚动后重算落点。
11
+ - 落盘:改期请求**按 uid 串行 + 合并**(连按方向键只落最后一次)、跳过无效位移、带 15 秒超时;失败回滚后还会 reload 对账(超时可能已经写进去了)。
12
+ - 位移改成**日历日 + 墙钟分钟**运算:此前按毫秒加,DST 切换日会偏一小时(评审用 `TZ=America/New_York` 复现,中国时区看不见);日末钳制也吸附到 15 分钟网格。
13
+ - **修一个渲染即崩的 P0**:焦点恢复的 `useEffect` 依赖数组引用了声明在后面的 `data`,TDZ 直接抛错、整个面板打不开(纯函数测试测不到,评审渲染才发现)。
14
+ - 明确取舍:拖动越过午夜**滚到次日**(与 Google 日历一致,也让幽灵块按 `slot.dayKey` 的日列渲染自洽),而不是硬钳在当天最后一刻。
15
+
16
+ ## 0.7.0(2026-09-19)
17
+
18
+ - **新增**:面板**记住上次用的视图**(月/周/议程,存 localStorage;隐私模式写不进去就退回「月」),下次打开不再固定跳回月视图。
19
+ - **新增**:周视图**默认落在 07:00**(半夜那几格基本没人看,但需要时还能往上滚,不裁剪任何事件)。
20
+ - **适配窄容器**:面板在设置页里比对话页浮层窄得多,现在用容器查询自适应 —— 窄的时候隐藏芯片里的时间前缀(让日程标题露出来)、隐藏重复的面板标题、收紧工具栏间距。
21
+ - **修复**:月视图的格子由 `<button>` 改为 `<div role="button" tabIndex=0>`。按钮不能嵌套按钮(芯片本身是按钮),一旦同格有多条日程,浏览器会把外层按钮提前闭合、把芯片挤成网格的兄弟节点;现在结构合法,键盘 Enter/空格仍可新建,并加了回归测试(月视图里不允许出现 `button`)。
22
+
3
23
  ## 0.6.0(2026-09-19)
4
24
 
5
25
  - **新增设置页日历面板**:月 / 周 / 议程三种视图;点日程开右侧详情(时间、时长、重复规则的人话、地点、备注、uid),可直接改标题时间、加重复、删除(二次确认);新建支持全天 / 地点 / 备注 / RRULE;保存前做冲突检测。
package/README.en.md CHANGED
@@ -171,7 +171,8 @@ The stable event identifier `uid` is the CalDAV href (full object URL); `calenda
171
171
 
172
172
  Once installed, DSH grows a「Calendar」section in Settings; the chat page also gets a small 📅 button in the bottom-right that opens the same panel (non-modal, Esc closes).
173
173
 
174
- - **Three views**: month (6×7 grid, today highlighted, click a cell to create), week (24-hour time grid, overlapping events side by side) and agenda (grouped by day).
174
+ - **Three views**: month (6×7 grid, today highlighted, click a cell to create), week (24-hour time grid starting at 07:00, overlapping events side by side) and agenda (grouped by day). The panel **remembers the view you used last**.
175
+ - **Drag to reschedule**: in the week view drag an event up/down to change the time, left/right to change the day (15-minute snapping), and drag its bottom edge to change the duration. A drop preview shows where it lands and turns yellow when it overlaps something (overlaps are allowed, it is only a warning). Keyboard works too: `↑/↓` 15 minutes, `Shift+↑/↓` an hour, `←/→` a day, `Esc` cancels mid-drag. Every move offers a 5-second undo and rolls back (then re-reads the server) if the request fails. **Recurring events and events longer than 24 hours cannot be dragged yet** — edit those in the details drawer.
175
176
  - **Click to edit**: open any event in the right-hand drawer — time, duration, a human-readable repeat rule (`FREQ=WEEKLY;COUNT=6` → "weekly (6 times)"), location, notes, one-click uid copy — then edit or delete (deletion asks twice).
176
177
  - **Create**: title, date, start/end, all-day, location, notes, repeat (daily/weekly/monthly/yearly, or a raw RRULE).
177
178
  - **Conflict notice**: before saving, overlapping events are listed and you are asked whether to continue.
package/README.md CHANGED
@@ -197,7 +197,8 @@ Nextcloud / 自定义 Basic 服务:检查账号、密码或服务要求的应
197
197
 
198
198
  装好后,DSH 的「设置 → 日历」里会多出一节面板;对话页右下角还有一个 📅 小按钮,点开是同一个面板(非模态,Esc 关闭)。
199
199
 
200
- - **三种视图**:月(6×7 网格,今天高亮,点格子直接新建)、周(24 小时时间网格,重叠日程并排显示)、议程(按天分组,最像「接下来要干什么」)。
200
+ - **三种视图**:月(6×7 网格,今天高亮,点格子直接新建)、周(24 小时时间网格,重叠日程并排显示,默认落在 07:00)、议程(按天分组,最像「接下来要干什么」)。面板会**记住你上次用的视图**。
201
+ - **直接拖就能改期**:周视图里拖动日程上下改时间、左右换天(15 分钟吸附),拖块底部改时长;拖动中会显示落点,与已有日程重叠时描成黄色(允许重叠,只是提醒)。键盘也能改:`↑/↓` 挪 15 分钟、`Shift+↑/↓` 挪 1 小时、`←/→` 挪一天,拖动中 `Esc` 取消。改完有 5 秒可撤销,落盘失败会自动回滚并重新对账。**重复日程与超过 24 小时的日程暂不支持拖动改期**(请在详情里编辑)。
201
202
  - **点开就能改**:点任意日程打开右侧详情 —— 时间、时长、重复规则的人话(`FREQ=WEEKLY;COUNT=6` → 「每周(共 6 次)」)、地点、备注、uid 一键复制;接着「编辑」或「删除」(删除要二次确认)。
202
203
  - **新建**:标题、日期、开始/结束、全天、地点、备注、重复(每天/每周/每月/每年,或直接写 RRULE)。
203
204
  - **冲突提醒**:保存前若与已有日程重叠,会把重叠的几条列出来问你是否继续。
package/lib/client.js CHANGED
@@ -20,10 +20,28 @@ const VIEWS = ["month", "week", "agenda"];
20
20
 
21
21
  const WEEKDAYS_ZH = ["一", "二", "三", "四", "五", "六", "日"];
22
22
 
23
+ /** 上次用的视图:用户下次打开面板不该又跳回「月」。localStorage 不可用(隐私模式)就退回 default。 */
24
+ const VIEW_STORAGE_KEY = "dsh-calendar:view";
25
+ function loadStoredView() {
26
+ try {
27
+ const stored = window.localStorage.getItem(VIEW_STORAGE_KEY);
28
+ return VIEWS.indexOf(stored) === -1 ? "month" : stored;
29
+ } catch (error) {
30
+ return "month";
31
+ }
32
+ }
33
+ function storeView(view) {
34
+ try { window.localStorage.setItem(VIEW_STORAGE_KEY, view); } catch (error) { /* 写不进去就算了 */ }
35
+ }
36
+
23
37
  /* @ui-css-start */
24
38
  const CSS = [
25
39
  ".dshc-root{color:var(--dsw-alias-label-primary,#111);font-size:13px;line-height:1.5}",
26
- ".dshc-shell{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08));border-radius:14px;padding:14px 16px 16px}",
40
+ // 面板可能出现在两个宽度差很大的容器里(对话页浮层 ~760px、设置页更窄)。
41
+ // 用容器查询按「面板自己的宽度」适配:窄的时候藏掉时间前缀与重复的标题,
42
+ // 让芯片至少能把日程标题露出来。
43
+ ".dshc-shell{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08));border-radius:14px;padding:14px 16px 16px;container-type:inline-size}",
44
+ "@container (max-width: 620px){.dshc-title{display:none}.dshc-chip .t{display:none}.dshc-toolbar{gap:6px}.dshc-monthhead{min-width:0}}",
27
45
  ".dshc-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px}",
28
46
  ".dshc-title{font-size:15px;font-weight:600;margin:0}",
29
47
  ".dshc-sub{color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}",
@@ -41,7 +59,7 @@ const CSS = [
41
59
  ".dshc-monthhead{text-align:center;font-size:13px;font-weight:600;min-width:104px}",
42
60
  ".dshc-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}",
43
61
  ".dshc-dow{text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af);padding:2px 0 4px}",
44
- ".dshc-cell{min-height:88px;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}",
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}",
45
63
  ".dshc-cell:hover{background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.07))}",
46
64
  ".dshc-cell.out{opacity:.45}",
47
65
  ".dshc-cell.today{border-color:var(--dsw-alias-state-business-primary,#2563eb)}",
@@ -60,7 +78,19 @@ const CSS = [
60
78
  ".dshc-daycol.today{background:var(--dsw-alias-bg-mask-3,rgba(37,99,235,.06))}",
61
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}",
62
80
  ".dshc-dayhead b{color:var(--dsw-alias-label-primary,#111);font-weight:600;font-size:13px}",
63
- ".dshc-slot{position:absolute;left:2px;right:2px;border-radius:7px;padding:3px 5px;font-size:11px;line-height:1.3;overflow:hidden;cursor:pointer;background:var(--dsw-alias-state-business-primary,#2563eb);color:var(--dsw-alias-label-primary-foreground,#fff);border:0;text-align:left}",
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}",
82
+ ".dshc-slot:active{cursor:grabbing}",
83
+ ".dshc-slot.dragging{opacity:.35}",
84
+ ".dshc-slot-title{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
85
+ ".dshc-slot-time{opacity:.78;white-space:nowrap}",
86
+ ".dshc-resize{position:absolute;left:0;right:0;bottom:0;height:6px;cursor:ns-resize;border-bottom-left-radius:7px;border-bottom-right-radius:7px}",
87
+ ".dshc-resize:hover{background:rgba(255,255,255,.35)}",
88
+ ".dshc-ghost{background:transparent !important;border:1px dashed var(--dsw-alias-state-business-primary,#2563eb);color:var(--dsw-alias-label-primary,#111);z-index:3;pointer-events:none;display:flex;flex-direction:column;justify-content:center}",
89
+ ".dshc-ghost.conflict{border-color:var(--dsw-alias-state-warn-primary,#f59e0b);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 18%,transparent) !important}",
90
+ ".dshc-draghint{margin-top:6px;font-size:11px;color:var(--dsw-alias-label-tertiary,#9ca3af)}",
91
+ ".dshc-confirmbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 14%,transparent);border-radius:10px;padding:8px 10px;font-size:12px;margin-bottom:10px}",
92
+ ".dshc-undo{display:flex;align-items:center;gap:10px;background:var(--dsw-alias-label-primary,#111);color:var(--dsw-alias-label-primary-foreground,#fff);border-radius:10px;padding:8px 12px;font-size:12px;margin-bottom:10px}",
93
+ ".dshc-undo-btn{border:1px solid currentColor;background:transparent;color:inherit;border-radius:7px;padding:2px 8px;font-size:12px;cursor:pointer}",
64
94
  ".dshc-slot.allday{background:var(--dsw-alias-state-success-primary,#16a34a)}",
65
95
  ".dshc-slot.recurring{background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1f2937}",
66
96
  ".dshc-agenda{display:flex;flex-direction:column;gap:14px;max-height:560px;overflow:auto;padding-right:4px}",
@@ -136,6 +166,17 @@ const UI = {
136
166
  "count.events": "{n} 个日程", "count.occurrences": "含 {n} 次重复展开",
137
167
  "format.duration.hm": "{h} 小时 {m} 分", "format.duration.h": "{h} 小时", "format.duration.m": "{m} 分钟", "format.duration.zero": "0 分钟",
138
168
  "format.allday": "全天",
169
+ "drag.hint": "提示:周视图里可以直接拖动日程改期 —— 上下拖改时间、左右拖换天,拖块底部改时长;键盘 ↑/↓ 挪 15 分钟、Shift+↑/↓ 挪 1 小时、←/→ 挪一天。",
170
+ "drag.drop": "松手即改到这里",
171
+ "drag.conflict": "与 {n} 个日程重叠",
172
+ "drag.keep": "仍可松手",
173
+ "drag.moveTitle": "可拖动改期",
174
+ "drag.resizeTitle": "拖动改时长",
175
+ "move.recurringUnsupported": "重复日程暂不支持拖动改期:请在详情里用「编辑」(拖动会重锚系列起点,早于该次的实例会消失)。",
176
+ "move.tooLong": "超过 24 小时的日程暂不支持拖动改期。",
177
+ "move.saved": "已改期",
178
+ "move.undo": "撤销",
179
+ "move.failed": "改期失败:{message}",
139
180
  "conn.button": "连接设置",
140
181
  "conn.title": "CalDAV 连接设置",
141
182
  "conn.intro": "填完点「测试连接」,通了才会保存。密码只存在本机(不进日志、不进导出)。",
@@ -220,6 +261,17 @@ const UI = {
220
261
  "count.events": "{n} events", "count.occurrences": "{n} expanded occurrences",
221
262
  "format.duration.hm": "{h}h {m}m", "format.duration.h": "{h}h", "format.duration.m": "{m}m", "format.duration.zero": "0m",
222
263
  "format.allday": "All day",
264
+ "drag.hint": "Tip: drag events in the week view to reschedule — up/down changes the time, left/right changes the day, the bottom edge changes the duration. Keyboard: ↑/↓ moves 15 min, Shift+↑/↓ an hour, ←/→ a day.",
265
+ "drag.drop": "release to drop here",
266
+ "drag.conflict": "overlaps {n} event(s)",
267
+ "drag.keep": "you can still drop it",
268
+ "drag.moveTitle": "drag to reschedule",
269
+ "drag.resizeTitle": "drag to resize",
270
+ "move.recurringUnsupported": "Recurring events cannot be dragged yet — use Edit in the details drawer (dragging would re-anchor the series and drop earlier occurrences).",
271
+ "move.tooLong": "Events longer than 24 hours cannot be dragged yet.",
272
+ "move.saved": "Rescheduled",
273
+ "move.undo": "Undo",
274
+ "move.failed": "Could not reschedule: {message}",
223
275
  "conn.button": "Connection",
224
276
  "conn.title": "CalDAV connection",
225
277
  "conn.intro": "Fill this in and hit「Test connection」— nothing is saved until it works. The password stays on this machine only (no logs, no exports).",
@@ -330,8 +382,13 @@ function t(key, params) {
330
382
  }
331
383
 
332
384
  /** 调插件自己的路由;错误码原样带出,方便上层区分「没配置」与「服务器拒绝」。 */
385
+ const API_TIMEOUT_MS = 15000;
386
+
333
387
  async function api(action, payload) {
334
388
  const init = { credentials: "same-origin", method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ action }, payload)) };
389
+ // 请求必须有上限:挂起的 Promise 会让乐观覆盖一直盖着、撤销条永不出现,
390
+ // 表现就是「界面改了、服务器没改」而用户以为成功。超时按失败处理(调用方会回滚并对账)。
391
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") init.signal = AbortSignal.timeout(API_TIMEOUT_MS);
335
392
  const res = await fetch(ROUTE, init);
336
393
  let body = null;
337
394
  try { body = await res.json(); } catch (e) { body = null; }
@@ -528,6 +585,100 @@ async function copyText(text) {
528
585
  return false;
529
586
  }
530
587
 
588
+ // ───────────────────────────── 拖拽/键盘改期的几何 ─────────────────────────────
589
+
590
+ // 这一组是本轮唯一的「算法」:拖动、缩放手柄、键盘微调都走它们。它们不碰 DOM、不认 React,
591
+ // 所以跨天/跨月/DST/吸附边界这些真正容易错的地方能被单测直接盯住(见 test/schedule.test.mjs)。
592
+
593
+ /** 全天事件与定时事件共用一个「槽位」概念:某天 + 当天第几分钟 + 时长。 */
594
+ const SNAP_MINUTES = 15;
595
+ const RESIZE_MIN_MINUTES = 15;
596
+
597
+ function snapMinutes(value, step) {
598
+ const unit = typeof step === "number" && step > 0 ? step : SNAP_MINUTES;
599
+ return Math.round(value / unit) * unit;
600
+ }
601
+
602
+ /** 事件 → 槽位(按浏览器本地墙钟,显示与拖拽都以用户看到的为准)。 */
603
+ function slotFromEvent(event) {
604
+ const start = startDate(event);
605
+ const end = endDate(event);
606
+ const minutes = start.getHours() * 60 + start.getMinutes();
607
+ const durationMinutes = Math.max(RESIZE_MIN_MINUTES, Math.round((end - start) / 60000));
608
+ return { dayKey: dayKey(start), minutes, durationMinutes, allDay: event.allDay === true };
609
+ }
610
+
611
+ /**
612
+ * 槽位 + 位移 → 新槽位。
613
+ * 位移以「天 + 分钟」给(拖动时由像素换算),跨天/跨月/跨年都靠时间戳自然进位,
614
+ * 最后把开始时间吸附到 15 分钟并钳在当天 00:00 与「当天结束前还放得下整个时长」之间。
615
+ */
616
+ function moveSlot(slot, deltaDays, deltaMinutes, step) {
617
+ // 分钟先合成「当天内 + 跨几天」,再用日历日相加 —— 不能用「天 × 1440 毫秒」当瞬时位移:
618
+ // DST 切换日一天是 23 或 25 小时,按毫秒加会整体偏一小时(评审查出来的,中国时区看不见)。
619
+ const total = slot.minutes + (deltaMinutes || 0);
620
+ const dayShift = Math.floor(total / 1440);
621
+ const minutes = total - dayShift * 1440;
622
+ const base = new Date(slot.dayKey + "T00:00:00");
623
+ const target = addDays(base, (deltaDays || 0) + dayShift);
624
+ const unit = typeof step === "number" && step > 0 ? step : SNAP_MINUTES;
625
+ // 日末钳制也要落在网格上:1440 − 时长不保证是 15 的倍数(50 分钟 → 23:10 这种脏值)。
626
+ const latestStart = Math.max(0, Math.floor((24 * 60 - slot.durationMinutes) / unit) * unit);
627
+ const snapped = Math.max(0, Math.min(snapMinutes(minutes, unit), latestStart));
628
+ return { dayKey: dayKey(target), minutes: snapped, durationMinutes: slot.durationMinutes, allDay: slot.allDay };
629
+ }
630
+
631
+ /** 拖拽像素位移 → 槽位位移(横向一格 = 一天,纵向按小时像素换算成分钟)。 */
632
+ function slotFromDrag(slot, drag, step) {
633
+ const hourPx = typeof drag.hourPx === "number" && drag.hourPx > 0 ? drag.hourPx : 44;
634
+ const columns = Math.round(drag.columns || 0);
635
+ const unit = typeof step === "number" && step > 0 ? step : SNAP_MINUTES;
636
+ const rawMinutes = ((drag.pixelsY || 0) / hourPx) * 60;
637
+ const deltaMinutes = Math.round(rawMinutes / unit) * unit;
638
+ return moveSlot(slot, columns, deltaMinutes, unit);
639
+ }
640
+
641
+ /** 缩放手柄:只改时长,不改开始时间(最短 15 分钟,最长到当天结束)。 */
642
+ function resizeSlot(slot, deltaMinutes, step) {
643
+ const unit = typeof step === "number" && step > 0 ? step : SNAP_MINUTES;
644
+ // 只吸附**位移量**:50 分钟的日程碰一下手柄(位移 0)不该被吸成 45。
645
+ // 上限就是 24 小时 —— 允许拖过午夜(此前按「当天还剩多少」压缩,23:30–00:30 一碰就变 30 分钟)。
646
+ const next = slot.durationMinutes + snapMinutes(deltaMinutes || 0, unit);
647
+ return { dayKey: slot.dayKey, minutes: slot.minutes, durationMinutes: Math.max(RESIZE_MIN_MINUTES, Math.min(next, 24 * 60)), allDay: slot.allDay };
648
+ }
649
+
650
+ /** 槽位 → 提交给服务器的 start/end(本地墙钟换算成 UTC 秒级 ISO)。 */
651
+ function rangeOfSlot(slot) {
652
+ const start = new Date(slot.dayKey + "T00:00:00");
653
+ start.setMinutes(slot.minutes);
654
+ const end = new Date(start.getTime() + slot.durationMinutes * 60000);
655
+ return { start: isoOf(start), end: isoOf(end) };
656
+ }
657
+
658
+ /** 落点与哪些已有日程重叠(拖动中用来把幽灵块描成黄色)。 */
659
+ function conflictsForSlot(events, slot, uid) {
660
+ const range = rangeOfSlot(slot);
661
+ const pseudo = { uid: "__drag", summary: "", start: range.start, end: range.end, allDay: slot.allDay === true };
662
+ return events.filter((event) => event.uid !== uid && overlaps(event, pseudo));
663
+ }
664
+
665
+ /**
666
+ * 指针 x(视口坐标)→ 列号。坐标必须来自 getBoundingClientRect ——
667
+ * offsetLeft 是相对 offsetParent 的,浮层面板里与 clientX 不是一个坐标系(评审抓到的坑)。
668
+ * 找不到列就返回 undefined,调用方按「不换天」处理。
669
+ */
670
+ function columnIndexFromRects(clientX, rects) {
671
+ for (const rect of rects) {
672
+ if (clientX >= rect.left && clientX < rect.left + rect.width) return rect.index;
673
+ }
674
+ return undefined;
675
+ }
676
+
677
+ /** 重复日程(系列本身 or 某次实例):CalDAV 里整个系列是一个对象,拖动会改整条。 */
678
+ function isRecurring(event) {
679
+ return event.rrule !== undefined || event.isOccurrence === true || event.seriesStart !== undefined;
680
+ }
681
+
531
682
  // ───────────────────────────── 小部件 ─────────────────────────────
532
683
 
533
684
  /** 事件属于哪一类:影响左侧色条(全天绿 / 重复黄 / 普通蓝)。 */
@@ -584,11 +735,15 @@ function MonthView(props) {
584
735
  const list = sortForDisplay(byDay[key] || []);
585
736
  const shown = list.slice(0, 3);
586
737
  const rest = list.length - shown.length;
587
- cells.push(h("button", {
738
+ // 格子必须是 div 而不是 button:里面的日程芯片本身是按钮,而按钮不能嵌套按钮 ——
739
+ // 嵌套时浏览器会把外层按钮提前闭合,芯片就被挤到网格外面去了(真被用户抓到过)。
740
+ cells.push(h("div", {
588
741
  key: key,
589
- type: "button",
742
+ role: "button",
743
+ tabIndex: 0,
590
744
  className: "dshc-cell" + (day.getMonth() !== anchor.getMonth() ? " out" : "") + (key === today ? " today" : ""),
591
745
  onClick: () => onCreateAt(day),
746
+ onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCreateAt(day); } },
592
747
  title: t("action.new")
593
748
  }, [
594
749
  h("div", { className: "dshc-daynum" }, [key === today ? h("b", null, String(day.getDate())) : h("span", null, String(day.getDate()))]),
@@ -602,9 +757,24 @@ function MonthView(props) {
602
757
  ]);
603
758
  }
604
759
 
605
- /** 周视图:7 列 × 24 小时网格,事件按分钟定位;同一时段重叠的事件并排显示。 */
760
+ /**
761
+ * 周视图:7 列 × 24 小时网格。除了画日程,它还负责改期:
762
+ * · 拖动事件块:上下改时间、左右换天(15 分钟吸附);
763
+ * · 拖块底部 6px 的缩放手柄:只改时长;
764
+ * · 拖动中画幽灵落点,与已有日程重叠时描成黄色(允许重叠,只是提醒);
765
+ * · 拖到容器上下边缘自动滚动,长会议不用先滚再拖;
766
+ * · 键盘:Tab 聚焦后 ↑/↓ 挪 15 分钟、Shift+↑/↓ 挪 1 小时、←/→ 挪一天、Enter 打开详情、拖动中 Esc 取消。
767
+ * 几何计算全在纯函数里(slotFromDrag / resizeSlot / moveSlot / columnIndexFromRects),这里只做指针与 DOM 的事。
768
+ *
769
+ * 两个坑写在这里免得后人再踩:
770
+ * 1. 列判定必须用 getBoundingClientRect:offsetLeft 是相对 offsetParent 的,浮层面板里与 clientX 不同坐标系;
771
+ * 2. window 监听器只在挂载时注册一次,通过 impl ref 调用「最新实现」;
772
+ * 按渲染闭包挂/摘会出现「摘不掉 → 卸载后 pointerup 替用户提交一次改期」。
773
+ */
606
774
  function WeekView(props) {
607
- const { anchor, events, onPick, onCreateAt } = props;
775
+ const { anchor, events, onPick, onCreateAt, onReschedule } = props;
776
+ /** 示例模式下没有 onReschedule:不做改期,也不显示拖拽提示。 */
777
+ const canEdit = typeof onReschedule === "function";
608
778
  const HOUR_PX = 44;
609
779
  const start = startOfWeek(anchor);
610
780
  const days = [];
@@ -613,6 +783,194 @@ function WeekView(props) {
613
783
  const allDay = sortForDisplay(events.filter((e) => e.allDay));
614
784
  const timed = events.filter((e) => !e.allDay);
615
785
 
786
+ // 默认落在 07:00:半夜那几格基本没人看,但需要时还能往上滚(不裁剪任何事件)。
787
+ const scroller = useRef(null);
788
+ useEffect(() => {
789
+ const node = scroller.current;
790
+ if (node === null || node === undefined) return;
791
+ node.scrollTop = 7 * HOUR_PX;
792
+ }, [dayKey(anchor)]);
793
+
794
+ /** 拖拽位置放 ref:每帧 setState 会让整棵树重渲染,拖动会顿。 */
795
+ const dragRef = useRef(null);
796
+ const suppressClick = useRef(false);
797
+ const [ghost, setGhost] = useState(null);
798
+
799
+ /** 每列的**视口**矩形(见文件头注释第 1 条)。 */
800
+ const columnRects = () => {
801
+ const node = scroller.current;
802
+ if (node === null || node === undefined) return [];
803
+ return days.map((day, index) => {
804
+ const column = node.querySelector('[data-day="' + dayKey(day) + '"]');
805
+ if (column === null) return { dayKey: dayKey(day), index, left: 0, width: 0 };
806
+ const rect = column.getBoundingClientRect();
807
+ return { dayKey: dayKey(day), index, left: rect.left, width: rect.width };
808
+ });
809
+ };
810
+
811
+ const scrollTopNow = () => {
812
+ const node = scroller.current;
813
+ return node === null || node === undefined ? 0 : node.scrollTop;
814
+ };
815
+
816
+ const isOwnPointer = (pointerEvent) => {
817
+ const drag = dragRef.current;
818
+ if (drag === null) return false;
819
+ return pointerEvent.pointerId === undefined || drag.pointerId === undefined || pointerEvent.pointerId === drag.pointerId;
820
+ };
821
+
822
+ const stopDrag = (commit) => {
823
+ stopAutoScroll();
824
+ const drag = dragRef.current;
825
+ dragRef.current = null;
826
+ setGhost(null);
827
+ if (drag === null) return;
828
+ const moved = drag.moved === true;
829
+ if (moved === true) {
830
+ // 拖动之后浏览器通常会补一个 click,用它把「抑制」消费掉;
831
+ // 但如果松手在空白处(没有 click),下个事件循环必须清掉 —— 否则会吞掉将来某次正常点击。
832
+ suppressClick.current = true;
833
+ setTimeout(() => { suppressClick.current = false; }, 0);
834
+ }
835
+ if (commit !== false && moved === true && canEdit === true) {
836
+ const slot = drag.previewSlot === undefined ? drag.slot : drag.previewSlot;
837
+ // 没有幽灵块就没有落点:比如纵向拖出本周(跨到下周)时不该盲提交。
838
+ const visible = days.some((day) => dayKey(day) === slot.dayKey) && ghost !== null;
839
+ if (visible === true) onReschedule(drag.event, rangeOfSlot(slot), drag.mode);
840
+ }
841
+ };
842
+
843
+ /** 由指针位置算落点、画幽灵(只管几何,不碰边缘滚动的调度)。 */
844
+ const applyPreviewAt = (clientX, clientY, drag) => {
845
+ // 容器自己滚动过的话,指针下方的**内容**坐标变了:不补这一项,幽灵块会落后于指针,
846
+ // 松手落点也跟着偏(误差 = 滚动量,可达数小时)。
847
+ const scrolled = scrollTopNow() - drag.startScrollTop;
848
+ const dx = clientX - drag.startX;
849
+ const dy = clientY - drag.startY + scrolled;
850
+ if (drag.moved !== true && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
851
+ drag.moved = true;
852
+ const ownIndex = drag.columns.findIndex((column) => column.dayKey === drag.slot.dayKey);
853
+ const targetIndex = columnIndexFromRects(clientX, drag.columns);
854
+ // 横向只允许在本周 7 天内换天:跨到下周会让幽灵无处渲染、事件从当前视图消失。
855
+ const rawColumns = targetIndex === undefined || ownIndex === -1 ? 0 : targetIndex - ownIndex;
856
+ const columns = ownIndex === -1 ? 0 : Math.max(-ownIndex, Math.min(6 - ownIndex, rawColumns));
857
+ const slot = drag.mode === "resize"
858
+ ? resizeSlot(drag.slot, (dy / HOUR_PX) * 60, SNAP_MINUTES)
859
+ : slotFromDrag(drag.slot, { columns, pixelsY: dy, hourPx: HOUR_PX }, SNAP_MINUTES);
860
+ drag.previewSlot = slot;
861
+ setGhost({ uid: drag.uid, slot, conflicts: conflictsForSlot(events, slot, drag.uid).length });
862
+ };
863
+
864
+ /**
865
+ * 边缘自动滚动:用 rAF 持续滚,而不是「每次 pointermove 滚一小步」。
866
+ * 前者指针停在边缘不动也会继续滚(这才是「长会议不用先滚再拖」),
867
+ * 后者只在指针移动时滚,用户会觉得网格在滚、事件却不跟手。
868
+ */
869
+ const autoScroll = useRef({ raf: 0, x: 0, y: 0 });
870
+ const stopAutoScroll = () => {
871
+ if (autoScroll.current.raf !== 0) { cancelAnimationFrame(autoScroll.current.raf); autoScroll.current.raf = 0; }
872
+ };
873
+ const stepAutoScroll = () => {
874
+ autoScroll.current.raf = 0;
875
+ const drag = dragRef.current;
876
+ const node = scroller.current;
877
+ if (drag === null || node === null || node === undefined) return;
878
+ const rect = node.getBoundingClientRect();
879
+ const y = autoScroll.current.y;
880
+ let delta = 0;
881
+ if (y < rect.top + 40) delta = -Math.min(24, (rect.top + 40 - y) / 3);
882
+ else if (y > rect.bottom - 40) delta = Math.min(24, (y - (rect.bottom - 40)) / 3);
883
+ if (delta === 0) return; // 指针离开边缘:循环自然停下,下次移动会再起
884
+ node.scrollTop += delta;
885
+ applyPreviewAt(autoScroll.current.x, y, drag); // 滚动后重算落点(含滚动补偿)
886
+ autoScroll.current.raf = requestAnimationFrame(stepAutoScroll);
887
+ };
888
+ const ensureAutoScroll = (pointerEvent) => {
889
+ autoScroll.current.x = pointerEvent.clientX;
890
+ autoScroll.current.y = pointerEvent.clientY;
891
+ const node = scroller.current;
892
+ if (node === null || node === undefined) return;
893
+ const rect = node.getBoundingClientRect();
894
+ 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);
896
+ };
897
+
898
+ const updatePreview = (pointerEvent) => {
899
+ const drag = dragRef.current;
900
+ if (drag === null) return;
901
+ // 鼠标在窗口外松开时浏览器可能不派发 pointerup:按键已经松了就当作取消,
902
+ // 免得「卡住的拖拽」在回到窗口后被下一次移动继续更新、下一次松开就替用户提交。
903
+ if (drag.moved === true && pointerEvent.buttons === 0) { stopDrag(false); return; }
904
+ applyPreviewAt(pointerEvent.clientX, pointerEvent.clientY, drag);
905
+ ensureAutoScroll(pointerEvent);
906
+ };
907
+
908
+ /**
909
+ * window 监听器只在挂载时注册一次,通过 impl ref 调「最新实现」。
910
+ * 这样既不会因为渲染闭包更换而摘不掉(那会导致卸载后 pointerup 真的发一次 update),
911
+ * 也不会读到过期的 state。
912
+ */
913
+ const impl = useRef({});
914
+ impl.current = { stopDrag, updatePreview, isOwnPointer };
915
+ useEffect(() => {
916
+ const onMove = (pointerEvent) => { if (dragRef.current !== null && impl.current.isOwnPointer(pointerEvent) === true) impl.current.updatePreview(pointerEvent); };
917
+ const onUp = (pointerEvent) => { if (dragRef.current !== null && impl.current.isOwnPointer(pointerEvent) === true) impl.current.stopDrag(true); };
918
+ const onCancel = () => { if (dragRef.current !== null) impl.current.stopDrag(false); };
919
+ const onKey = (keyEvent) => { if (keyEvent.key === "Escape" && dragRef.current !== null) { keyEvent.preventDefault(); keyEvent.stopPropagation(); impl.current.stopDrag(false); } };
920
+ const onBlur = () => { if (dragRef.current !== null) impl.current.stopDrag(false); };
921
+ window.addEventListener("pointermove", onMove);
922
+ window.addEventListener("pointerup", onUp);
923
+ window.addEventListener("pointercancel", onCancel);
924
+ // 捕获阶段:保证「取消拖动」先于浮层面板的 document Esc(否则会顺手把面板也关了)。
925
+ window.addEventListener("keydown", onKey, true);
926
+ window.addEventListener("blur", onBlur);
927
+ return () => {
928
+ window.removeEventListener("pointermove", onMove);
929
+ window.removeEventListener("pointerup", onUp);
930
+ window.removeEventListener("pointercancel", onCancel);
931
+ window.removeEventListener("keydown", onKey, true);
932
+ if (autoScroll.current.raf !== 0) { cancelAnimationFrame(autoScroll.current.raf); autoScroll.current.raf = 0; }
933
+ window.removeEventListener("blur", onBlur);
934
+ dragRef.current = null; // 卸载时不能留着半截拖拽状态
935
+ };
936
+ }, []);
937
+
938
+ const beginDrag = (event, pointerEvent, mode) => {
939
+ if (canEdit !== true) return; // 示例模式:不落盘就不给拖
940
+ if (pointerEvent.button !== undefined && pointerEvent.button !== 0) return;
941
+ if (dragRef.current !== null) return; // 已经有一根指针在拖,忽略第二根
942
+ suppressClick.current = false;
943
+ try { if (typeof pointerEvent.currentTarget.setPointerCapture === "function") pointerEvent.currentTarget.setPointerCapture(pointerEvent.pointerId); } catch (error) { /* 不支持就算了,window 监听器兜底 */ }
944
+ dragRef.current = {
945
+ uid: event.uid,
946
+ event,
947
+ pointerId: pointerEvent.pointerId,
948
+ mode: mode === "resize" ? "resize" : "move",
949
+ slot: slotFromEvent(event),
950
+ startX: pointerEvent.clientX,
951
+ startY: pointerEvent.clientY,
952
+ startScrollTop: scrollTopNow(),
953
+ moved: false,
954
+ columns: columnRects(),
955
+ };
956
+ };
957
+
958
+ /** 键盘改期:与拖拽走同一套纯函数,所以行为一致(且不用鼠标也能用)。 */
959
+ const onKeyDown = (event, keyEvent) => {
960
+ if (keyEvent.key === "Enter" || keyEvent.key === " ") { keyEvent.preventDefault(); onPick(event); return; }
961
+ if (canEdit !== true) return;
962
+ const slot = slotFromEvent(event);
963
+ let deltaDays = 0;
964
+ let deltaMinutes = 0;
965
+ if (keyEvent.key === "ArrowUp") deltaMinutes = keyEvent.shiftKey ? -60 : -SNAP_MINUTES;
966
+ else if (keyEvent.key === "ArrowDown") deltaMinutes = keyEvent.shiftKey ? 60 : SNAP_MINUTES;
967
+ else if (keyEvent.key === "ArrowLeft") deltaDays = -1;
968
+ else if (keyEvent.key === "ArrowRight") deltaDays = 1;
969
+ else return;
970
+ keyEvent.preventDefault();
971
+ onReschedule(event, rangeOfSlot(moveSlot(slot, deltaDays, deltaMinutes, SNAP_MINUTES)), "move");
972
+ };
973
+
616
974
  const hoursColumn = h("div", { key: "__hours" }, [h("div", { className: "dshc-dayhead", key: "h" }, "\u00a0")].concat(
617
975
  Array.from({ length: 24 }, (_, hour) => h("div", { className: "dshc-hour", key: "h" + hour }, hour === 0 ? "" : pad2(hour) + ":00"))
618
976
  ));
@@ -620,7 +978,6 @@ function WeekView(props) {
620
978
  const dayColumns = days.map((day) => {
621
979
  const key = dayKey(day);
622
980
  const dayTimed = timed.filter((event) => daysOfEvent(event).some((d) => dayKey(d) === key));
623
- // 重叠事件分轨:按开始时间排好,逐个找第一条已经结束的轨道;没有就新开一条。
624
981
  const laneEnds = [];
625
982
  const placed = dayTimed.slice().sort((a, b) => startDate(a) - startDate(b)).map((event) => {
626
983
  const from = startDate(event), to = endDate(event);
@@ -630,33 +987,58 @@ function WeekView(props) {
630
987
  return { event, lane };
631
988
  });
632
989
  const laneCount = Math.max(1, laneEnds.length);
990
+ const laneStyle = (lane) => ({ left: "calc(" + (lane * (100 / laneCount)) + "% + 2px)", width: "calc(" + (100 / laneCount) + "% - 4px)" });
633
991
  const blocks = placed.map(({ event, lane }) => {
634
992
  const from = startDate(event), to = endDate(event);
635
993
  const top = (from.getHours() * 60 + from.getMinutes()) * (HOUR_PX / 60);
636
994
  const height = Math.max(18, ((to - from) / 60000) * (HOUR_PX / 60));
637
- const width = "calc(" + (100 / laneCount) + "% - 4px)";
638
- const left = "calc(" + (lane * (100 / laneCount)) + "% + 2px)";
639
- return h("button", {
995
+ const dragging = ghost !== null && ghost.uid === event.uid;
996
+ return h("div", {
640
997
  key: event.uid,
641
- type: "button",
642
- className: "dshc-slot " + eventClass(event),
643
- style: { top: top + "px", height: height + "px", left, width },
644
- title: event.summary + " " + hhmm(from) + "\u2013" + hhmm(to),
645
- onClick: (e) => { e.stopPropagation(); onPick(event); }
646
- }, [h("div", null, event.summary), h("div", { style: { opacity: .75 } }, hhmm(from) + "\u2013" + hhmm(to))]);
998
+ role: "button",
999
+ tabIndex: 0,
1000
+ "data-uid": event.uid,
1001
+ className: "dshc-slot " + eventClass(event) + (dragging ? " dragging" : ""),
1002
+ style: Object.assign({ top: top + "px", height: height + "px" }, laneStyle(lane)),
1003
+ title: event.summary + " " + hhmm(from) + "\u2013" + hhmm(to) + (canEdit === true ? " · " + t("drag.moveTitle") : ""),
1004
+ onPointerDown: (e) => beginDrag(event, e, "move"),
1005
+ onKeyDown: (e) => onKeyDown(event, e),
1006
+ onClick: (clickEvent) => {
1007
+ // slot 是 daycol 的子节点:不挡住冒泡的话,点事件会同时触发 daycol 的「新建」。
1008
+ clickEvent.stopPropagation();
1009
+ if (suppressClick.current === true) { suppressClick.current = false; return; }
1010
+ onPick(event);
1011
+ }
1012
+ }, [
1013
+ h("div", { className: "dshc-slot-title" }, event.summary),
1014
+ h("div", { className: "dshc-slot-time" }, hhmm(from) + "\u2013" + hhmm(to)),
1015
+ canEdit === true ? h("div", { className: "dshc-resize", title: t("drag.resizeTitle"), onPointerDown: (e) => { e.stopPropagation(); beginDrag(event, e, "resize"); } }) : null
1016
+ ]);
647
1017
  });
1018
+ const ghostBlock = ghost !== null && ghost.slot.dayKey === key
1019
+ ? h("div", {
1020
+ className: "dshc-slot dshc-ghost" + (ghost.conflicts > 0 ? " conflict" : ""),
1021
+ 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
+ key: "__ghost"
1023
+ }, [
1024
+ h("div", { className: "dshc-slot-title" }, ghost.conflicts > 0 ? t("drag.conflict", { n: ghost.conflicts }) + " · " + t("drag.keep") : t("drag.drop")),
1025
+ 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
+ ])
1027
+ : null;
648
1028
  return h("div", { key, style: { minWidth: 0 } }, [
649
- h("div", { className: "dshc-dayhead" }, [
650
- h("b", null, String(day.getDate())),
651
- " ",
652
- relativeDay(day)
653
- ]),
1029
+ h("div", { className: "dshc-dayhead" }, [h("b", null, String(day.getDate())), " ", relativeDay(day)]),
654
1030
  h("div", {
655
1031
  className: "dshc-daycol" + (key === today ? " today" : ""),
1032
+ "data-day": key,
656
1033
  style: { height: (24 * HOUR_PX) + "px" },
657
- onClick: () => onCreateAt(day),
1034
+ onClick: (clickEvent) => {
1035
+ // 只有点在格子空白处才新建:拖拽结束后的 click 可能落在 daycol 上,靠 suppressClick 兜住。
1036
+ if (clickEvent.target !== clickEvent.currentTarget) return;
1037
+ if (suppressClick.current === true) return;
1038
+ onCreateAt(day);
1039
+ },
658
1040
  title: t("action.new")
659
- }, blocks)
1041
+ }, blocks.concat(ghostBlock === null ? [] : [ghostBlock]))
660
1042
  ]);
661
1043
  });
662
1044
 
@@ -664,7 +1046,8 @@ function WeekView(props) {
664
1046
  allDay.length > 0
665
1047
  ? h("div", { style: { marginBottom: "8px" } }, allDay.map((event) => h(Row, { key: event.uid, event, onClick: onPick })))
666
1048
  : null,
667
- h("div", { className: "dshc-week" }, [hoursColumn].concat(dayColumns))
1049
+ h("div", { className: "dshc-week", ref: scroller }, [hoursColumn].concat(dayColumns)),
1050
+ canEdit === true ? h("div", { className: "dshc-draghint" }, t("drag.hint")) : null
668
1051
  ]);
669
1052
  }
670
1053
 
@@ -1119,7 +1502,7 @@ function ConnectionForm(props) {
1119
1502
 
1120
1503
  function CalendarPanel(props) {
1121
1504
  const compact = props.compact === true;
1122
- const [view, setView] = useState("month");
1505
+ const [view, setView] = useState(loadStoredView);
1123
1506
  const [anchor, setAnchor] = useState(() => new Date());
1124
1507
  const [demo, setDemo] = useState(false);
1125
1508
  const [reloadToken, setReloadToken] = useState(0);
@@ -1133,6 +1516,13 @@ function CalendarPanel(props) {
1133
1516
  const [configured, setConfigured] = useState(null);
1134
1517
  const [conn, setConn] = useState(null);
1135
1518
  const [connOpen, setConnOpen] = useState(false);
1519
+ /** 乐观改期:uid → { start, end }。等服务器回读到同样的时间再撤掉,避免闪回。 */
1520
+ const [overrides, setOverrides] = useState({});
1521
+ const [undo, setUndo] = useState(null);
1522
+ const undoTimer = useRef(0);
1523
+ /** 每个 uid 一个串行队列:见 applyMove 的注释。 */
1524
+ const moveQueue = useRef({});
1525
+ useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
1136
1526
  useEffect(() => {
1137
1527
  let alive = true;
1138
1528
  api("connection", {}).then((value) => { if (alive) { setConn(value); setConfigured(value && value.configured !== false); } })
@@ -1144,6 +1534,130 @@ function CalendarPanel(props) {
1144
1534
  const data = useCalendarData(range, reloadToken, demo);
1145
1535
  const reload = () => setReloadToken((n) => n + 1);
1146
1536
 
1537
+ /** 渲染用的事件:把乐观改期叠加在服务器数据之上。 */
1538
+ const visibleEvents = useMemo(() => data.events.map((event) => {
1539
+ const override = overrides[event.uid];
1540
+ return override === undefined ? event : Object.assign({}, event, override);
1541
+ }), [data.events, overrides]);
1542
+
1543
+ /**
1544
+ * 改期成功后把焦点还给同一个日程。
1545
+ *
1546
+ * 位置必须在 const data = useCalendarData(...) **之后**:依赖数组 [data.events] 在渲染期求值,
1547
+ * 写在前面会直接 TDZ 抛错(panel 每次渲染就崩 —— 纯函数测试抓不到,评审抓到了)。
1548
+ * 另外只在「焦点还在面板里」时才回收,免得用户在慢 reload 期间已经点到别处又被抢回来。
1549
+ */
1550
+ const focusUid = useRef(null);
1551
+ useEffect(() => {
1552
+ const uid = focusUid.current;
1553
+ if (uid === null) return;
1554
+ focusUid.current = null;
1555
+ const active = document.activeElement;
1556
+ const inPanel = active === undefined || active === null || active === document.body
1557
+ || (typeof active.closest === "function" && active.closest(".dshc-root") !== null);
1558
+ if (inPanel !== true) return;
1559
+ try {
1560
+ const selector = '.dshc-slot[data-uid="' + String(uid).replace(/"/g, '\\"') + '"]';
1561
+ const node = document.querySelector(selector);
1562
+ if (node !== null && typeof node.focus === "function") node.focus({ preventScroll: true });
1563
+ } catch (error) { /* uid 里有怪字符就算了,不值得为此报错 */ }
1564
+ }, [data.events]);
1565
+
1566
+ /** 服务器回读到新时间后,撤掉对应的乐观覆盖(否则会一直盖着)。 */
1567
+ useEffect(() => {
1568
+ setOverrides((prev) => {
1569
+ const uids = Object.keys(prev);
1570
+ if (uids.length === 0) return prev;
1571
+ const next = Object.assign({}, prev);
1572
+ let changed = false;
1573
+ for (const uid of uids) {
1574
+ const stored = data.events.find((event) => event.uid === uid);
1575
+ if (stored !== undefined && stored.start === prev[uid].start && stored.end === prev[uid].end) { delete next[uid]; changed = true; }
1576
+ }
1577
+ return changed ? next : prev;
1578
+ });
1579
+ }, [data.events]);
1580
+
1581
+ /** 乐观改期的落盘:先动画面、再发请求;失败就把覆盖撤掉并报错。 */
1582
+ /**
1583
+ * 改期落盘:**按 uid 串行 + 合并**。
1584
+ *
1585
+ * 按住方向键会按 repeat 频率连发,直接并发发 PUT 的话:乱序可能让服务器停在中间某个时间、
1586
+ * 而先发的那次失败会把后发那次的乐观位置一起回滚掉。这里同一 uid 只跑一个请求,
1587
+ * 期间来的新落点合并成「最后一次」,跑完再接着发。
1588
+ */
1589
+ const applyMove = (event, range) => {
1590
+ const uid = event.uid;
1591
+ const shown = overrides[uid] === undefined ? { start: event.start, end: event.end } : overrides[uid];
1592
+ if (range.start === shown.start && range.end === shown.end) return; // 无效位移(同一格内晃一下)不发请求
1593
+ const queue = moveQueue.current;
1594
+ if (queue[uid] === undefined) queue[uid] = { running: false, next: null };
1595
+ const entry = queue[uid];
1596
+ if (entry.running === true) { entry.next = range; return; }
1597
+ entry.running = true;
1598
+ void drain(uid, event, range, entry);
1599
+ };
1600
+
1601
+ /** 串行队列的驱动:失败即回滚 + 对账(超时可能已经落盘,不能假设「失败 = 没改」)。 */
1602
+ const drain = async (uid, event, firstRange, entry) => {
1603
+ const original = { uid, start: event.start, end: event.end, summary: event.summary };
1604
+ let range = firstRange;
1605
+ try {
1606
+ for (;;) {
1607
+ setOverrides((prev) => Object.assign({}, prev, { [uid]: { start: range.start, end: range.end } }));
1608
+ try {
1609
+ await api("update", { uid, start: range.start, end: range.end });
1610
+ } catch (error) {
1611
+ setOverrides((prev) => { const next = Object.assign({}, prev); delete next[uid]; return next; });
1612
+ notify(t("move.failed", { message: error !== null && error !== undefined && error.message ? error.message : String(error) }));
1613
+ reload(); // 对账:超时/断连时服务器可能已经改了
1614
+ break;
1615
+ }
1616
+ focusUid.current = uid;
1617
+ // 一次「连续操作」只留一个撤销点:记录的是这段操作前的原始位置,而不是上一小步。
1618
+ setUndo(Object.assign({ recurring: false }, original));
1619
+ if (undoTimer.current) clearTimeout(undoTimer.current);
1620
+ undoTimer.current = setTimeout(() => setUndo(null), 5000);
1621
+ reload();
1622
+ if (entry.next === null) break;
1623
+ range = entry.next;
1624
+ entry.next = null;
1625
+ }
1626
+ } finally {
1627
+ entry.running = false;
1628
+ if (entry.next === null) delete moveQueue.current[uid];
1629
+ else { const pending = entry.next; entry.next = null; applyMove(event, pending); }
1630
+ }
1631
+ };
1632
+
1633
+ /** 改期入口:重复日程先问一句(拖动会移动整个系列,这是语义雷区,不能悄悄做)。 */
1634
+ const reschedule = (event, range) => {
1635
+ // 重复日程暂不支持拖动改期:CalDAV 里整个系列是一个对象,而 update 只能改 master 的
1636
+ // DTSTART —— 把「这次实例的目标时间」发过去等于**重锚系列起点**(早于它的实例全丢、
1637
+ // COUNT/UNTIL 语义被改,撤销也回不到原系列)。评审实测过,这里必须先挡住。
1638
+ if (isRecurring(event)) { notify(t("move.recurringUnsupported")); return; }
1639
+ // 超过 24 小时的日程:当天放不下,任何位移都会被钳到 00:00,语义不清,一并挡住。
1640
+ if (slotFromEvent(event).durationMinutes > 24 * 60) { notify(t("move.tooLong")); return; }
1641
+ applyMove(event, range);
1642
+ };
1643
+
1644
+ /** 撤销:把上一次改期前的 start/end 再写回去。 */
1645
+ const undoMove = async () => {
1646
+ if (undo === null) return;
1647
+ const target = undo;
1648
+ setUndo(null);
1649
+ if (undoTimer.current) clearTimeout(undoTimer.current);
1650
+ const optimistic = true; // 撤销只服务单次日程(重复日程不走拖动)
1651
+ if (optimistic === true) setOverrides((prev) => Object.assign({}, prev, { [target.uid]: { start: target.start, end: target.end } }));
1652
+ try {
1653
+ await api("update", { uid: target.uid, start: target.start, end: target.end });
1654
+ reload();
1655
+ } catch (error) {
1656
+ if (optimistic === true) setOverrides((prev) => { const next = Object.assign({}, prev); delete next[target.uid]; return next; });
1657
+ notify(t("move.failed", { message: error !== null && error !== undefined && error.message ? error.message : String(error) }));
1658
+ }
1659
+ };
1660
+
1147
1661
  const notify = (text) => { setBanner(text); setTimeout(() => setBanner((current) => (current === text ? null : current)), 2200); };
1148
1662
 
1149
1663
  const title = view === "week" ? formatWeekTitle(anchor) : view === "agenda" ? t("view.agenda") : formatMonthTitle(anchor);
@@ -1185,7 +1699,8 @@ function CalendarPanel(props) {
1185
1699
  const toolbar = h("div", { className: "dshc-toolbar" }, [
1186
1700
  h("h3", { className: "dshc-title", key: "title" }, t("section.title")),
1187
1701
  h("div", { className: "dshc-tabs", key: "tabs" }, VIEWS.map((name) => h("button", {
1188
- key: name, type: "button", className: "dshc-tab" + (view === name ? " on" : ""), onClick: () => setView(name)
1702
+ key: name, type: "button", className: "dshc-tab" + (view === name ? " on" : ""),
1703
+ onClick: () => { setView(name); storeView(name); }
1189
1704
  }, t("view." + name)))),
1190
1705
  h("span", { className: "dshc-spacer", key: "spacer" }),
1191
1706
  h("span", { className: "dshc-monthhead", key: "period" }, title),
@@ -1214,9 +1729,9 @@ function CalendarPanel(props) {
1214
1729
  if (data.loading === true && data.events.length === 0) {
1215
1730
  return h("div", { className: "dshc-grid" }, Array.from({ length: 14 }, (_, i) => h("div", { className: "dshc-skel", key: i })));
1216
1731
  }
1217
- if (view === "week") return h(WeekView, { anchor, events: data.events, onPick: openEvent, onCreateAt: createAt });
1218
- if (view === "agenda") return h(AgendaView, { events: data.events, onPick: openEvent, onCreateAt: createAt });
1219
- return h(MonthView, { anchor, events: data.events, onPick: openEvent, onCreateAt: createAt });
1732
+ if (view === "week") return h(WeekView, { anchor, events: visibleEvents, onPick: openEvent, onCreateAt: createAt, onReschedule: reschedule });
1733
+ if (view === "agenda") return h(AgendaView, { events: visibleEvents, onPick: openEvent, onCreateAt: createAt });
1734
+ return h(MonthView, { anchor, events: visibleEvents, onPick: openEvent, onCreateAt: createAt });
1220
1735
  };
1221
1736
 
1222
1737
  const openEvent = (event) => { setConfirmDelete(false); setSelected(event); };
@@ -1224,7 +1739,13 @@ function CalendarPanel(props) {
1224
1739
 
1225
1740
  return h("div", { className: "dshc-root" }, [
1226
1741
  h("div", { className: "dshc-shell" }, [
1227
- banner !== null ? h("div", { className: "dshc-alert demo" }, banner) : null,
1742
+ banner !== null ? h("div", { className: "dshc-alert demo", role: "status", "aria-live": "polite" }, banner) : null,
1743
+ undo !== null
1744
+ ? h("div", { className: "dshc-undo", role: "status", "aria-live": "polite" }, [
1745
+ h("span", { key: "text" }, t("move.saved") + ":" + (undo.summary || "")),
1746
+ h("button", { key: "undo", type: "button", className: "dshc-undo-btn", onClick: undoMove }, t("move.undo"))
1747
+ ])
1748
+ : null,
1228
1749
  demo === true ? h("div", { className: "dshc-alert demo" }, t("demo.banner")) : null,
1229
1750
  data.error !== null && data.error !== undefined && demo === false && configured !== false
1230
1751
  ? h("div", { className: "dshc-alert error" }, [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-calendar",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "DSH 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth2、iCloud、Nextcloud 与自建服务。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -61,7 +61,10 @@ export function validateClientId(clientId) {
61
61
  */
62
62
  export async function checkAuthUrl(url, fetchImpl = fetch) {
63
63
  try {
64
- const response = await fetchImpl(url, { redirect: 'manual', headers: { 'user-agent': 'dsh-calendar-oauth/1.0' } })
64
+ const init = { redirect: 'manual', headers: { 'user-agent': 'dsh-calendar-oauth/1.0' } }
65
+ // 预检只是「顺手问一句」,不能把流程拖住:网络慢/被墙就用超时当「没意见」。
66
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') init.signal = AbortSignal.timeout(4000)
67
+ const response = await fetchImpl(url, init)
65
68
  if (response.status === 200 || response.status === 302 || response.status === 303) return undefined
66
69
  const body = await response.text().catch(() => '')
67
70
  const title = /<title>([^<]*)<\/title>/i.exec(body)