dsh-calendar 0.8.0 → 0.8.1

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1(2026-09-19)
4
+
5
+ - **新增真 DOM 行为测试层**(`test/render.test.mjs`,devDependencies 里加 `react` / `react-dom` / `jsdom`,不进发布包):用 jsdom + 真 React 渲染周视图,然后真派发 `pointerdown/move/up`、`click`、`keydown`。7 条覆盖:渲染不抛、点事件不会顺手弹「新建」、拖动只提交一次且按视口坐标换算、拖动中 Esc 零请求、卸载后不留监听器且之后的 pointerup 不提交、拖到边缘 rAF 持续滚动、重复日程被面板挡住。
6
+ - **它的第一批产出就是三个真问题**:
7
+ ① **同一批次内拖动不提交**:提交前的「有没有落点」判断读的是 React state(`ghost`),而 `pointermove` 与 `pointerup` 落在同一批次时 state 还没刷新 → 明明拖了却不落盘。改为在 `dragRef` 上记同步标记。
8
+ ② `requestAnimationFrame` / `cancelAnimationFrame` 按全局调用:浏览器里有,但模块作用域下应当显式走 `window.*`。
9
+ ③ 三处 React key 警告(日期头、事件块、幽灵块、日列的子节点用了数组却没给 key)——dev 模式控制台一直在吵。
10
+
3
11
  ## 0.8.0(2026-09-19)
4
12
 
5
13
  - **新增:周视图拖拽改期**。拖动事件块上下改时间、左右换天(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
@@ -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
- const visible = days.some((day) => dayKey(day) === slot.dayKey) && ghost !== null;
839
- if (visible === true) onReschedule(drag.event, rangeOfSlot(slot), drag.mode);
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
- return h("div", { key, style: { minWidth: 0 } }, [
1029
- h("div", { className: "dshc-dayhead" }, [h("b", null, String(day.getDate())), " ", relativeDay(day)]),
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
- return h("div", null, [
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
  /** 议程视图:从今天起按天分组,最像「接下来要干什么」。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-calendar",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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": {