@arcaneorion/dsh-teaching-board 0.5.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +28 -2
  2. package/package.json +1 -1
  3. package/src/client.js +177 -15
package/README.md CHANGED
@@ -16,7 +16,7 @@ dsh plugin --profile web add @arcaneorion/dsh-teaching-board
16
16
  它是一个 **profile 级 bundle**:装一次,这个进程里所有会话都拿得到 `stage_*` 工具与「教学平面」页签。
17
17
  (`dsh plugin add` 会把依赖与 bundles 条目一起写进该 profile 的 manifest。)
18
18
 
19
- > **发布状态**:`@arcaneorion/dsh-teaching-board` 已发布(`0.4.0` 于 2026-09-15 10:17 CST 上线,当前 `0.5.0`)。
19
+ > **发布状态**:`@arcaneorion/dsh-teaching-board` 已发布(`0.4.0` 于 2026-09-15 10:17 CST 上线,当前 `0.5.1`)。
20
20
  > 改名前的 `@arcaneorion/dsh-stage-panel@0.1.0` 仍在 registry 上,对应本仓早期版本——**别再用**,
21
21
  > 装它只会拿到残缺面板(`npm deprecate @arcaneorion/dsh-stage-panel "renamed to @arcaneorion/dsh-teaching-board"` 可让老名字自己说明去向)。
22
22
 
@@ -68,6 +68,7 @@ dsh plugin --profile web add @arcaneorion/dsh-teaching-board
68
68
  | `skills/stage-panel/` | 行为层:使用手册 `SKILL.md` + 选型/布局/视觉三份规范 + 五个素材库 |
69
69
  | `src/client.js` | client 半:「教学平面」视图(工具栏 + 注入 runtime + 截图交付 + agent 请求监听) |
70
70
  | `package.json` / `cordis.patch.yml` | bundle 声明(行 id `teaching-board`),挂载进 `web` profile |
71
+ | `test/ink-anchor.html` + `test/serve.mjs` | 审计测试:笔迹锚定的 5 项断言(改注入 runtime 后必跑)。不进 npm 包(`files` 未含 `test`) |
71
72
  | `PROTOTYPE-*.js` | 早期动态原型(`lwst-2`)存档,仅供历史参考 |
72
73
 
73
74
  ## 分层
@@ -99,7 +100,11 @@ dsh plugin --profile web add @arcaneorion/dsh-teaching-board
99
100
 
100
101
  **保留与边界**:
101
102
  - 笔迹按 `board` id 存在客户端,板面更新后由父层重新注入(`cmd:'load'`);滚动位置同理。
102
- - 笔迹是**视口坐标**:`append` 只往板尾加内容、已有内容不动,所以笔迹对得上;若演进时上半部分重排,旧笔迹会错位——规范是「接着写」时保持板上半部分稳定。
103
+ - **笔迹锚定在板面内容上(书页感)**:画布是视口大小的浮层,但笔迹坐标存的是**文档坐标**,
104
+ 重绘时按滚动量平移,并记下「这一笔写在哪一段(`.stg-block`)上」当锚点。
105
+ 于是滚动、以及 AI 在它上面改写内容导致的重排,笔迹都跟着那段文字一起走 ——
106
+ 不会像旧版那样钉在屏幕上(旧版存视口坐标,滚动一下圈就脱开,实测漂移 = 滚动距离)。
107
+ 块被删掉时退回文档坐标。审计测试见 `test/ink-anchor.html`。
103
108
  - 笔迹存在每个客户端自己那里:同一会话开了两个浏览器,两边各画各的,不互相同步。
104
109
  - 第一块(`op:"open"`)决定整块板的样式:后续片段共享它的 CSS,所以视觉规范要写在开板那一次里。
105
110
 
@@ -120,5 +125,26 @@ dsh plugin --profile web install # 补 link symlink(改包源后必须
120
125
  # 视图槽位 occupancy 出现 id=teaching-board;工具目录出现 stage_*
121
126
  ```
122
127
 
128
+ ### 审计测试(改注入 runtime 后必跑)
129
+
130
+ ```bash
131
+ node test/serve.mjs # 默认 8130,打印测试页地址
132
+ # 打开 http://127.0.0.1:8130/test/ink-anchor.html
133
+ ```
134
+
135
+ `test/ink-anchor.html` 直接在浏览器里把 `src/client.js` 的注入 runtime 抽出来跑,
136
+ 构造合成板面并断言 5 件事(页面右侧实时显示每项实测数字,`window.__audit` 给出结构化结果):
137
+
138
+ | | 断言 | 说明 |
139
+ |---|---|---|
140
+ | A | 画下去就对准那段文字 | 笔迹与文字中心差 ≤ 3px |
141
+ | B | 笔迹坐标 = 文档坐标 | 存下的 y ≈ 屏幕 y + 滚动量(旧版存的是视口坐标,这项必红) |
142
+ | C | 滚动 200px 后不脱开 | 笔迹位移 = 内容位移(旧版笔迹不动 → 漂移 = 滚动距离) |
143
+ | D | 上方段落改写后不脱开 | 走的是段落锚点(`.stg-block`)而不是绝对坐标 |
144
+ | E | 截图里笔迹与文字对齐 | 截图按当前视口(`viewBox`)截取,图内两者仍然对齐 |
145
+
146
+ > 用旧版(`git show HEAD:src/client.js > src/client.js`)跑过:B/C/D/E 四项变红,
147
+ > 说明这组断言确实盯得住这个 bug —— 不是「跑过就算过」的测试。
148
+
123
149
  > 静态 bundle 改动**需要重启 dsh 进程并刷新页面**,与动态 Cordis 插件的热更新不同。
124
150
  > 其它静态化踩坑(boot 解析 / host 需 JS 源 / ModuleLoader handoff / 空配置分支)详见 `../README.md` §4。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcaneorion/dsh-teaching-board",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "exports": {
package/src/client.js CHANGED
@@ -56,12 +56,22 @@ window.__ModuleLoader__.load({
56
56
  if (window.__stagePanelInk) return;
57
57
  var P = { mode: 'off', color: '#e5484d', size: 3 };
58
58
  var strokes = [], cur = null, drawing = false;
59
+ var anchorCache = {}; // 一次重绘内,同一块只算一次位移
60
+ var lastSX = -1, lastSY = -1; // 上次重绘时的滚动位置(判断要不要重画)
59
61
  var MAX_BYTES = 1200 * 1024;
60
62
 
61
63
  function send(m) { try { parent.postMessage(m, '*'); } catch (e) {} }
62
64
 
63
65
  var cv = document.createElement('canvas');
64
66
  cv.setAttribute('data-stage-ink', '');
67
+ /**
68
+ * 画布仍是**视口大小**(内存恒定,不受板面长度影响 —— 铺满整份文档在长板子上
69
+ * 会变成几十 MB 的位图,移动端还有 canvas 面积上限),
70
+ * 但笔迹存的是**文档坐标**,重绘时按滚动量平移 —— 于是它钉在板面内容上。
71
+ *
72
+ * 原实现存 clientX/clientY 且不平移:笔迹其实钉在**屏幕**上,
73
+ * 一滚动(或 AI 增量写板把内容推下去)就与内容错位。
74
+ */
65
75
  cv.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;touch-action:none;';
66
76
  (document.body || document.documentElement).appendChild(cv);
67
77
  var g = cv.getContext('2d');
@@ -69,11 +79,20 @@ window.__ModuleLoader__.load({
69
79
  function dpr() { return window.devicePixelRatio || 1; }
70
80
  function fit() {
71
81
  var d = dpr();
72
- cv.width = Math.max(1, Math.round(window.innerWidth * d));
73
- cv.height = Math.max(1, Math.round(window.innerHeight * d));
82
+ var w = Math.max(1, Math.round(window.innerWidth));
83
+ var h = Math.max(1, Math.round(window.innerHeight));
84
+ cv.style.width = w + 'px';
85
+ cv.style.height = h + 'px';
86
+ var nw = Math.max(1, Math.round(w * d));
87
+ var nh = Math.max(1, Math.round(h * d));
88
+ if (nw === cv.width && nh === cv.height) return; // 尺寸没变就别白清一次位图
89
+ cv.width = nw;
90
+ cv.height = nh;
74
91
  redraw();
75
92
  }
76
- function paintStroke(s) {
93
+ function paintStroke(s, dx, dy) {
94
+ dx = dx || 0;
95
+ dy = dy || 0;
77
96
  g.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
78
97
  g.strokeStyle = s.color;
79
98
  g.lineWidth = s.erase ? s.size * 5 : s.size;
@@ -81,19 +100,45 @@ window.__ModuleLoader__.load({
81
100
  g.lineJoin = 'round';
82
101
  g.beginPath();
83
102
  for (var i = 0; i < s.pts.length; i++) {
84
- if (i === 0) g.moveTo(s.pts[i][0], s.pts[i][1]); else g.lineTo(s.pts[i][0], s.pts[i][1]);
103
+ if (i === 0) g.moveTo(s.pts[i][0] + dx, s.pts[i][1] + dy);
104
+ else g.lineTo(s.pts[i][0] + dx, s.pts[i][1] + dy);
85
105
  }
86
- if (s.pts.length === 1) g.lineTo(s.pts[0][0] + 0.1, s.pts[0][1]);
106
+ if (s.pts.length === 1) g.lineTo(s.pts[0][0] + dx + 0.1, s.pts[0][1] + dy);
87
107
  g.stroke();
88
108
  g.globalCompositeOperation = 'source-over';
89
109
  }
90
110
  function redraw() {
91
111
  var d = dpr();
112
+ lastSX = window.scrollX || 0;
113
+ lastSY = window.scrollY || 0;
114
+ anchorCache = {}; // 每次重绘重新解析锚点(块可能刚被 AI 改写 / 移动过)
92
115
  g.setTransform(1, 0, 0, 1, 0, 0);
93
116
  g.clearRect(0, 0, cv.width, cv.height);
94
117
  g.setTransform(d, 0, 0, d, 0, 0);
95
- for (var i = 0; i < strokes.length; i++) paintStroke(strokes[i]);
96
- if (cur) paintStroke(cur);
118
+ // ★ 视口画布 + 按滚动量平移:笔迹存的是文档坐标,平移之后正好落在板面内容上。
119
+ g.translate(-lastSX, -lastSY);
120
+ for (var i = 0; i < strokes.length; i++) {
121
+ var off = strokeDelta(strokes[i]);
122
+ paintStroke(strokes[i], off[0], off[1]);
123
+ }
124
+ if (cur) {
125
+ var offCur = strokeDelta(cur);
126
+ paintStroke(cur, offCur[0], offCur[1]);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 滚动 = 笔迹要跟着内容走。
132
+ *
133
+ * ⚠️ 必须**同步**重画,不能塞进 requestAnimationFrame:
134
+ * 后台标签页 / 被遮挡的 iframe 里 rAF 会被浏览器暂停,那样笔迹就定格在旧位置
135
+ * (实测:审计测试 C/D 两项就是被这个坑掉的)。浏览器本来就把 scroll 事件
136
+ * 节流到每帧一次,同步重画不会把主线程刷爆。
137
+ */
138
+ function syncScroll() {
139
+ var x = window.scrollX || 0, y = window.scrollY || 0;
140
+ if (x === lastSX && y === lastSY) return; // 位置没动就别白画一遍
141
+ redraw();
97
142
  }
98
143
  function count() { send({ __stagePanel: true, type: 'ink', strokes: strokes.length, data: strokes }); }
99
144
 
@@ -137,17 +182,89 @@ window.__ModuleLoader__.load({
137
182
  if (n && n.parentNode) n.parentNode.removeChild(n);
138
183
  }
139
184
 
185
+ /** 视口坐标 → **文档坐标**(笔迹的坐标系:原点 = 文档左上角)。
186
+ 画布是视口大小的浮层,指针事件给的是 clientX/clientY,所以必须加上
187
+ 当前滚动量换算成文档坐标;原实现直接把 clientX/clientY 存下来,
188
+ 笔迹其实钉在屏幕上 —— 一滚动、或 AI 增量写板把内容推下去,就与内容错位。 */
189
+ function toBoard(e) {
190
+ var r = cv.getBoundingClientRect();
191
+ return [
192
+ e.clientX - r.left + (window.scrollX || 0),
193
+ e.clientY - r.top + (window.scrollY || 0),
194
+ ];
195
+ }
196
+
197
+ /**
198
+ * 这一笔落在哪一块上(.stg-block),记下它的**文档原点**当锚点。
199
+ *
200
+ * 为什么需要锚点:文档坐标只解决「滚动」,解决不了「重排」——
201
+ * AI 在用户圈过的那段上面又写了一段(set/remove 也会改变高度),
202
+ * 那段文字整体下移,而笔迹的文档坐标没变,于是又脱开了。
203
+ * 锚在「它所在的那一块」上,块移动多少,笔迹就跟着移动多少 —— 这才是写在书上。
204
+ *
205
+ * ⚠️ 不能用 elementFromPoint:画布盖在最上层,内容层根本点不到。
206
+ */
207
+ function anchorAt(clientX, clientY) {
208
+ var blocks = document.querySelectorAll('.stg-block');
209
+ for (var i = 0; i < blocks.length; i++) {
210
+ var r = blocks[i].getBoundingClientRect();
211
+ if (clientY >= r.top && clientY <= r.bottom && clientX >= r.left && clientX <= r.right) {
212
+ return {
213
+ region: blocks[i].getAttribute('data-stage-region') || null,
214
+ index: i,
215
+ ox: r.left + (window.scrollX || 0),
216
+ oy: r.top + (window.scrollY || 0),
217
+ };
218
+ }
219
+ }
220
+ return null; // 落在块外的空白上(比如最后一块下面那片留白):只能靠文档坐标
221
+ }
222
+
223
+ /** 锚点现在跑到哪了(相对画下去时的位置)。块没了就退回绝对坐标。 */
224
+ function anchorDeltaByKey(key) {
225
+ var cached = anchorCache[key];
226
+ if (cached !== undefined) return cached;
227
+
228
+ var a = JSON.parse(key); // [region, index, ox, oy]
229
+ var node = null;
230
+ if (a[0]) {
231
+ try { node = document.querySelector('[data-stage-region="' + String(a[0]).replace(/"/g, '') + '"]'); } catch (e) { node = null; }
232
+ }
233
+ if (!node) {
234
+ var all = document.querySelectorAll('.stg-block');
235
+ node = all[a[1]] || null;
236
+ }
237
+ var out = [0, 0];
238
+ if (node) {
239
+ var r = node.getBoundingClientRect();
240
+ out = [r.left + (window.scrollX || 0) - a[2], r.top + (window.scrollY || 0) - a[3]];
241
+ }
242
+ anchorCache[key] = out;
243
+ return out;
244
+ }
245
+
246
+ function strokeDelta(s) {
247
+ var a = s.anchor;
248
+ if (!a) return [0, 0];
249
+ return anchorDeltaByKey(JSON.stringify([a.region, a.index, Math.round(a.ox), Math.round(a.oy)]));
250
+ }
251
+
140
252
  cv.addEventListener('pointerdown', function (e) {
141
253
  if (P.mode === 'off') return;
142
254
  if (forwardClick(e.clientX, e.clientY)) return;
143
255
  drawing = true;
144
256
  try { cv.setPointerCapture(e.pointerId); } catch (err) {}
145
- cur = { color: P.color, size: P.size, erase: P.mode === 'erase', pts: [[e.clientX, e.clientY]] };
257
+ cur = {
258
+ color: P.color, size: P.size, erase: P.mode === 'erase',
259
+ pts: [toBoard(e)],
260
+ // 记下「这一笔写在哪一段上」—— 那一段以后被推下去,笔迹跟着它走
261
+ anchor: anchorAt(e.clientX, e.clientY),
262
+ };
146
263
  redraw();
147
264
  });
148
265
  cv.addEventListener('pointermove', function (e) {
149
266
  if (!drawing) return;
150
- cur.pts.push([e.clientX, e.clientY]);
267
+ cur.pts.push(toBoard(e));
151
268
  redraw();
152
269
  });
153
270
  function end() {
@@ -166,6 +283,8 @@ window.__ModuleLoader__.load({
166
283
  return new Promise(function (resolve, reject) {
167
284
  var w = Math.max(1, Math.round(window.innerWidth));
168
285
  var h = Math.max(1, Math.round(window.innerHeight));
286
+ var sx = Math.max(0, window.scrollX || 0);
287
+ var sy = Math.max(0, window.scrollY || 0);
169
288
  var clone = document.documentElement.cloneNode(true);
170
289
  var drop = clone.querySelectorAll('[data-stage-capture-ignore]');
171
290
  for (var d = 0; d < drop.length; d++) drop[d].parentNode.removeChild(drop[d]);
@@ -173,15 +292,31 @@ window.__ModuleLoader__.load({
173
292
  var dsts = clone.querySelectorAll('canvas');
174
293
  for (var i = 0; i < srcs.length && i < dsts.length; i++) {
175
294
  var st = window.getComputedStyle(srcs[i]);
295
+ var rect = srcs[i].getBoundingClientRect();
176
296
  var im = document.createElement('img');
177
297
  try { im.setAttribute('src', srcs[i].toDataURL('image/png')); } catch (e) { continue; }
178
- im.setAttribute('style', 'position:fixed;left:' + st.left + ';top:' + st.top +
179
- ';width:' + st.width + ';height:' + st.height + ';');
298
+ /**
299
+ * ★ img 要放到**文档坐标**上,不能照抄 computed left/top。
300
+ *
301
+ * 画布是 position:fixed(视口锚定)的浮层,它的 left/top 恒为 0px ——
302
+ * 照抄的话,位图会被贴到「文档最顶端」,而它画的却是当前这一屏的笔迹,
303
+ * 于是截图里的圈和内容差着整整一个滚动距离。
304
+ * 位图本身画的是「当前视口 + 滚动量平移」的结果,所以要换算成文档坐标再绝对定位;
305
+ * 再配合下面 SVG 的 viewBox(= 当前视口),拍到的才是「你正在看的那一屏」。
306
+ */
307
+ var left = Math.round(rect.left + sx);
308
+ var top = Math.round(rect.top + sy);
309
+ im.setAttribute('style', 'position:absolute;left:' + left + 'px;top:' + top + 'px;' +
310
+ 'width:' + st.width + ';height:' + st.height + ';z-index:2147483000;');
180
311
  dsts[i].parentNode.replaceChild(im, dsts[i]);
181
312
  }
182
313
  var xml = new XMLSerializer().serializeToString(clone);
314
+ // 文档可能比视口长:foreignObject 装整份文档,viewBox 只框当前视口 ——
315
+ // 拍到的就是你正在看的区域(含笔迹),而不是文档开头。
316
+ var docW = Math.max(w, document.documentElement.scrollWidth || 0);
317
+ var docH = Math.max(h, document.documentElement.scrollHeight || 0);
183
318
  var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h +
184
- '" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject x="0" y="0" width="' + w + '" height="' + h +
319
+ '" viewBox="' + sx + ' ' + sy + ' ' + w + ' ' + h + '"><foreignObject x="0" y="0" width="' + docW + '" height="' + docH +
185
320
  '">' + xml + '</foreignObject></svg>';
186
321
  var img = new Image();
187
322
  img.onload = function () {
@@ -257,19 +392,42 @@ window.__ModuleLoader__.load({
257
392
  else if (po.op === 'set') setRegionBlock(po.region, po.html);
258
393
  else if (po.op === 'remove') removeRegionBlock(po.region);
259
394
  }
395
+ // 内容长高不影响画布(画布是视口大小、按滚动量平移),不用重新 fit
260
396
  } else if (d.cmd === 'load') {
261
397
  // 同一块板的演进:把父层保存的笔迹重新注入新板。
262
398
  strokes = Array.isArray(d.strokes) ? d.strokes : [];
263
- redraw();
264
- count();
265
399
  if (typeof d.scrollY === 'number' && d.scrollY > 0) {
266
400
  try { window.scrollTo(0, d.scrollY); } catch (e) {}
267
401
  }
402
+ redraw(); // 平移量取决于滚动位置,滚动还原之后必须重画一次
403
+ count();
268
404
  } else if (d.cmd === 'ping') {
269
405
  send({ __stagePanel: true, type: 'ready' });
270
406
  }
271
407
  });
272
408
 
409
+ /**
410
+ * 滚动 = 笔迹要跟着内容走(syncScroll 定义在上面,和 redraw 放一起)。
411
+ *
412
+ * ⚠️ 必须**同步**重画,不能塞进 requestAnimationFrame:
413
+ * 后台标签页 / 被遮挡的 iframe 里 rAF 会被浏览器暂停,那样笔迹就定格在旧位置
414
+ * (实测:审计测试 C/D 两项就是被这个坑掉的)。浏览器本来就把 scroll 事件
415
+ * 节流到每帧一次,同步重画不会把主线程刷爆。
416
+ */
417
+ window.addEventListener('scroll', syncScroll, { passive: true });
418
+ /**
419
+ * 回到前台 / 重新获得焦点时**强制**重画一次。
420
+ *
421
+ * 为什么需要:滚动事件属于「渲染帧」的生命周期 —— 标签页被遮挡、iframe 不在视口里时
422
+ * 浏览器会暂停出帧,scroll 事件也就不派发,位图可能停在旧位置;
423
+ * 用户切回来第一眼就会看到错位的笔迹。(这也正是自动化测试在后台标签页里
424
+ * 会看到「滚动之后笔迹不动」的原因 —— 不是坐标算错了,是事件没来。)
425
+ */
426
+ function forceRedraw() { lastSX = -1; lastSY = -1; syncScroll(); }
427
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) forceRedraw(); });
428
+ window.addEventListener('focus', forceRedraw);
429
+ window.addEventListener('pageshow', forceRedraw);
430
+
273
431
  // 把滚动位置报给父层,换板后用来还原(学生正在看的那一段不该跳回顶部)。
274
432
  var scrollTimer = null;
275
433
  window.addEventListener('scroll', function () {
@@ -280,7 +438,11 @@ window.__ModuleLoader__.load({
280
438
  }, 120);
281
439
  });
282
440
 
283
- window.addEventListener('resize', fit);
441
+ window.addEventListener('resize', function () {
442
+ fit();
443
+ lastSX = -1; lastSY = -1; // 尺寸/方向变了,强制重画一次
444
+ syncScroll();
445
+ });
284
446
  fit();
285
447
  count();
286
448
  send({ __stagePanel: true, type: 'ready' });