dsh-plugin-lookatstudy 0.26.1 → 0.26.3

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/lib/client.js CHANGED
@@ -5900,233 +5900,628 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5900
5900
  return toastStore.show(message, opts);
5901
5901
  }
5902
5902
  //#endregion
5903
- //#region src/client/celebration.ts
5904
- const listeners = /* @__PURE__ */ new Set();
5905
- /** 触发一次庆祝(任何组件都可调)。渲染由 <CelebrationLayer> 统一处理。 */
5906
- function celebrate(kind, opts) {
5907
- const e = {
5908
- kind,
5909
- intensity: opts?.intensity,
5910
- origin: opts?.origin,
5911
- ts: typeof performance !== "undefined" ? performance.now() : Date.now()
5912
- };
5913
- for (const l of listeners) l(e);
5903
+ //#region src/vendor/panzoom.ts
5904
+ /** 适屏(contain)缩放:内容完整装进视口(留 padding 边距),不超出 1。 */
5905
+ function fitScale(contentW, contentH, viewW, viewH, padding = 24) {
5906
+ if (contentW <= 0 || contentH <= 0 || viewW <= 0 || viewH <= 0) return 1;
5907
+ const s = Math.min((viewW - padding * 2) / contentW, (viewH - padding * 2) / contentH, 1);
5908
+ return Math.max(.02, s);
5914
5909
  }
5915
- /** 订阅庆祝事件(<CelebrationLayer> 用)。返回取消订阅。 */
5916
- function onCelebration(l) {
5917
- listeners.add(l);
5918
- return () => {
5919
- listeners.delete(l);
5910
+ /** 适屏位姿:居中。 */
5911
+ function fitTransform(contentW, contentH, viewW, viewH, padding = 24) {
5912
+ const scale = fitScale(contentW, contentH, viewW, viewH, padding);
5913
+ return {
5914
+ scale,
5915
+ x: (viewW - contentW * scale) / 2,
5916
+ y: (viewH - contentH * scale) / 2
5920
5917
  };
5921
5918
  }
5922
- /** 按 kind 推断默认粒子数/持续时长/配色(upstream verbatim)。 */
5923
- function celebrationDefaults(kind) {
5924
- switch (kind) {
5925
- case "correct": return {
5926
- particles: 28,
5927
- durationMs: 700,
5928
- colors: [
5929
- "#58cc02",
5930
- "#7ed957",
5931
- "#ffc800"
5932
- ]
5933
- };
5934
- case "mastery":
5935
- case "level-up": return {
5936
- particles: 48,
5937
- durationMs: 1100,
5938
- colors: [
5939
- "#ffc800",
5940
- "#ffe680",
5941
- "#fff7c2"
5942
- ]
5943
- };
5944
- case "unlock": return {
5945
- particles: 32,
5946
- durationMs: 800,
5947
- colors: [
5948
- "#58cc02",
5949
- "#1cb0f6",
5950
- "#ffffff"
5951
- ]
5952
- };
5953
- case "exam-pass": return {
5954
- particles: 56,
5955
- durationMs: 1200,
5956
- colors: [
5957
- "#a855f7",
5958
- "#c084fc",
5959
- "#ffc800"
5960
- ]
5961
- };
5962
- case "lesson-complete": return {
5963
- particles: 40,
5964
- durationMs: 1e3,
5965
- colors: [
5966
- "#58cc02",
5967
- "#ffc800",
5968
- "#1cb0f6"
5969
- ]
5970
- };
5971
- case "energy-full": return {
5972
- particles: 36,
5973
- durationMs: 900,
5974
- colors: [
5975
- "#58cc02",
5976
- "#7ed957",
5977
- "#ffffff"
5978
- ]
5979
- };
5980
- case "streak": return {
5981
- particles: 24,
5982
- durationMs: 700,
5983
- colors: [
5984
- "#ff7a1a",
5985
- "#ffc800",
5986
- "#ffffff"
5987
- ]
5988
- };
5989
- case "wrong": return {
5990
- particles: 12,
5991
- durationMs: 450,
5992
- colors: ["#ff4b4b", "#ff7a7a"]
5993
- };
5994
- default: return {
5995
- particles: 20,
5996
- durationMs: 600,
5997
- colors: ["#58cc02", "#ffffff"]
5998
- };
5999
- }
6000
- }
6001
- /** The reduced-motion fallback glyph per kind (semantic color, drawn statically). */
6002
- function iconFor(kind) {
6003
- switch (kind) {
6004
- case "correct": return {
6005
- icon: "✓",
6006
- color: "var(--brand)"
6007
- };
6008
- case "wrong": return {
6009
- icon: "✕",
6010
- color: "var(--warning)"
6011
- };
6012
- case "mastery":
6013
- case "level-up": return {
6014
- icon: "♛",
6015
- color: "var(--gold)"
6016
- };
6017
- case "unlock": return {
6018
- icon: "✦",
6019
- color: "var(--brand)"
6020
- };
6021
- case "streak": return {
6022
- icon: "▲",
6023
- color: "var(--review)"
6024
- };
6025
- case "energy-full": return {
6026
- icon: "⚡",
6027
- color: "var(--brand)"
6028
- };
6029
- case "exam-pass": return {
6030
- icon: "✪",
6031
- color: "var(--exam)"
6032
- };
6033
- case "lesson-complete": return {
6034
- icon: "☆",
6035
- color: "var(--gold)"
6036
- };
6037
- default: return {
6038
- icon: "✦",
6039
- color: "var(--brand)"
6040
- };
6041
- }
6042
- }
6043
5919
  /**
6044
- * Diff two poll snapshots into the celebrations to fire (pure — the panel
6045
- * maps unlock ids to on-screen bubble anchors). First sight (prev null)
6046
- * seeds every baseline silently — upstream seeds its refs from the initial
6047
- * fetch BEFORE subscribing, so a page opened mid-fulfilled-day never
6048
- * celebrates; streak fires only on an INCREASE; energy-full on the first
6049
- * goal crossing (prev < goal ≤ now).
5920
+ * 锚点缩放:factor>1 放大。锚点 (ax,ay) 是视口坐标 —— 缩放后锚点对准的内容点
5921
+ * 保持不动(标准图像查看器数学:new = anchor - (anchor - old) * factor)。
6050
5922
  */
6051
- function celebrationDiff(prev, next) {
6052
- const out = [];
6053
- if (prev !== null) {
6054
- const was = new Map(prev.lessons.map((l) => [l.id, l.status]));
6055
- for (const l of next.lessons) {
6056
- const before = was.get(l.id);
6057
- if (before === void 0 || before === l.status) continue;
6058
- if (before === "locked" && l.status === "available") out.push({
6059
- kind: "unlock",
6060
- lessonId: l.id
6061
- });
6062
- else if (l.status === "mastered") out.push({
6063
- kind: "mastery",
6064
- lessonId: l.id
6065
- });
6066
- }
6067
- if (next.streak > prev.streak) out.push({ kind: "streak" });
6068
- }
6069
- if (prev === null) return [];
6070
- const full = next.dailyGoal > 0 && next.todayXp >= next.dailyGoal;
6071
- const wasFull = prev.dailyGoal > 0 && prev.todayXp >= prev.dailyGoal;
6072
- if (full && !wasFull) out.push({ kind: "energy-full" });
6073
- return out;
5923
+ function zoomAt(t, factor, ax, ay) {
5924
+ return {
5925
+ scale: t.scale * factor,
5926
+ x: ax - (ax - t.x) * factor,
5927
+ y: ay - (ay - t.y) * factor
5928
+ };
6074
5929
  }
6075
5930
  /**
6076
- * Seed one burst's particles (pure; upstream burst() extracted for tests).
6077
- * `rand` injects randomness so tests can freeze the spread.
5931
+ * 平移钳制:内容不会被拖到完全离屏 —— 至少留 `keep`(默认 64px)可见,
5932
+ * 且内容小于视口时不允许拖走(保持居中观感)。
6078
5933
  */
6079
- function seedBurst(event, viewport, rand = Math.random) {
6080
- const def = celebrationDefaults(event.kind);
6081
- const n = Math.round(def.particles * (event.intensity ?? 1));
6082
- const ox = event.origin?.x ?? viewport.w / 2;
6083
- const oy = event.origin?.y ?? viewport.h / 2;
6084
- const out = [];
6085
- for (let i = 0; i < n; i++) {
6086
- const angle = Math.PI * 2 * i / n + rand() * .3;
6087
- const speed = 3 + rand() * 6;
6088
- const maxLife = def.durationMs / 16 * (.7 + rand() * .5);
6089
- out.push({
6090
- x: ox,
6091
- y: oy,
6092
- vx: Math.cos(angle) * speed,
6093
- vy: Math.sin(angle) * speed - 2,
6094
- life: maxLife,
6095
- maxLife,
6096
- color: def.colors[i % def.colors.length],
6097
- size: 4 + rand() * 5,
6098
- rot: rand() * Math.PI,
6099
- vr: (rand() - .5) * .3,
6100
- shape: rand() < .5 ? "rect" : "circle"
6101
- });
6102
- }
6103
- return out;
6104
- }
6105
- /** Advance one particle one frame (pure; gravity + drag + spin). Dead ⇒ null. */
6106
- function stepParticle(p) {
6107
- const life = p.life - 1;
6108
- if (life <= 0) return null;
5934
+ function clampPan(t, contentW, contentH, viewW, viewH, keep = 64) {
5935
+ const w = contentW * t.scale;
5936
+ const h = contentH * t.scale;
5937
+ let { x, y } = t;
5938
+ if (w <= viewW) x = (viewW - w) / 2;
5939
+ else x = Math.min(Math.max(x, viewW - w - keep), keep);
5940
+ if (h <= viewH) y = (viewH - h) / 2;
5941
+ else y = Math.min(Math.max(y, viewH - h - keep), keep);
6109
5942
  return {
6110
- ...p,
6111
- x: p.x + p.vx,
6112
- y: p.y + p.vy,
6113
- vy: p.vy + .25,
6114
- vx: p.vx * .99,
6115
- rot: p.rot + p.vr,
6116
- life
5943
+ x,
5944
+ y,
5945
+ scale: t.scale
6117
5946
  };
6118
5947
  }
6119
- /** Fading alpha 1 → 0 across the particle's life (pure). */
6120
- function particleAlpha(p) {
6121
- return Math.min(1, p.life / p.maxLife);
5948
+ /** 钳制缩放(先算锚点缩放,再夹 scale 到 [min,max],再钳平移)。 */
5949
+ function zoomAtClamped(t, factor, ax, ay, bounds) {
5950
+ const target = t.scale * factor;
5951
+ const clampedFactor = Math.min(Math.max(target, bounds.min), bounds.max) / t.scale;
5952
+ if (clampedFactor === 1) return t;
5953
+ return clampPan(zoomAt(t, clampedFactor, ax, ay), bounds.contentW, bounds.contentH, bounds.viewW, bounds.viewH);
6122
5954
  }
5955
+ //#endregion
5956
+ //#region src/client/canvasstage.tsx
6123
5957
  /**
6124
- * The exits after one finished card — never fewer than two (the anti-dead-end
6125
- * promise). Wrong answers → patch the hole first; perfect + near-mastered →
6126
- * offer the mastery exit; perfect + already graduated → move on.
6127
- * Pure port of upstream getPostQuizActions.
6128
- */
6129
- function getPostQuizActions(score, mastery) {
5958
+ * CanvasStage (P14, upstream CanvasStage.tsx port): the canvas-style artifact
5959
+ * stage — content keeps its natural size and one transform (translate+scale)
5960
+ * does everything. Gestures: single-pointer drag pan, two-pointer pinch
5961
+ * anchored at the midpoint, wheel zoom anchored at the cursor, double
5962
+ * click/tap toggles fit↔100%. ResizeObserver refits on content/stage size
5963
+ * changes (mermaid's async svg) — through the #12 gates: value-equality on
5964
+ * setSize, a bounds-differ short-circuit on refit, rAF coalescing, and a
5965
+ * recurrence-tripping oscillation breaker (see makeRefitBreaker). A floating
5966
+ * −/%/fit/+ toolbar stays mounted. Shared by the notebook's board tab and
5967
+ * the diagram modals.
5968
+ * (lucide glyphs collapse to text/plugin icons; the client stays dependency-
5969
+ * free.)
5970
+ * @module dsh-plugin-lookatstudy/client/canvasstage
5971
+ */
5972
+ const MIN_SCALE = .05;
5973
+ const MAX_SCALE = 4;
5974
+ /** 工具条步进(手势/滚轮连续,按钮给干脆的一档)。 */
5975
+ const STEP = 1.25;
5976
+ /** True when the two bounds readings differ in ANY field — the auto-refit
5977
+ * short-circuit. The pre-fix refit ran fitTransform on every RO callback with
5978
+ * zero hysteresis, so any feedback touching a size re-fired it forever. */
5979
+ function stageBoundsDiffer(a, b) {
5980
+ if (a === null) return true;
5981
+ return a.contentW !== b.contentW || a.contentH !== b.contentH || a.viewW !== b.viewW || a.viewH !== b.viewH;
5982
+ }
5983
+ function makeRefitBreaker(opts = {}) {
5984
+ const windowMs = opts.windowMs ?? 2e3;
5985
+ const now = opts.now ?? (() => performance.now());
5986
+ let seen = [];
5987
+ let tripped = false;
5988
+ return {
5989
+ allow(key) {
5990
+ const t = now();
5991
+ seen = seen.filter((s) => t - s.t < windowMs);
5992
+ if (tripped) return false;
5993
+ if (seen.some((s) => s.key === key)) {
5994
+ tripped = true;
5995
+ return false;
5996
+ }
5997
+ seen.push({
5998
+ t,
5999
+ key
6000
+ });
6001
+ return true;
6002
+ },
6003
+ reset() {
6004
+ seen = [];
6005
+ tripped = false;
6006
+ },
6007
+ tripped() {
6008
+ return tripped;
6009
+ }
6010
+ };
6011
+ }
6012
+ const boundsKey = (b) => `${String(b.contentW)}x${String(b.contentH)}@${String(b.viewW)}x${String(b.viewH)}`;
6013
+ function CanvasStage({ children, grid = true, testid }) {
6014
+ const stageRef = (0, react.useRef)(null);
6015
+ const contentRef = (0, react.useRef)(null);
6016
+ const [tf, setTf] = (0, react.useState)({
6017
+ x: 0,
6018
+ y: 0,
6019
+ scale: 1
6020
+ });
6021
+ const [size, setSize] = (0, react.useState)(null);
6022
+ /** 活跃手势中禁用过渡(跟手必须即时;按钮缩放才享受平滑)。 */
6023
+ const [gesturing, setGesturing] = (0, react.useState)(false);
6024
+ const lastPointerTypeRef = (0, react.useRef)("mouse");
6025
+ const atFitRef = (0, react.useRef)(true);
6026
+ const gesture = (0, react.useRef)({
6027
+ pointers: /* @__PURE__ */ new Map(),
6028
+ pinchBase: null,
6029
+ lastMid: null,
6030
+ panning: false,
6031
+ moved: false
6032
+ });
6033
+ const lastTapRef = (0, react.useRef)(null);
6034
+ const lastBoundsRef = (0, react.useRef)(null);
6035
+ const breakerRef = (0, react.useRef)(null);
6036
+ if (breakerRef.current === null) breakerRef.current = makeRefitBreaker();
6037
+ const bounds = (0, react.useCallback)(() => {
6038
+ const stage = stageRef.current;
6039
+ const content = contentRef.current;
6040
+ return {
6041
+ min: MIN_SCALE,
6042
+ max: MAX_SCALE,
6043
+ contentW: content?.offsetWidth ?? 0,
6044
+ contentH: content?.offsetHeight ?? 0,
6045
+ viewW: stage?.clientWidth ?? 0,
6046
+ viewH: stage?.clientHeight ?? 0
6047
+ };
6048
+ }, []);
6049
+ const currentBounds = (0, react.useCallback)(() => ({
6050
+ contentW: contentRef.current?.offsetWidth ?? 0,
6051
+ contentH: contentRef.current?.offsetHeight ?? 0,
6052
+ viewW: stageRef.current?.clientWidth ?? 0,
6053
+ viewH: stageRef.current?.clientHeight ?? 0
6054
+ }), []);
6055
+ const applyFit = (0, react.useCallback)((b) => {
6056
+ setTf(fitTransform(b.contentW, b.contentH, b.viewW, b.viewH));
6057
+ atFitRef.current = true;
6058
+ }, []);
6059
+ const refit = (0, react.useCallback)(() => {
6060
+ breakerRef.current?.reset();
6061
+ const b = currentBounds();
6062
+ if (b.contentW === 0 || b.viewW === 0) return;
6063
+ lastBoundsRef.current = b;
6064
+ applyFit(b);
6065
+ }, [currentBounds, applyFit]);
6066
+ const measureNow = (0, react.useCallback)(() => {
6067
+ const content = contentRef.current;
6068
+ if (content === null) return;
6069
+ const w = content.offsetWidth;
6070
+ const h = content.offsetHeight;
6071
+ setSize((prev) => prev !== null && prev.w === w && prev.h === h ? prev : {
6072
+ w,
6073
+ h
6074
+ });
6075
+ const b = currentBounds();
6076
+ if (!stageBoundsDiffer(lastBoundsRef.current, b)) return;
6077
+ lastBoundsRef.current = b;
6078
+ if (b.contentW === 0 || b.viewW === 0) return;
6079
+ if (b.contentW < 120) return;
6080
+ if ((breakerRef.current ?? makeRefitBreaker()).allow(boundsKey(b))) applyFit(b);
6081
+ }, [currentBounds, applyFit]);
6082
+ (0, react.useEffect)(() => {
6083
+ const stage = stageRef.current;
6084
+ const content = contentRef.current;
6085
+ if (stage === null || content === null) return;
6086
+ measureNow();
6087
+ let raf = 0;
6088
+ const onRo = () => {
6089
+ if (raf !== 0) return;
6090
+ raf = requestAnimationFrame(() => {
6091
+ raf = 0;
6092
+ measureNow();
6093
+ });
6094
+ };
6095
+ const ro = new ResizeObserver(onRo);
6096
+ ro.observe(content);
6097
+ ro.observe(stage);
6098
+ return () => {
6099
+ ro.disconnect();
6100
+ if (raf !== 0) cancelAnimationFrame(raf);
6101
+ };
6102
+ }, [measureNow]);
6103
+ const zoomBy = (0, react.useCallback)((factor, ax, ay) => {
6104
+ breakerRef.current?.reset();
6105
+ const b = bounds();
6106
+ const cx = ax ?? b.viewW / 2;
6107
+ const cy = ay ?? b.viewH / 2;
6108
+ setTf((cur) => zoomAtClamped(cur, factor, cx, cy, b));
6109
+ atFitRef.current = false;
6110
+ }, [bounds]);
6111
+ const onPointerDown = (0, react.useCallback)((e) => {
6112
+ if (e.target.closest?.("[data-testid=\"canvas-zoom-controls\"]") !== null) return;
6113
+ const el = e.currentTarget;
6114
+ try {
6115
+ el.setPointerCapture(e.pointerId);
6116
+ } catch {}
6117
+ const g = gesture.current;
6118
+ lastPointerTypeRef.current = e.pointerType;
6119
+ if (g.pointers.size === 0) setGesturing(true);
6120
+ g.pointers.set(e.pointerId, {
6121
+ x: e.clientX,
6122
+ y: e.clientY
6123
+ });
6124
+ if (g.pointers.size === 2) {
6125
+ const [p1, p2] = [...g.pointers.values()];
6126
+ g.pinchBase = {
6127
+ dist: Math.hypot(p1.x - p2.x, p1.y - p2.y),
6128
+ factorAcc: 1
6129
+ };
6130
+ g.lastMid = {
6131
+ x: (p1.x + p2.x) / 2,
6132
+ y: (p1.y + p2.y) / 2
6133
+ };
6134
+ g.panning = false;
6135
+ } else if (g.pointers.size === 1) {
6136
+ g.panning = true;
6137
+ g.moved = false;
6138
+ g.lastMid = {
6139
+ x: e.clientX,
6140
+ y: e.clientY
6141
+ };
6142
+ }
6143
+ }, []);
6144
+ const onPointerMove = (0, react.useCallback)((e) => {
6145
+ const g = gesture.current;
6146
+ if (!g.pointers.has(e.pointerId)) return;
6147
+ g.pointers.set(e.pointerId, {
6148
+ x: e.clientX,
6149
+ y: e.clientY
6150
+ });
6151
+ const stage = stageRef.current;
6152
+ if (stage === null) return;
6153
+ const rect = stage.getBoundingClientRect();
6154
+ if (g.pointers.size >= 2 && g.pinchBase !== null) {
6155
+ const [p1, p2] = [...g.pointers.values()];
6156
+ const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
6157
+ const mid = {
6158
+ x: (p1.x + p2.x) / 2,
6159
+ y: (p1.y + p2.y) / 2
6160
+ };
6161
+ if (g.pinchBase.dist > 0) {
6162
+ const factor = dist / g.pinchBase.dist / g.pinchBase.factorAcc;
6163
+ g.pinchBase.factorAcc *= factor;
6164
+ zoomBy(factor, mid.x - rect.left, mid.y - rect.top);
6165
+ }
6166
+ if (g.lastMid !== null) {
6167
+ const dx = mid.x - g.lastMid.x;
6168
+ const dy = mid.y - g.lastMid.y;
6169
+ const b = bounds();
6170
+ setTf((cur) => clampPan({
6171
+ ...cur,
6172
+ x: cur.x + dx,
6173
+ y: cur.y + dy
6174
+ }, b.contentW, b.contentH, b.viewW, b.viewH));
6175
+ }
6176
+ g.lastMid = mid;
6177
+ return;
6178
+ }
6179
+ if (g.panning && g.lastMid !== null) {
6180
+ const dx = e.clientX - g.lastMid.x;
6181
+ const dy = e.clientY - g.lastMid.y;
6182
+ if (Math.abs(dx) + Math.abs(dy) > 2) g.moved = true;
6183
+ const b = bounds();
6184
+ setTf((cur) => clampPan({
6185
+ ...cur,
6186
+ x: cur.x + dx,
6187
+ y: cur.y + dy
6188
+ }, b.contentW, b.contentH, b.viewW, b.viewH));
6189
+ g.lastMid = {
6190
+ x: e.clientX,
6191
+ y: e.clientY
6192
+ };
6193
+ }
6194
+ }, [bounds, zoomBy]);
6195
+ const onPointerUp = (0, react.useCallback)((e) => {
6196
+ const g = gesture.current;
6197
+ const wasSingle = g.pointers.size === 1;
6198
+ g.pointers.delete(e.pointerId);
6199
+ if (g.pointers.size < 2) g.pinchBase = null;
6200
+ if (g.pointers.size === 0) {
6201
+ g.panning = false;
6202
+ g.lastMid = null;
6203
+ setGesturing(false);
6204
+ if (wasSingle && !g.moved && e.pointerType !== "mouse") {
6205
+ const now = performance.now();
6206
+ const last = lastTapRef.current;
6207
+ if (last !== null && now - last.t < 320 && Math.hypot(e.clientX - last.x, e.clientY - last.y) < 32) {
6208
+ const b = bounds();
6209
+ if (atFitRef.current) {
6210
+ setTf(clampPan({
6211
+ x: (b.viewW - b.contentW) / 2,
6212
+ y: Math.max(24, (b.viewH - b.contentH) / 2),
6213
+ scale: 1
6214
+ }, b.contentW, b.contentH, b.viewW, b.viewH));
6215
+ atFitRef.current = false;
6216
+ } else refit();
6217
+ lastTapRef.current = null;
6218
+ } else lastTapRef.current = {
6219
+ t: now,
6220
+ x: e.clientX,
6221
+ y: e.clientY
6222
+ };
6223
+ }
6224
+ } else if (g.pointers.size === 1) {
6225
+ const only = [...g.pointers.values()][0];
6226
+ g.lastMid = {
6227
+ x: only.x,
6228
+ y: only.y
6229
+ };
6230
+ g.panning = true;
6231
+ }
6232
+ }, [bounds, refit]);
6233
+ const onWheel = (0, react.useCallback)((e) => {
6234
+ e.preventDefault();
6235
+ const rect = stageRef.current?.getBoundingClientRect();
6236
+ if (rect === void 0) return;
6237
+ zoomBy(e.deltaY < 0 ? STEP : 1 / STEP, e.clientX - rect.left, e.clientY - rect.top);
6238
+ }, [zoomBy]);
6239
+ const onDoubleClick = (0, react.useCallback)(() => {
6240
+ if (lastPointerTypeRef.current !== "mouse") return;
6241
+ const b = bounds();
6242
+ if (atFitRef.current) {
6243
+ setTf(clampPan({
6244
+ x: (b.viewW - b.contentW) / 2,
6245
+ y: Math.max(24, (b.viewH - b.contentH) / 2),
6246
+ scale: 1
6247
+ }, b.contentW, b.contentH, b.viewW, b.viewH));
6248
+ atFitRef.current = false;
6249
+ } else refit();
6250
+ }, [bounds, refit]);
6251
+ return (0, react.createElement)("div", {
6252
+ ref: stageRef,
6253
+ "data-testid": testid,
6254
+ "data-noswipe": "",
6255
+ className: `lks14-stage${grid ? " grid" : ""}`,
6256
+ onPointerDown,
6257
+ onPointerMove,
6258
+ onPointerUp,
6259
+ onPointerCancel: onPointerUp,
6260
+ onWheel,
6261
+ onDoubleClick
6262
+ }, (0, react.createElement)("div", {
6263
+ ref: contentRef,
6264
+ className: `lks14-stage-content${gesturing ? "" : " glide"}`,
6265
+ style: {
6266
+ transform: `translate(${String(tf.x)}px, ${String(tf.y)}px) scale(${String(tf.scale)})`,
6267
+ transformOrigin: "0 0"
6268
+ }
6269
+ }, children), (0, react.createElement)("div", {
6270
+ className: "lks14-stage-tools",
6271
+ "data-testid": "canvas-zoom-controls"
6272
+ }, (0, react.createElement)("button", {
6273
+ onClick: () => {
6274
+ zoomBy(1 / STEP);
6275
+ },
6276
+ disabled: tf.scale <= .050001000000000004,
6277
+ "aria-label": tr("artifact.zoomOut"),
6278
+ "data-testid": "canvas-zoom-out"
6279
+ }, "−"), (0, react.createElement)("span", { "data-testid": "canvas-zoom-pct" }, `${String(Math.round(tf.scale * 100))}%`), (0, react.createElement)("button", {
6280
+ onClick: () => {
6281
+ zoomBy(STEP);
6282
+ },
6283
+ disabled: tf.scale >= 3.999999,
6284
+ "aria-label": tr("artifact.zoomIn"),
6285
+ "data-testid": "canvas-zoom-in"
6286
+ }, (0, react.createElement)(IconPlusOutline16, { size: 14 })), (0, react.createElement)("button", {
6287
+ onClick: () => {
6288
+ refit();
6289
+ },
6290
+ "aria-label": tr("artifact.canvas.fit"),
6291
+ "data-testid": "canvas-zoom-fit"
6292
+ }, (0, react.createElement)(IconMaximizeOutline16, { size: 14 }))), (0, react.createElement)("span", {
6293
+ "data-testid": "canvas-natural-size",
6294
+ className: "lks14-stage-probe"
6295
+ }, size === null ? "" : `${String(size.w)}x${String(size.h)}`));
6296
+ }
6297
+ //#endregion
6298
+ //#region src/client/celebration.ts
6299
+ const listeners = /* @__PURE__ */ new Set();
6300
+ /** 触发一次庆祝(任何组件都可调)。渲染由 <CelebrationLayer> 统一处理。 */
6301
+ function celebrate(kind, opts) {
6302
+ const e = {
6303
+ kind,
6304
+ intensity: opts?.intensity,
6305
+ origin: opts?.origin,
6306
+ ts: typeof performance !== "undefined" ? performance.now() : Date.now()
6307
+ };
6308
+ for (const l of listeners) l(e);
6309
+ }
6310
+ /** 订阅庆祝事件(<CelebrationLayer> 用)。返回取消订阅。 */
6311
+ function onCelebration(l) {
6312
+ listeners.add(l);
6313
+ return () => {
6314
+ listeners.delete(l);
6315
+ };
6316
+ }
6317
+ /** 按 kind 推断默认粒子数/持续时长/配色(upstream verbatim)。 */
6318
+ function celebrationDefaults(kind) {
6319
+ switch (kind) {
6320
+ case "correct": return {
6321
+ particles: 28,
6322
+ durationMs: 700,
6323
+ colors: [
6324
+ "#58cc02",
6325
+ "#7ed957",
6326
+ "#ffc800"
6327
+ ]
6328
+ };
6329
+ case "mastery":
6330
+ case "level-up": return {
6331
+ particles: 48,
6332
+ durationMs: 1100,
6333
+ colors: [
6334
+ "#ffc800",
6335
+ "#ffe680",
6336
+ "#fff7c2"
6337
+ ]
6338
+ };
6339
+ case "unlock": return {
6340
+ particles: 32,
6341
+ durationMs: 800,
6342
+ colors: [
6343
+ "#58cc02",
6344
+ "#1cb0f6",
6345
+ "#ffffff"
6346
+ ]
6347
+ };
6348
+ case "exam-pass": return {
6349
+ particles: 56,
6350
+ durationMs: 1200,
6351
+ colors: [
6352
+ "#a855f7",
6353
+ "#c084fc",
6354
+ "#ffc800"
6355
+ ]
6356
+ };
6357
+ case "lesson-complete": return {
6358
+ particles: 40,
6359
+ durationMs: 1e3,
6360
+ colors: [
6361
+ "#58cc02",
6362
+ "#ffc800",
6363
+ "#1cb0f6"
6364
+ ]
6365
+ };
6366
+ case "energy-full": return {
6367
+ particles: 36,
6368
+ durationMs: 900,
6369
+ colors: [
6370
+ "#58cc02",
6371
+ "#7ed957",
6372
+ "#ffffff"
6373
+ ]
6374
+ };
6375
+ case "streak": return {
6376
+ particles: 24,
6377
+ durationMs: 700,
6378
+ colors: [
6379
+ "#ff7a1a",
6380
+ "#ffc800",
6381
+ "#ffffff"
6382
+ ]
6383
+ };
6384
+ case "wrong": return {
6385
+ particles: 12,
6386
+ durationMs: 450,
6387
+ colors: ["#ff4b4b", "#ff7a7a"]
6388
+ };
6389
+ default: return {
6390
+ particles: 20,
6391
+ durationMs: 600,
6392
+ colors: ["#58cc02", "#ffffff"]
6393
+ };
6394
+ }
6395
+ }
6396
+ /** The reduced-motion fallback glyph per kind (semantic color, drawn statically). */
6397
+ function iconFor(kind) {
6398
+ switch (kind) {
6399
+ case "correct": return {
6400
+ icon: "✓",
6401
+ color: "var(--brand)"
6402
+ };
6403
+ case "wrong": return {
6404
+ icon: "✕",
6405
+ color: "var(--warning)"
6406
+ };
6407
+ case "mastery":
6408
+ case "level-up": return {
6409
+ icon: "♛",
6410
+ color: "var(--gold)"
6411
+ };
6412
+ case "unlock": return {
6413
+ icon: "✦",
6414
+ color: "var(--brand)"
6415
+ };
6416
+ case "streak": return {
6417
+ icon: "▲",
6418
+ color: "var(--review)"
6419
+ };
6420
+ case "energy-full": return {
6421
+ icon: "⚡",
6422
+ color: "var(--brand)"
6423
+ };
6424
+ case "exam-pass": return {
6425
+ icon: "✪",
6426
+ color: "var(--exam)"
6427
+ };
6428
+ case "lesson-complete": return {
6429
+ icon: "☆",
6430
+ color: "var(--gold)"
6431
+ };
6432
+ default: return {
6433
+ icon: "✦",
6434
+ color: "var(--brand)"
6435
+ };
6436
+ }
6437
+ }
6438
+ /**
6439
+ * Diff two poll snapshots into the celebrations to fire (pure — the panel
6440
+ * maps unlock ids to on-screen bubble anchors). First sight (prev null)
6441
+ * seeds every baseline silently — upstream seeds its refs from the initial
6442
+ * fetch BEFORE subscribing, so a page opened mid-fulfilled-day never
6443
+ * celebrates; streak fires only on an INCREASE; energy-full on the first
6444
+ * goal crossing (prev < goal ≤ now).
6445
+ */
6446
+ function celebrationDiff(prev, next) {
6447
+ const out = [];
6448
+ if (prev !== null) {
6449
+ const was = new Map(prev.lessons.map((l) => [l.id, l.status]));
6450
+ for (const l of next.lessons) {
6451
+ const before = was.get(l.id);
6452
+ if (before === void 0 || before === l.status) continue;
6453
+ if (before === "locked" && l.status === "available") out.push({
6454
+ kind: "unlock",
6455
+ lessonId: l.id
6456
+ });
6457
+ else if (l.status === "mastered") out.push({
6458
+ kind: "mastery",
6459
+ lessonId: l.id
6460
+ });
6461
+ }
6462
+ if (next.streak > prev.streak) out.push({ kind: "streak" });
6463
+ }
6464
+ if (prev === null) return [];
6465
+ const full = next.dailyGoal > 0 && next.todayXp >= next.dailyGoal;
6466
+ const wasFull = prev.dailyGoal > 0 && prev.todayXp >= prev.dailyGoal;
6467
+ if (full && !wasFull) out.push({ kind: "energy-full" });
6468
+ return out;
6469
+ }
6470
+ /**
6471
+ * Seed one burst's particles (pure; upstream burst() extracted for tests).
6472
+ * `rand` injects randomness so tests can freeze the spread.
6473
+ */
6474
+ function seedBurst(event, viewport, rand = Math.random) {
6475
+ const def = celebrationDefaults(event.kind);
6476
+ const n = Math.round(def.particles * (event.intensity ?? 1));
6477
+ const ox = event.origin?.x ?? viewport.w / 2;
6478
+ const oy = event.origin?.y ?? viewport.h / 2;
6479
+ const out = [];
6480
+ for (let i = 0; i < n; i++) {
6481
+ const angle = Math.PI * 2 * i / n + rand() * .3;
6482
+ const speed = 3 + rand() * 6;
6483
+ const maxLife = def.durationMs / 16 * (.7 + rand() * .5);
6484
+ out.push({
6485
+ x: ox,
6486
+ y: oy,
6487
+ vx: Math.cos(angle) * speed,
6488
+ vy: Math.sin(angle) * speed - 2,
6489
+ life: maxLife,
6490
+ maxLife,
6491
+ color: def.colors[i % def.colors.length],
6492
+ size: 4 + rand() * 5,
6493
+ rot: rand() * Math.PI,
6494
+ vr: (rand() - .5) * .3,
6495
+ shape: rand() < .5 ? "rect" : "circle"
6496
+ });
6497
+ }
6498
+ return out;
6499
+ }
6500
+ /** Advance one particle one frame (pure; gravity + drag + spin). Dead ⇒ null. */
6501
+ function stepParticle(p) {
6502
+ const life = p.life - 1;
6503
+ if (life <= 0) return null;
6504
+ return {
6505
+ ...p,
6506
+ x: p.x + p.vx,
6507
+ y: p.y + p.vy,
6508
+ vy: p.vy + .25,
6509
+ vx: p.vx * .99,
6510
+ rot: p.rot + p.vr,
6511
+ life
6512
+ };
6513
+ }
6514
+ /** Fading alpha 1 → 0 across the particle's life (pure). */
6515
+ function particleAlpha(p) {
6516
+ return Math.min(1, p.life / p.maxLife);
6517
+ }
6518
+ /**
6519
+ * The exits after one finished card — never fewer than two (the anti-dead-end
6520
+ * promise). Wrong answers → patch the hole first; perfect + near-mastered →
6521
+ * offer the mastery exit; perfect + already graduated → move on.
6522
+ * Pure port of upstream getPostQuizActions.
6523
+ */
6524
+ function getPostQuizActions(score, mastery) {
6130
6525
  const { correct, total } = score;
6131
6526
  const allCorrect = total > 0 && correct === total;
6132
6527
  const hasWrong = total > 0 && correct < total;
@@ -6177,7 +6572,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6177
6572
  const raw = storage.getItem(quizProgressKey(lessonId, artifactId));
6178
6573
  if (raw === null) return {
6179
6574
  answers: Array(questionCount).fill(-1),
6180
- done: false
6575
+ done: false,
6576
+ hooked: false
6181
6577
  };
6182
6578
  const parsed = JSON.parse(raw);
6183
6579
  const answers = Array.isArray(parsed.answers) ? parsed.answers.map((a) => typeof a === "number" ? a : -1) : [];
@@ -6185,12 +6581,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6185
6581
  for (let i = 0; i < Math.min(answers.length, questionCount); i += 1) padded[i] = answers[i];
6186
6582
  return {
6187
6583
  answers: padded,
6188
- done: parsed.done === true
6584
+ done: parsed.done === true,
6585
+ hooked: parsed.hooked === true || parsed.done === true
6189
6586
  };
6190
6587
  } catch {
6191
6588
  return {
6192
6589
  answers: Array(questionCount).fill(-1),
6193
- done: false
6590
+ done: false,
6591
+ hooked: false
6194
6592
  };
6195
6593
  }
6196
6594
  }
@@ -6209,555 +6607,163 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6209
6607
  total += 1;
6210
6608
  if (choice === q.answer) correct += 1;
6211
6609
  });
6212
- return {
6213
- correct,
6214
- total
6215
- };
6216
- }
6217
- /**
6218
- * C5 option tone under select-then-submit: before the commit the tone NEVER
6219
- * leaks correctness (only the pick highlight); after it, the judged pair
6220
- * (right/wrong/dim) shows. Pure.
6221
- */
6222
- function optionTone(chosen, pending, i, isRight) {
6223
- if (chosen >= 0) return chosen === i ? isRight ? " right" : " wrong" : isRight ? " right dim" : "";
6224
- return pending === i ? " picked" : "";
6225
- }
6226
- const ACTION_ICONS = {
6227
- "explain-wrong": IconBookFill16,
6228
- "retry": IconRefreshOutline16,
6229
- "go-deeper": IconBoltFill16,
6230
- "mark-mastered": IconCrownFill16,
6231
- "next-topic": IconStarFill16
6232
- };
6233
- /**
6234
- * The card: one question at a time, click-to-answer (instant local judge +
6235
- * explanation reveal — the cursor is explicit, so the reveal is never skipped),
6236
- * progress persisted per artifact; the score screen is an explicit step, and
6237
- * on first arrival its completion hook goes to the tutor exactly once.
6238
- */
6239
- function QuizCard({ lessonId, artifactId, data, masteryPct, send, onFinished }) {
6240
- const questions = data.questions;
6241
- const [progress, setProgress] = (0, react.useState)(() => loadQuizProgress(lessonId, artifactId, questions.length));
6242
- const [cursor, setCursor] = (0, react.useState)(0);
6243
- const [showScore, setShowScore] = (0, react.useState)(false);
6244
- const [hookSent, setHookSent] = (0, react.useState)(false);
6245
- const [pending, setPending] = (0, react.useState)(null);
6246
- const rootRef = (0, react.useRef)(null);
6247
- (0, react.useEffect)(() => {
6248
- const next = loadQuizProgress(lessonId, artifactId, questions.length);
6249
- setProgress(next);
6250
- setShowScore(next.done);
6251
- const firstUnanswered = next.answers.findIndex((a) => a < 0);
6252
- setCursor(firstUnanswered === -1 ? questions.length - 1 : firstUnanswered);
6253
- setHookSent(false);
6254
- setPending(null);
6255
- }, [
6256
- lessonId,
6257
- artifactId,
6258
- questions.length
6259
- ]);
6260
- const allAnswered = progress.answers.every((a) => a >= 0);
6261
- const score = quizScore(data, progress.answers);
6262
- const mastery = masteryPct === null ? null : masteryPct / 100;
6263
- (0, react.useEffect)(() => {
6264
- if (!showScore || !allAnswered || hookSent) return;
6265
- setHookSent(true);
6266
- onFinished?.(score.correct === score.total);
6267
- studyStore.reportPracticeGraded(lessonId);
6268
- const next = {
6269
- answers: [...progress.answers],
6270
- done: true
6271
- };
6272
- setProgress(next);
6273
- saveQuizProgress(lessonId, artifactId, next);
6274
- send(tr("quiz.hook", {
6275
- correct: score.correct,
6276
- total: score.total
6277
- }));
6278
- }, [
6279
- showScore,
6280
- allAnswered,
6281
- hookSent,
6282
- lessonId,
6283
- artifactId,
6284
- progress.answers,
6285
- score.correct,
6286
- score.total,
6287
- send
6288
- ]);
6289
- const answer = (choice) => {
6290
- const next = {
6291
- answers: [...progress.answers],
6292
- done: progress.done
6293
- };
6294
- next.answers[cursor] = choice;
6295
- setProgress(next);
6296
- saveQuizProgress(lessonId, artifactId, next);
6297
- const correct = choice === questions[cursor].answer;
6298
- const el = rootRef.current;
6299
- const r = correct && el !== null ? el.getBoundingClientRect() : null;
6300
- celebrate(correct ? "correct" : "wrong", r !== null ? { origin: {
6301
- x: r.right - 48,
6302
- y: r.top + 24
6303
- } } : void 0);
6304
- };
6305
- if (showScore && allAnswered) {
6306
- const actions = getPostQuizActions(score, mastery);
6307
- return (0, react.createElement)("div", {
6308
- className: "lks-qcard",
6309
- "data-lks-quiz": artifactId,
6310
- ref: rootRef
6311
- }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconStarFill16, { size: 14 }), tr("quiz.score", {
6312
- correct: score.correct,
6313
- total: score.total
6314
- })), (0, react.createElement)("div", { className: "lks-qcard-review" }, ...questions.map((q, i) => {
6315
- const choice = progress.answers[i] ?? -1;
6316
- const right = choice === q.answer;
6317
- return (0, react.createElement)("div", {
6318
- key: i,
6319
- className: `lks-qcard-q review${right ? "" : " wrong"}`
6320
- }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, `${i + 1}. ${q.prompt}`), (0, react.createElement)("div", { className: "lks-qcard-ans" }, right ? "" : `${tr("quiz.youChose")}:${q.options[choice] ?? "—"} · `, `${tr("quiz.answer")}:${q.options[q.answer] ?? "—"}`), (0, react.createElement)("div", { className: "lks-qcard-expl" }, q.explanation));
6321
- })), (0, react.createElement)("div", { className: "lks-qcard-actions" }, ...actions.map((a) => (0, react.createElement)("button", {
6322
- key: a.id,
6323
- className: "lks-qcard-action",
6324
- "data-tooltip": a.advancesMastery ? tr("quiz.action.markMastered.hint") : void 0,
6325
- onClick: () => {
6326
- send(tr(`quiz.msg.${a.id}`));
6327
- }
6328
- }, (0, react.createElement)(ACTION_ICONS[a.id], { size: 13 }), tr(`quiz.action.${a.id}`)))));
6329
- }
6330
- const q = questions[cursor];
6331
- const chosen = progress.answers[cursor] ?? -1;
6332
- const options = q.options.map((opt, i) => {
6333
- const tone = optionTone(chosen, pending, i, i === q.answer);
6334
- return (0, react.createElement)("button", {
6335
- key: i,
6336
- className: `lks-qcard-opt${tone}`,
6337
- disabled: chosen >= 0,
6338
- onClick: () => {
6339
- setPending(i);
6340
- }
6341
- }, opt);
6342
- });
6343
- const submitRow = chosen < 0 && pending !== null ? (0, react.createElement)("div", { style: { marginTop: "6px" } }, (0, react.createElement)("button", {
6344
- className: "lks-qcard-next",
6345
- onClick: () => {
6346
- answer(pending);
6347
- setPending(null);
6348
- }
6349
- }, tr("quiz.submit"))) : null;
6350
- const explanation = chosen < 0 ? submitRow : (0, react.createElement)("div", { className: "lks-qcard-expl" }, `${chosen === q.answer ? tr("quiz.right") : tr("quiz.wrong")} — ${q.explanation}`, (0, react.createElement)("div", { style: { marginTop: "6px" } }, (0, react.createElement)("button", {
6351
- className: "lks-qcard-next",
6352
- onClick: () => {
6353
- setPending(null);
6354
- if (cursor + 1 < questions.length) setCursor(cursor + 1);
6355
- else setShowScore(true);
6356
- }
6357
- }, cursor + 1 < questions.length ? tr("quiz.next") : tr("quiz.finish"))));
6358
- return (0, react.createElement)("div", {
6359
- className: "lks-qcard",
6360
- "data-lks-quiz": artifactId,
6361
- ref: rootRef
6362
- }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconBookFill16, { size: 14 }), data.title ?? tr("quiz.card.title"), (0, react.createElement)("span", { className: "lks-qcard-count" }, tr("quiz.progress", {
6363
- cur: cursor + 1,
6364
- total: questions.length
6365
- }))), (0, react.createElement)("div", { className: "lks-qcard-q" }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, q.prompt), (0, react.createElement)("div", { className: "lks-qcard-opts" }, ...options), explanation));
6366
- }
6367
- //#endregion
6368
- //#region src/vendor/panzoom.ts
6369
- /** 适屏(contain)缩放:内容完整装进视口(留 padding 边距),不超出 1。 */
6370
- function fitScale(contentW, contentH, viewW, viewH, padding = 24) {
6371
- if (contentW <= 0 || contentH <= 0 || viewW <= 0 || viewH <= 0) return 1;
6372
- const s = Math.min((viewW - padding * 2) / contentW, (viewH - padding * 2) / contentH, 1);
6373
- return Math.max(.02, s);
6374
- }
6375
- /** 适屏位姿:居中。 */
6376
- function fitTransform(contentW, contentH, viewW, viewH, padding = 24) {
6377
- const scale = fitScale(contentW, contentH, viewW, viewH, padding);
6378
- return {
6379
- scale,
6380
- x: (viewW - contentW * scale) / 2,
6381
- y: (viewH - contentH * scale) / 2
6382
- };
6383
- }
6384
- /**
6385
- * 锚点缩放:factor>1 放大。锚点 (ax,ay) 是视口坐标 —— 缩放后锚点对准的内容点
6386
- * 保持不动(标准图像查看器数学:new = anchor - (anchor - old) * factor)。
6387
- */
6388
- function zoomAt(t, factor, ax, ay) {
6389
- return {
6390
- scale: t.scale * factor,
6391
- x: ax - (ax - t.x) * factor,
6392
- y: ay - (ay - t.y) * factor
6610
+ return {
6611
+ correct,
6612
+ total
6393
6613
  };
6394
6614
  }
6395
6615
  /**
6396
- * 平移钳制:内容不会被拖到完全离屏 —— 至少留 `keep`(默认 64px)可见,
6397
- * 且内容小于视口时不允许拖走(保持居中观感)。
6616
+ * C5 option tone under select-then-submit: before the commit the tone NEVER
6617
+ * leaks correctness (only the pick highlight); after it, the judged pair
6618
+ * (right/wrong/dim) shows. Pure.
6398
6619
  */
6399
- function clampPan(t, contentW, contentH, viewW, viewH, keep = 64) {
6400
- const w = contentW * t.scale;
6401
- const h = contentH * t.scale;
6402
- let { x, y } = t;
6403
- if (w <= viewW) x = (viewW - w) / 2;
6404
- else x = Math.min(Math.max(x, viewW - w - keep), keep);
6405
- if (h <= viewH) y = (viewH - h) / 2;
6406
- else y = Math.min(Math.max(y, viewH - h - keep), keep);
6407
- return {
6408
- x,
6409
- y,
6410
- scale: t.scale
6411
- };
6412
- }
6413
- /** 钳制缩放(先算锚点缩放,再夹 scale 到 [min,max],再钳平移)。 */
6414
- function zoomAtClamped(t, factor, ax, ay, bounds) {
6415
- const target = t.scale * factor;
6416
- const clampedFactor = Math.min(Math.max(target, bounds.min), bounds.max) / t.scale;
6417
- if (clampedFactor === 1) return t;
6418
- return clampPan(zoomAt(t, clampedFactor, ax, ay), bounds.contentW, bounds.contentH, bounds.viewW, bounds.viewH);
6620
+ function optionTone(chosen, pending, i, isRight) {
6621
+ if (chosen >= 0) return chosen === i ? isRight ? " right" : " wrong" : isRight ? " right dim" : "";
6622
+ return pending === i ? " picked" : "";
6419
6623
  }
6420
- //#endregion
6421
- //#region src/client/canvasstage.tsx
6624
+ const ACTION_ICONS = {
6625
+ "explain-wrong": IconBookFill16,
6626
+ "retry": IconRefreshOutline16,
6627
+ "go-deeper": IconBoltFill16,
6628
+ "mark-mastered": IconCrownFill16,
6629
+ "next-topic": IconStarFill16
6630
+ };
6422
6631
  /**
6423
- * CanvasStage (P14, upstream CanvasStage.tsx port): the canvas-style artifact
6424
- * stage — content keeps its natural size and one transform (translate+scale)
6425
- * does everything. Gestures: single-pointer drag pan, two-pointer pinch
6426
- * anchored at the midpoint, wheel zoom anchored at the cursor, double
6427
- * click/tap toggles fit↔100%. ResizeObserver refits on content/stage size
6428
- * changes (mermaid's async svg) — through the #12 gates: value-equality on
6429
- * setSize, a bounds-differ short-circuit on refit, rAF coalescing, and a
6430
- * recurrence-tripping oscillation breaker (see makeRefitBreaker). A floating
6431
- * −/%/fit/+ toolbar stays mounted. Shared by the notebook's board tab and
6432
- * the diagram modals.
6433
- * (lucide glyphs collapse to text/plugin icons; the client stays dependency-
6434
- * free.)
6435
- * @module dsh-plugin-lookatstudy/client/canvasstage
6632
+ * The card: one question at a time, click-to-answer (instant local judge +
6633
+ * explanation reveal — the cursor is explicit, so the reveal is never skipped),
6634
+ * progress persisted per artifact; the score screen is an explicit step, and
6635
+ * its completion hook goes to the tutor exactly once per card — the receipt
6636
+ * persists beside the progress (issue #14).
6436
6637
  */
6437
- const MIN_SCALE = .05;
6438
- const MAX_SCALE = 4;
6439
- /** 工具条步进(手势/滚轮连续,按钮给干脆的一档)。 */
6440
- const STEP = 1.25;
6441
- /** True when the two bounds readings differ in ANY field — the auto-refit
6442
- * short-circuit. The pre-fix refit ran fitTransform on every RO callback with
6443
- * zero hysteresis, so any feedback touching a size re-fired it forever. */
6444
- function stageBoundsDiffer(a, b) {
6445
- if (a === null) return true;
6446
- return a.contentW !== b.contentW || a.contentH !== b.contentH || a.viewW !== b.viewW || a.viewH !== b.viewH;
6447
- }
6448
- function makeRefitBreaker(opts = {}) {
6449
- const windowMs = opts.windowMs ?? 2e3;
6450
- const now = opts.now ?? (() => performance.now());
6451
- let seen = [];
6452
- let tripped = false;
6453
- return {
6454
- allow(key) {
6455
- const t = now();
6456
- seen = seen.filter((s) => t - s.t < windowMs);
6457
- if (tripped) return false;
6458
- if (seen.some((s) => s.key === key)) {
6459
- tripped = true;
6460
- return false;
6461
- }
6462
- seen.push({
6463
- t,
6464
- key
6465
- });
6466
- return true;
6467
- },
6468
- reset() {
6469
- seen = [];
6470
- tripped = false;
6471
- },
6472
- tripped() {
6473
- return tripped;
6474
- }
6475
- };
6476
- }
6477
- const boundsKey = (b) => `${String(b.contentW)}x${String(b.contentH)}@${String(b.viewW)}x${String(b.viewH)}`;
6478
- function CanvasStage({ children, grid = true, testid }) {
6479
- const stageRef = (0, react.useRef)(null);
6480
- const contentRef = (0, react.useRef)(null);
6481
- const [tf, setTf] = (0, react.useState)({
6482
- x: 0,
6483
- y: 0,
6484
- scale: 1
6485
- });
6486
- const [size, setSize] = (0, react.useState)(null);
6487
- /** 活跃手势中禁用过渡(跟手必须即时;按钮缩放才享受平滑)。 */
6488
- const [gesturing, setGesturing] = (0, react.useState)(false);
6489
- const lastPointerTypeRef = (0, react.useRef)("mouse");
6490
- const atFitRef = (0, react.useRef)(true);
6491
- const gesture = (0, react.useRef)({
6492
- pointers: /* @__PURE__ */ new Map(),
6493
- pinchBase: null,
6494
- lastMid: null,
6495
- panning: false,
6496
- moved: false
6497
- });
6498
- const lastTapRef = (0, react.useRef)(null);
6499
- const lastBoundsRef = (0, react.useRef)(null);
6500
- const breakerRef = (0, react.useRef)(null);
6501
- if (breakerRef.current === null) breakerRef.current = makeRefitBreaker();
6502
- const bounds = (0, react.useCallback)(() => {
6503
- const stage = stageRef.current;
6504
- const content = contentRef.current;
6505
- return {
6506
- min: MIN_SCALE,
6507
- max: MAX_SCALE,
6508
- contentW: content?.offsetWidth ?? 0,
6509
- contentH: content?.offsetHeight ?? 0,
6510
- viewW: stage?.clientWidth ?? 0,
6511
- viewH: stage?.clientHeight ?? 0
6512
- };
6513
- }, []);
6514
- const currentBounds = (0, react.useCallback)(() => ({
6515
- contentW: contentRef.current?.offsetWidth ?? 0,
6516
- contentH: contentRef.current?.offsetHeight ?? 0,
6517
- viewW: stageRef.current?.clientWidth ?? 0,
6518
- viewH: stageRef.current?.clientHeight ?? 0
6519
- }), []);
6520
- const applyFit = (0, react.useCallback)((b) => {
6521
- setTf(fitTransform(b.contentW, b.contentH, b.viewW, b.viewH));
6522
- atFitRef.current = true;
6523
- }, []);
6524
- const refit = (0, react.useCallback)(() => {
6525
- breakerRef.current?.reset();
6526
- const b = currentBounds();
6527
- if (b.contentW === 0 || b.viewW === 0) return;
6528
- lastBoundsRef.current = b;
6529
- applyFit(b);
6530
- }, [currentBounds, applyFit]);
6531
- const measureNow = (0, react.useCallback)(() => {
6532
- const content = contentRef.current;
6533
- if (content === null) return;
6534
- const w = content.offsetWidth;
6535
- const h = content.offsetHeight;
6536
- setSize((prev) => prev !== null && prev.w === w && prev.h === h ? prev : {
6537
- w,
6538
- h
6539
- });
6540
- const b = currentBounds();
6541
- if (!stageBoundsDiffer(lastBoundsRef.current, b)) return;
6542
- lastBoundsRef.current = b;
6543
- if (b.contentW === 0 || b.viewW === 0) return;
6544
- if (b.contentW < 120) return;
6545
- if ((breakerRef.current ?? makeRefitBreaker()).allow(boundsKey(b))) applyFit(b);
6546
- }, [currentBounds, applyFit]);
6547
- (0, react.useEffect)(() => {
6548
- const stage = stageRef.current;
6549
- const content = contentRef.current;
6550
- if (stage === null || content === null) return;
6551
- measureNow();
6552
- let raf = 0;
6553
- const onRo = () => {
6554
- if (raf !== 0) return;
6555
- raf = requestAnimationFrame(() => {
6556
- raf = 0;
6557
- measureNow();
6558
- });
6559
- };
6560
- const ro = new ResizeObserver(onRo);
6561
- ro.observe(content);
6562
- ro.observe(stage);
6563
- return () => {
6564
- ro.disconnect();
6565
- if (raf !== 0) cancelAnimationFrame(raf);
6566
- };
6567
- }, [measureNow]);
6568
- const zoomBy = (0, react.useCallback)((factor, ax, ay) => {
6569
- breakerRef.current?.reset();
6570
- const b = bounds();
6571
- const cx = ax ?? b.viewW / 2;
6572
- const cy = ay ?? b.viewH / 2;
6573
- setTf((cur) => zoomAtClamped(cur, factor, cx, cy, b));
6574
- atFitRef.current = false;
6575
- }, [bounds]);
6576
- const onPointerDown = (0, react.useCallback)((e) => {
6577
- if (e.target.closest?.("[data-testid=\"canvas-zoom-controls\"]") !== null) return;
6578
- const el = e.currentTarget;
6579
- try {
6580
- el.setPointerCapture(e.pointerId);
6581
- } catch {}
6582
- const g = gesture.current;
6583
- lastPointerTypeRef.current = e.pointerType;
6584
- if (g.pointers.size === 0) setGesturing(true);
6585
- g.pointers.set(e.pointerId, {
6586
- x: e.clientX,
6587
- y: e.clientY
6588
- });
6589
- if (g.pointers.size === 2) {
6590
- const [p1, p2] = [...g.pointers.values()];
6591
- g.pinchBase = {
6592
- dist: Math.hypot(p1.x - p2.x, p1.y - p2.y),
6593
- factorAcc: 1
6594
- };
6595
- g.lastMid = {
6596
- x: (p1.x + p2.x) / 2,
6597
- y: (p1.y + p2.y) / 2
6598
- };
6599
- g.panning = false;
6600
- } else if (g.pointers.size === 1) {
6601
- g.panning = true;
6602
- g.moved = false;
6603
- g.lastMid = {
6604
- x: e.clientX,
6605
- y: e.clientY
6606
- };
6607
- }
6608
- }, []);
6609
- const onPointerMove = (0, react.useCallback)((e) => {
6610
- const g = gesture.current;
6611
- if (!g.pointers.has(e.pointerId)) return;
6612
- g.pointers.set(e.pointerId, {
6613
- x: e.clientX,
6614
- y: e.clientY
6615
- });
6616
- const stage = stageRef.current;
6617
- if (stage === null) return;
6618
- const rect = stage.getBoundingClientRect();
6619
- if (g.pointers.size >= 2 && g.pinchBase !== null) {
6620
- const [p1, p2] = [...g.pointers.values()];
6621
- const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
6622
- const mid = {
6623
- x: (p1.x + p2.x) / 2,
6624
- y: (p1.y + p2.y) / 2
6625
- };
6626
- if (g.pinchBase.dist > 0) {
6627
- const factor = dist / g.pinchBase.dist / g.pinchBase.factorAcc;
6628
- g.pinchBase.factorAcc *= factor;
6629
- zoomBy(factor, mid.x - rect.left, mid.y - rect.top);
6638
+ function QuizCard({ lessonId, artifactId, data, masteryPct, send, onFinished }) {
6639
+ const questions = data.questions;
6640
+ const [progress, setProgress] = (0, react.useState)(() => loadQuizProgress(lessonId, artifactId, questions.length));
6641
+ const [cursor, setCursor] = (0, react.useState)(0);
6642
+ const [showScore, setShowScore] = (0, react.useState)(false);
6643
+ const [hookSent, setHookSent] = (0, react.useState)(false);
6644
+ const [pending, setPending] = (0, react.useState)(null);
6645
+ const rootRef = (0, react.useRef)(null);
6646
+ (0, react.useEffect)(() => {
6647
+ const next = loadQuizProgress(lessonId, artifactId, questions.length);
6648
+ setProgress(next);
6649
+ setShowScore(next.done);
6650
+ const firstUnanswered = next.answers.findIndex((a) => a < 0);
6651
+ setCursor(firstUnanswered === -1 ? questions.length - 1 : firstUnanswered);
6652
+ setHookSent(next.hooked);
6653
+ setPending(null);
6654
+ }, [
6655
+ lessonId,
6656
+ artifactId,
6657
+ questions.length
6658
+ ]);
6659
+ const allAnswered = progress.answers.every((a) => a >= 0);
6660
+ const score = quizScore(data, progress.answers);
6661
+ const mastery = masteryPct === null ? null : masteryPct / 100;
6662
+ (0, react.useEffect)(() => {
6663
+ if (!showScore || !allAnswered || hookSent) return;
6664
+ setHookSent(true);
6665
+ onFinished?.(score.correct === score.total);
6666
+ studyStore.reportPracticeGraded(lessonId);
6667
+ const next = {
6668
+ answers: [...progress.answers],
6669
+ done: true,
6670
+ hooked: true
6671
+ };
6672
+ setProgress(next);
6673
+ saveQuizProgress(lessonId, artifactId, next);
6674
+ send(tr("quiz.hook", {
6675
+ correct: score.correct,
6676
+ total: score.total
6677
+ }));
6678
+ }, [
6679
+ showScore,
6680
+ allAnswered,
6681
+ hookSent,
6682
+ lessonId,
6683
+ artifactId,
6684
+ progress.answers,
6685
+ score.correct,
6686
+ score.total,
6687
+ send
6688
+ ]);
6689
+ const answer = (choice) => {
6690
+ const next = {
6691
+ answers: [...progress.answers],
6692
+ done: progress.done,
6693
+ hooked: progress.hooked
6694
+ };
6695
+ next.answers[cursor] = choice;
6696
+ setProgress(next);
6697
+ saveQuizProgress(lessonId, artifactId, next);
6698
+ const correct = choice === questions[cursor].answer;
6699
+ const el = rootRef.current;
6700
+ const r = correct && el !== null ? el.getBoundingClientRect() : null;
6701
+ celebrate(correct ? "correct" : "wrong", r !== null ? { origin: {
6702
+ x: r.right - 48,
6703
+ y: r.top + 24
6704
+ } } : void 0);
6705
+ };
6706
+ if (showScore && allAnswered) {
6707
+ const actions = getPostQuizActions(score, mastery);
6708
+ return (0, react.createElement)("div", {
6709
+ className: "lks-qcard",
6710
+ "data-lks-quiz": artifactId,
6711
+ ref: rootRef
6712
+ }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconStarFill16, { size: 14 }), tr("quiz.score", {
6713
+ correct: score.correct,
6714
+ total: score.total
6715
+ })), (0, react.createElement)("div", { className: "lks-qcard-review" }, ...questions.map((q, i) => {
6716
+ const choice = progress.answers[i] ?? -1;
6717
+ const right = choice === q.answer;
6718
+ return (0, react.createElement)("div", {
6719
+ key: i,
6720
+ className: `lks-qcard-q review${right ? "" : " wrong"}`
6721
+ }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, `${i + 1}. ${q.prompt}`), (0, react.createElement)("div", { className: "lks-qcard-ans" }, right ? "" : `${tr("quiz.youChose")}:${q.options[choice] ?? "—"} · `, `${tr("quiz.answer")}:${q.options[q.answer] ?? "—"}`), (0, react.createElement)("div", { className: "lks-qcard-expl" }, q.explanation));
6722
+ })), (0, react.createElement)("div", { className: "lks-qcard-actions" }, ...actions.map((a) => (0, react.createElement)("button", {
6723
+ key: a.id,
6724
+ className: "lks-qcard-action",
6725
+ "data-tooltip": a.advancesMastery ? tr("quiz.action.markMastered.hint") : void 0,
6726
+ onClick: () => {
6727
+ send(tr(`quiz.msg.${a.id}`));
6630
6728
  }
6631
- if (g.lastMid !== null) {
6632
- const dx = mid.x - g.lastMid.x;
6633
- const dy = mid.y - g.lastMid.y;
6634
- const b = bounds();
6635
- setTf((cur) => clampPan({
6636
- ...cur,
6637
- x: cur.x + dx,
6638
- y: cur.y + dy
6639
- }, b.contentW, b.contentH, b.viewW, b.viewH));
6729
+ }, (0, react.createElement)(ACTION_ICONS[a.id], { size: 13 }), tr(`quiz.action.${a.id}`)))));
6730
+ }
6731
+ const q = questions[cursor];
6732
+ const chosen = progress.answers[cursor] ?? -1;
6733
+ const options = q.options.map((opt, i) => {
6734
+ const tone = optionTone(chosen, pending, i, i === q.answer);
6735
+ return (0, react.createElement)("button", {
6736
+ key: i,
6737
+ className: `lks-qcard-opt${tone}`,
6738
+ disabled: chosen >= 0,
6739
+ onClick: () => {
6740
+ setPending(i);
6640
6741
  }
6641
- g.lastMid = mid;
6642
- return;
6643
- }
6644
- if (g.panning && g.lastMid !== null) {
6645
- const dx = e.clientX - g.lastMid.x;
6646
- const dy = e.clientY - g.lastMid.y;
6647
- if (Math.abs(dx) + Math.abs(dy) > 2) g.moved = true;
6648
- const b = bounds();
6649
- setTf((cur) => clampPan({
6650
- ...cur,
6651
- x: cur.x + dx,
6652
- y: cur.y + dy
6653
- }, b.contentW, b.contentH, b.viewW, b.viewH));
6654
- g.lastMid = {
6655
- x: e.clientX,
6656
- y: e.clientY
6657
- };
6742
+ }, opt);
6743
+ });
6744
+ const submitRow = chosen < 0 && pending !== null ? (0, react.createElement)("div", { style: { marginTop: "6px" } }, (0, react.createElement)("button", {
6745
+ className: "lks-qcard-next",
6746
+ onClick: () => {
6747
+ answer(pending);
6748
+ setPending(null);
6658
6749
  }
6659
- }, [bounds, zoomBy]);
6660
- const onPointerUp = (0, react.useCallback)((e) => {
6661
- const g = gesture.current;
6662
- const wasSingle = g.pointers.size === 1;
6663
- g.pointers.delete(e.pointerId);
6664
- if (g.pointers.size < 2) g.pinchBase = null;
6665
- if (g.pointers.size === 0) {
6666
- g.panning = false;
6667
- g.lastMid = null;
6668
- setGesturing(false);
6669
- if (wasSingle && !g.moved && e.pointerType !== "mouse") {
6670
- const now = performance.now();
6671
- const last = lastTapRef.current;
6672
- if (last !== null && now - last.t < 320 && Math.hypot(e.clientX - last.x, e.clientY - last.y) < 32) {
6673
- const b = bounds();
6674
- if (atFitRef.current) {
6675
- setTf(clampPan({
6676
- x: (b.viewW - b.contentW) / 2,
6677
- y: Math.max(24, (b.viewH - b.contentH) / 2),
6678
- scale: 1
6679
- }, b.contentW, b.contentH, b.viewW, b.viewH));
6680
- atFitRef.current = false;
6681
- } else refit();
6682
- lastTapRef.current = null;
6683
- } else lastTapRef.current = {
6684
- t: now,
6685
- x: e.clientX,
6686
- y: e.clientY
6687
- };
6688
- }
6689
- } else if (g.pointers.size === 1) {
6690
- const only = [...g.pointers.values()][0];
6691
- g.lastMid = {
6692
- x: only.x,
6693
- y: only.y
6694
- };
6695
- g.panning = true;
6750
+ }, tr("quiz.submit"))) : null;
6751
+ const explanation = chosen < 0 ? submitRow : (0, react.createElement)("div", { className: "lks-qcard-expl" }, `${chosen === q.answer ? tr("quiz.right") : tr("quiz.wrong")} — ${q.explanation}`, (0, react.createElement)("div", { style: { marginTop: "6px" } }, (0, react.createElement)("button", {
6752
+ className: "lks-qcard-next",
6753
+ onClick: () => {
6754
+ setPending(null);
6755
+ if (cursor + 1 < questions.length) setCursor(cursor + 1);
6756
+ else setShowScore(true);
6696
6757
  }
6697
- }, [bounds, refit]);
6698
- const onWheel = (0, react.useCallback)((e) => {
6699
- e.preventDefault();
6700
- const rect = stageRef.current?.getBoundingClientRect();
6701
- if (rect === void 0) return;
6702
- zoomBy(e.deltaY < 0 ? STEP : 1 / STEP, e.clientX - rect.left, e.clientY - rect.top);
6703
- }, [zoomBy]);
6704
- const onDoubleClick = (0, react.useCallback)(() => {
6705
- if (lastPointerTypeRef.current !== "mouse") return;
6706
- const b = bounds();
6707
- if (atFitRef.current) {
6708
- setTf(clampPan({
6709
- x: (b.viewW - b.contentW) / 2,
6710
- y: Math.max(24, (b.viewH - b.contentH) / 2),
6711
- scale: 1
6712
- }, b.contentW, b.contentH, b.viewW, b.viewH));
6713
- atFitRef.current = false;
6714
- } else refit();
6715
- }, [bounds, refit]);
6758
+ }, cursor + 1 < questions.length ? tr("quiz.next") : tr("quiz.finish"))));
6716
6759
  return (0, react.createElement)("div", {
6717
- ref: stageRef,
6718
- "data-testid": testid,
6719
- "data-noswipe": "",
6720
- className: `lks14-stage${grid ? " grid" : ""}`,
6721
- onPointerDown,
6722
- onPointerMove,
6723
- onPointerUp,
6724
- onPointerCancel: onPointerUp,
6725
- onWheel,
6726
- onDoubleClick
6727
- }, (0, react.createElement)("div", {
6728
- ref: contentRef,
6729
- className: `lks14-stage-content${gesturing ? "" : " glide"}`,
6730
- style: {
6731
- transform: `translate(${String(tf.x)}px, ${String(tf.y)}px) scale(${String(tf.scale)})`,
6732
- transformOrigin: "0 0"
6733
- }
6734
- }, children), (0, react.createElement)("div", {
6735
- className: "lks14-stage-tools",
6736
- "data-testid": "canvas-zoom-controls"
6737
- }, (0, react.createElement)("button", {
6738
- onClick: () => {
6739
- zoomBy(1 / STEP);
6740
- },
6741
- disabled: tf.scale <= .050001000000000004,
6742
- "aria-label": tr("artifact.zoomOut"),
6743
- "data-testid": "canvas-zoom-out"
6744
- }, "−"), (0, react.createElement)("span", { "data-testid": "canvas-zoom-pct" }, `${String(Math.round(tf.scale * 100))}%`), (0, react.createElement)("button", {
6745
- onClick: () => {
6746
- zoomBy(STEP);
6747
- },
6748
- disabled: tf.scale >= 3.999999,
6749
- "aria-label": tr("artifact.zoomIn"),
6750
- "data-testid": "canvas-zoom-in"
6751
- }, (0, react.createElement)(IconPlusOutline16, { size: 14 })), (0, react.createElement)("button", {
6752
- onClick: () => {
6753
- refit();
6754
- },
6755
- "aria-label": tr("artifact.canvas.fit"),
6756
- "data-testid": "canvas-zoom-fit"
6757
- }, (0, react.createElement)(IconMaximizeOutline16, { size: 14 }))), (0, react.createElement)("span", {
6758
- "data-testid": "canvas-natural-size",
6759
- className: "lks14-stage-probe"
6760
- }, size === null ? "" : `${String(size.w)}x${String(size.h)}`));
6760
+ className: "lks-qcard",
6761
+ "data-lks-quiz": artifactId,
6762
+ ref: rootRef
6763
+ }, (0, react.createElement)("div", { className: "lks-qcard-head" }, (0, react.createElement)(IconBookFill16, { size: 14 }), data.title ?? tr("quiz.card.title"), (0, react.createElement)("span", { className: "lks-qcard-count" }, tr("quiz.progress", {
6764
+ cur: cursor + 1,
6765
+ total: questions.length
6766
+ }))), (0, react.createElement)("div", { className: "lks-qcard-q" }, (0, react.createElement)("div", { className: "lks-qcard-prompt" }, q.prompt), (0, react.createElement)("div", { className: "lks-qcard-opts" }, ...options), explanation));
6761
6767
  }
6762
6768
  //#endregion
6763
6769
  //#region src/client/artifact-cards.tsx
@@ -7008,7 +7014,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7008
7014
  compare_table: "artifact.kind.compare_table",
7009
7015
  code_walkthrough: "artifact.kind.code_walkthrough",
7010
7016
  diagram: "artifact.kind.diagram",
7011
- guess: "artifact.guess.title"
7017
+ guess: "artifact.guess.title",
7018
+ quiz: "quiz.card.title"
7012
7019
  };
7013
7020
  /**
7014
7021
  * Issue #8: every artifact card wears a fold bar — cards sediment into the
@@ -7018,19 +7025,21 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7018
7025
  * the teach-stage and backlog surfaces start folded — the lesson prose owns
7019
7026
  * the first screen, issue #10).
7020
7027
  */
7021
- function FoldableArtifactCard({ artifact, send, defaultFolded = false, revealed = false }) {
7022
- const [folded, setFolded] = (0, react.useState)(() => acardFoldStoredOf(artifact.id) ?? defaultFolded);
7028
+ function FoldShell({ artifactId, title, defaultFolded, children }) {
7029
+ const [folded, setFolded] = (0, react.useState)(() => acardFoldStoredOf(artifactId) ?? defaultFolded);
7030
+ (0, react.useEffect)(() => {
7031
+ setFolded(acardFoldStoredOf(artifactId) ?? defaultFolded);
7032
+ }, [artifactId, defaultFolded]);
7023
7033
  const toggle = () => {
7024
7034
  setFolded((cur) => {
7025
7035
  const next = !cur;
7026
- markAcardFoldStored(artifact.id, next);
7036
+ markAcardFoldStored(artifactId, next);
7027
7037
  return next;
7028
7038
  });
7029
7039
  };
7030
- const title = artifact.title.trim() !== "" ? artifact.title : tr(KIND_LABEL_KEY[artifact.artifactType] ?? "artifact.fold");
7031
7040
  return (0, react.createElement)("div", {
7032
7041
  className: `lks-acard-foldwrap${folded ? " folded" : ""}`,
7033
- "data-lks-artifact": artifact.id
7042
+ "data-lks-artifact": artifactId
7034
7043
  }, (0, react.createElement)("button", {
7035
7044
  className: "lks-acard-fold",
7036
7045
  "aria-expanded": String(!folded),
@@ -7039,12 +7048,43 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7039
7048
  }, (0, react.createElement)("span", {
7040
7049
  className: "lks-acard-fold-caret",
7041
7050
  "aria-hidden": "true"
7042
- }, folded ? "▸" : "▾"), (0, react.createElement)("span", { className: "lks-acard-fold-title" }, title)), folded ? null : (0, react.createElement)(ArtifactCard, {
7051
+ }, folded ? "▸" : "▾"), (0, react.createElement)("span", { className: "lks-acard-fold-title" }, title)), folded ? null : children);
7052
+ }
7053
+ function FoldableArtifactCard({ artifact, send, defaultFolded = false, revealed = false }) {
7054
+ const title = artifact.title.trim() !== "" ? artifact.title : tr(KIND_LABEL_KEY[artifact.artifactType] ?? "artifact.fold");
7055
+ return (0, react.createElement)(FoldShell, {
7056
+ artifactId: artifact.id,
7057
+ title,
7058
+ defaultFolded
7059
+ }, (0, react.createElement)(ArtifactCard, {
7043
7060
  artifact,
7044
7061
  send,
7045
7062
  revealed
7046
7063
  }));
7047
7064
  }
7065
+ /**
7066
+ * Issue #14: quiz cards wear the same fold bar — a finished card's review
7067
+ * screen (every prompt + pick + answer + explanation) otherwise stacks open
7068
+ * forever, and the sediment backlog grows unbounded. Folding UNMOUNTS the
7069
+ * QuizCard: safe, because the answers AND the completion-hook receipt both
7070
+ * persist per artifact — unfolding neither loses progress nor re-fires the
7071
+ * 练习卡完成 hook.
7072
+ */
7073
+ function FoldableQuizCard({ lessonId, artifact, masteryPct, send, defaultFolded = false, onFinished }) {
7074
+ const title = artifact.title.trim() !== "" ? artifact.title : tr(KIND_LABEL_KEY.quiz);
7075
+ return (0, react.createElement)(FoldShell, {
7076
+ artifactId: artifact.id,
7077
+ title,
7078
+ defaultFolded
7079
+ }, (0, react.createElement)(QuizCard, {
7080
+ lessonId,
7081
+ artifactId: artifact.id,
7082
+ data: artifact.data,
7083
+ masteryPct,
7084
+ send,
7085
+ onFinished
7086
+ }));
7087
+ }
7048
7088
  //#endregion
7049
7089
  //#region src/client/highlights.ts
7050
7090
  /**
@@ -10172,13 +10212,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10172
10212
  role: "tool",
10173
10213
  toolState: "done"
10174
10214
  });
10175
- return (0, react.createElement)("div", { className: "lks14-inline-artifact" }, artifact.artifactType === "quiz" ? (0, react.createElement)(QuizCard, {
10215
+ return (0, react.createElement)("div", {
10216
+ className: "lks14-inline-artifact",
10217
+ key: row.artifactId
10218
+ }, artifact.artifactType === "quiz" ? (0, react.createElement)(FoldableQuizCard, {
10176
10219
  lessonId: lesson.lessonId,
10177
- artifactId: artifact.id,
10178
- data: artifact.data,
10220
+ artifact,
10179
10221
  masteryPct: lesson.masteryPct,
10180
- send,
10181
- onFinished: () => {}
10222
+ send
10182
10223
  }) : (0, react.createElement)(FoldableArtifactCard, {
10183
10224
  artifact,
10184
10225
  send,
@@ -10445,14 +10486,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10445
10486
  total: msgAudio?.total ?? 0,
10446
10487
  onPlay: playMessage,
10447
10488
  onStop: stopMessage
10448
- } : void 0)), rowsView.length > 0 && rowsView[rowsView.length - 1].role === "user" && busy ? (0, react.createElement)("div", { className: "lks14-thinking" }, (0, react.createElement)("i", null), (0, react.createElement)("i", null), (0, react.createElement)("i", null)) : null), ...backlog.filter((a) => a.artifactType === "quiz").map((a) => (0, react.createElement)(QuizCard, {
10489
+ } : void 0)), rowsView.length > 0 && rowsView[rowsView.length - 1].role === "user" && busy ? (0, react.createElement)("div", { className: "lks14-thinking" }, (0, react.createElement)("i", null), (0, react.createElement)("i", null), (0, react.createElement)("i", null)) : null), ...backlog.filter((a) => a.artifactType === "quiz").map((a) => (0, react.createElement)(FoldableQuizCard, {
10449
10490
  key: a.id,
10450
10491
  lessonId: lesson.lessonId,
10451
- artifactId: a.id,
10452
- data: a.data,
10492
+ artifact: a,
10453
10493
  masteryPct: lesson.masteryPct,
10454
10494
  send,
10455
- onFinished: () => {}
10495
+ defaultFolded: true
10456
10496
  })), ...backlog.filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(FoldableArtifactCard, {
10457
10497
  key: a.id,
10458
10498
  artifact: a,