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 +810 -770
- package/lib/client.js.map +1 -1
- package/package.json +4 -3
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/
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
const
|
|
5908
|
-
|
|
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
|
-
/**
|
|
5916
|
-
function
|
|
5917
|
-
|
|
5918
|
-
return
|
|
5919
|
-
|
|
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
|
-
*
|
|
6045
|
-
*
|
|
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
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
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
|
-
*
|
|
6077
|
-
*
|
|
5931
|
+
* 平移钳制:内容不会被拖到完全离屏 —— 至少留 `keep`(默认 64px)可见,
|
|
5932
|
+
* 且内容小于视口时不允许拖走(保持居中观感)。
|
|
6078
5933
|
*/
|
|
6079
|
-
function
|
|
6080
|
-
const
|
|
6081
|
-
const
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
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
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
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
|
-
/**
|
|
6120
|
-
function
|
|
6121
|
-
|
|
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
|
-
*
|
|
6125
|
-
*
|
|
6126
|
-
*
|
|
6127
|
-
*
|
|
6128
|
-
|
|
6129
|
-
|
|
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
|
-
*
|
|
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
|
|
6400
|
-
|
|
6401
|
-
|
|
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
|
-
|
|
6421
|
-
|
|
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
|
-
*
|
|
6424
|
-
*
|
|
6425
|
-
*
|
|
6426
|
-
*
|
|
6427
|
-
*
|
|
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
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
const
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
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
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
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
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
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
|
-
},
|
|
6660
|
-
const
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
6718
|
-
"data-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
|
|
6723
|
-
|
|
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
|
|
7022
|
-
const [folded, setFolded] = (0, react.useState)(() => acardFoldStoredOf(
|
|
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(
|
|
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":
|
|
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 :
|
|
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", {
|
|
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
|
-
|
|
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)(
|
|
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
|
-
|
|
10452
|
-
data: a.data,
|
|
10492
|
+
artifact: a,
|
|
10453
10493
|
masteryPct: lesson.masteryPct,
|
|
10454
10494
|
send,
|
|
10455
|
-
|
|
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,
|