dsh-calendar 0.7.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,18 @@
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
+
3
16
  ## 0.7.0(2026-09-19)
4
17
 
5
18
  - **新增**:面板**记住上次用的视图**(月/周/议程,存 localStorage;隐私模式写不进去就退回「月」),下次打开不再固定跳回月视图。
package/README.en.md CHANGED
@@ -172,6 +172,7 @@ The stable event identifier `uid` is the CalDAV href (full object URL); `calenda
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
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
@@ -198,6 +198,7 @@ Nextcloud / 自定义 Basic 服务:检查账号、密码或服务要求的应
198
198
  装好后,DSH 的「设置 → 日历」里会多出一节面板;对话页右下角还有一个 📅 小按钮,点开是同一个面板(非模态,Esc 关闭)。
199
199
 
200
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
@@ -78,7 +78,19 @@ const CSS = [
78
78
  ".dshc-daycol.today{background:var(--dsw-alias-bg-mask-3,rgba(37,99,235,.06))}",
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
- ".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}",
82
94
  ".dshc-slot.allday{background:var(--dsw-alias-state-success-primary,#16a34a)}",
83
95
  ".dshc-slot.recurring{background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1f2937}",
84
96
  ".dshc-agenda{display:flex;flex-direction:column;gap:14px;max-height:560px;overflow:auto;padding-right:4px}",
@@ -154,6 +166,17 @@ const UI = {
154
166
  "count.events": "{n} 个日程", "count.occurrences": "含 {n} 次重复展开",
155
167
  "format.duration.hm": "{h} 小时 {m} 分", "format.duration.h": "{h} 小时", "format.duration.m": "{m} 分钟", "format.duration.zero": "0 分钟",
156
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}",
157
180
  "conn.button": "连接设置",
158
181
  "conn.title": "CalDAV 连接设置",
159
182
  "conn.intro": "填完点「测试连接」,通了才会保存。密码只存在本机(不进日志、不进导出)。",
@@ -238,6 +261,17 @@ const UI = {
238
261
  "count.events": "{n} events", "count.occurrences": "{n} expanded occurrences",
239
262
  "format.duration.hm": "{h}h {m}m", "format.duration.h": "{h}h", "format.duration.m": "{m}m", "format.duration.zero": "0m",
240
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}",
241
275
  "conn.button": "Connection",
242
276
  "conn.title": "CalDAV connection",
243
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).",
@@ -348,8 +382,13 @@ function t(key, params) {
348
382
  }
349
383
 
350
384
  /** 调插件自己的路由;错误码原样带出,方便上层区分「没配置」与「服务器拒绝」。 */
385
+ const API_TIMEOUT_MS = 15000;
386
+
351
387
  async function api(action, payload) {
352
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);
353
392
  const res = await fetch(ROUTE, init);
354
393
  let body = null;
355
394
  try { body = await res.json(); } catch (e) { body = null; }
@@ -546,6 +585,100 @@ async function copyText(text) {
546
585
  return false;
547
586
  }
548
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
+
549
682
  // ───────────────────────────── 小部件 ─────────────────────────────
550
683
 
551
684
  /** 事件属于哪一类:影响左侧色条(全天绿 / 重复黄 / 普通蓝)。 */
@@ -624,9 +757,24 @@ function MonthView(props) {
624
757
  ]);
625
758
  }
626
759
 
627
- /** 周视图: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
+ */
628
774
  function WeekView(props) {
629
- const { anchor, events, onPick, onCreateAt } = props;
775
+ const { anchor, events, onPick, onCreateAt, onReschedule } = props;
776
+ /** 示例模式下没有 onReschedule:不做改期,也不显示拖拽提示。 */
777
+ const canEdit = typeof onReschedule === "function";
630
778
  const HOUR_PX = 44;
631
779
  const start = startOfWeek(anchor);
632
780
  const days = [];
@@ -643,6 +791,186 @@ function WeekView(props) {
643
791
  node.scrollTop = 7 * HOUR_PX;
644
792
  }, [dayKey(anchor)]);
645
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
+
646
974
  const hoursColumn = h("div", { key: "__hours" }, [h("div", { className: "dshc-dayhead", key: "h" }, "\u00a0")].concat(
647
975
  Array.from({ length: 24 }, (_, hour) => h("div", { className: "dshc-hour", key: "h" + hour }, hour === 0 ? "" : pad2(hour) + ":00"))
648
976
  ));
@@ -650,7 +978,6 @@ function WeekView(props) {
650
978
  const dayColumns = days.map((day) => {
651
979
  const key = dayKey(day);
652
980
  const dayTimed = timed.filter((event) => daysOfEvent(event).some((d) => dayKey(d) === key));
653
- // 重叠事件分轨:按开始时间排好,逐个找第一条已经结束的轨道;没有就新开一条。
654
981
  const laneEnds = [];
655
982
  const placed = dayTimed.slice().sort((a, b) => startDate(a) - startDate(b)).map((event) => {
656
983
  const from = startDate(event), to = endDate(event);
@@ -660,33 +987,58 @@ function WeekView(props) {
660
987
  return { event, lane };
661
988
  });
662
989
  const laneCount = Math.max(1, laneEnds.length);
990
+ const laneStyle = (lane) => ({ left: "calc(" + (lane * (100 / laneCount)) + "% + 2px)", width: "calc(" + (100 / laneCount) + "% - 4px)" });
663
991
  const blocks = placed.map(({ event, lane }) => {
664
992
  const from = startDate(event), to = endDate(event);
665
993
  const top = (from.getHours() * 60 + from.getMinutes()) * (HOUR_PX / 60);
666
994
  const height = Math.max(18, ((to - from) / 60000) * (HOUR_PX / 60));
667
- const width = "calc(" + (100 / laneCount) + "% - 4px)";
668
- const left = "calc(" + (lane * (100 / laneCount)) + "% + 2px)";
669
- return h("button", {
995
+ const dragging = ghost !== null && ghost.uid === event.uid;
996
+ return h("div", {
670
997
  key: event.uid,
671
- type: "button",
672
- className: "dshc-slot " + eventClass(event),
673
- style: { top: top + "px", height: height + "px", left, width },
674
- title: event.summary + " " + hhmm(from) + "\u2013" + hhmm(to),
675
- onClick: (e) => { e.stopPropagation(); onPick(event); }
676
- }, [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
+ ]);
677
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;
678
1028
  return h("div", { key, style: { minWidth: 0 } }, [
679
- h("div", { className: "dshc-dayhead" }, [
680
- h("b", null, String(day.getDate())),
681
- " ",
682
- relativeDay(day)
683
- ]),
1029
+ h("div", { className: "dshc-dayhead" }, [h("b", null, String(day.getDate())), " ", relativeDay(day)]),
684
1030
  h("div", {
685
1031
  className: "dshc-daycol" + (key === today ? " today" : ""),
1032
+ "data-day": key,
686
1033
  style: { height: (24 * HOUR_PX) + "px" },
687
- 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
+ },
688
1040
  title: t("action.new")
689
- }, blocks)
1041
+ }, blocks.concat(ghostBlock === null ? [] : [ghostBlock]))
690
1042
  ]);
691
1043
  });
692
1044
 
@@ -694,7 +1046,8 @@ function WeekView(props) {
694
1046
  allDay.length > 0
695
1047
  ? h("div", { style: { marginBottom: "8px" } }, allDay.map((event) => h(Row, { key: event.uid, event, onClick: onPick })))
696
1048
  : null,
697
- h("div", { className: "dshc-week", ref: scroller }, [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
698
1051
  ]);
699
1052
  }
700
1053
 
@@ -1163,6 +1516,13 @@ function CalendarPanel(props) {
1163
1516
  const [configured, setConfigured] = useState(null);
1164
1517
  const [conn, setConn] = useState(null);
1165
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); }, []);
1166
1526
  useEffect(() => {
1167
1527
  let alive = true;
1168
1528
  api("connection", {}).then((value) => { if (alive) { setConn(value); setConfigured(value && value.configured !== false); } })
@@ -1174,6 +1534,130 @@ function CalendarPanel(props) {
1174
1534
  const data = useCalendarData(range, reloadToken, demo);
1175
1535
  const reload = () => setReloadToken((n) => n + 1);
1176
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
+
1177
1661
  const notify = (text) => { setBanner(text); setTimeout(() => setBanner((current) => (current === text ? null : current)), 2200); };
1178
1662
 
1179
1663
  const title = view === "week" ? formatWeekTitle(anchor) : view === "agenda" ? t("view.agenda") : formatMonthTitle(anchor);
@@ -1245,9 +1729,9 @@ function CalendarPanel(props) {
1245
1729
  if (data.loading === true && data.events.length === 0) {
1246
1730
  return h("div", { className: "dshc-grid" }, Array.from({ length: 14 }, (_, i) => h("div", { className: "dshc-skel", key: i })));
1247
1731
  }
1248
- if (view === "week") return h(WeekView, { anchor, events: data.events, onPick: openEvent, onCreateAt: createAt });
1249
- if (view === "agenda") return h(AgendaView, { events: data.events, onPick: openEvent, onCreateAt: createAt });
1250
- 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 });
1251
1735
  };
1252
1736
 
1253
1737
  const openEvent = (event) => { setConfirmDelete(false); setSelected(event); };
@@ -1255,7 +1739,13 @@ function CalendarPanel(props) {
1255
1739
 
1256
1740
  return h("div", { className: "dshc-root" }, [
1257
1741
  h("div", { className: "dshc-shell" }, [
1258
- 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,
1259
1749
  demo === true ? h("div", { className: "dshc-alert demo" }, t("demo.banner")) : null,
1260
1750
  data.error !== null && data.error !== undefined && demo === false && configured !== false
1261
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.7.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)