894 lines
29 KiB
JavaScript
894 lines
29 KiB
JavaScript
(function () {
|
||
const $tool = (selector, root = document) => root.querySelector(selector);
|
||
const $$tool = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||
|
||
const CALC_FIELDS = {
|
||
derivative: ["order"],
|
||
integral: ["bounds"],
|
||
limit: ["point", "direction"],
|
||
series: ["point", "order"],
|
||
solve_system: ["variables"],
|
||
gradient: ["variables"],
|
||
hessian: ["variables"],
|
||
base: ["base"],
|
||
unit: ["unit"],
|
||
};
|
||
|
||
const CALC_OPERATION_META = {
|
||
statistics: {
|
||
placeholder: "12, 15, 18, 21, 24",
|
||
hint: "使用逗号分隔数据,返回四分位数、方差、标准差、极差等统计量。",
|
||
},
|
||
base: {
|
||
placeholder: "FF",
|
||
hint: "输入不带前缀的整数,原进制与目标进制均支持 2 到 36。",
|
||
},
|
||
solve: {
|
||
placeholder: "x^2 - 5*x + 6 = 0",
|
||
hint: "可省略等号右侧;通过“变量”指定要求解的未知量。",
|
||
},
|
||
solve_system: {
|
||
placeholder: "x + y = 5; x - y = 1",
|
||
hint: "使用分号分隔方程,通过“变量列表”指定未知量,最多 4 个变量、6 个方程。",
|
||
},
|
||
polynomial_roots: {
|
||
placeholder: "x^5 - x + 1 = 0",
|
||
hint: "返回至多 12 次单变量多项式的全部高精度数值根,包括复根。",
|
||
},
|
||
series: {
|
||
placeholder: "exp(x) * cos(x)",
|
||
hint: "在指定点附近展开;“阶数 6”表示保留到 5 阶并显示余项。",
|
||
order: 6,
|
||
},
|
||
gradient: {
|
||
placeholder: "x^2*y + sin(y)",
|
||
hint: "变量列表示例:x,y。结果按该顺序组成梯度列向量。",
|
||
},
|
||
hessian: {
|
||
placeholder: "x^2 + x*y + y^2",
|
||
hint: "变量列表示例:x,y。结果为二阶偏导组成的 Hessian 矩阵。",
|
||
},
|
||
matrix_det: { placeholder: "1,2;3,4", hint: "逗号分列、分号分行,最多 36 个元素。" },
|
||
matrix_inverse: { placeholder: "1,2;3,4", hint: "逆矩阵要求方阵且行列式非零。" },
|
||
matrix_rref: { placeholder: "1,2,3;2,4,6", hint: "返回矩阵的行最简形。" },
|
||
matrix_transpose: { placeholder: "1,2,3;4,5,6", hint: "交换矩阵的行与列。" },
|
||
matrix_rank: { placeholder: "1,2,3;2,4,6", hint: "通过精确行变换计算矩阵秩。" },
|
||
matrix_nullspace: { placeholder: "1,2;2,4", hint: "返回齐次方程组对应零空间的一组基。" },
|
||
matrix_eigenvalues: { placeholder: "2,1;1,2", hint: "要求方阵,返回特征值及其代数重数。" },
|
||
};
|
||
|
||
function calculatorCategoryFor(operation) {
|
||
const option = [...$tool("#calc-operation").options].find(
|
||
(item) => item.value === operation,
|
||
);
|
||
return option?.parentElement?.dataset.calcCategoryOptions || "algebra";
|
||
}
|
||
|
||
function setCalculatorCategory(category, selectFirst = true) {
|
||
const select = $tool("#calc-operation");
|
||
$$tool("[data-calc-category-options]").forEach((group) => {
|
||
const active = group.dataset.calcCategoryOptions === category;
|
||
group.hidden = !active;
|
||
group.disabled = !active;
|
||
});
|
||
$$tool("[data-calc-category]").forEach((button) => {
|
||
button.classList.toggle(
|
||
"active",
|
||
button.dataset.calcCategory === category,
|
||
);
|
||
});
|
||
if (selectFirst) {
|
||
const group = $tool(
|
||
`[data-calc-category-options="${category}"]`,
|
||
);
|
||
if (group?.querySelector("option")) {
|
||
select.value = group.querySelector("option").value;
|
||
}
|
||
}
|
||
updateCalculatorFields();
|
||
}
|
||
|
||
function resultText(value, field = "exact") {
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => resultText(item, field)).join("; ");
|
||
}
|
||
if (value && typeof value === "object") {
|
||
if ("exact" in value) return value[field] || value.exact;
|
||
return Object.entries(value)
|
||
.map(([key, item]) => `${key}: ${resultText(item, field)}`)
|
||
.join("; ");
|
||
}
|
||
return String(value ?? "");
|
||
}
|
||
|
||
function formatCalculatorResult(result) {
|
||
return {
|
||
exact: resultText(result, "exact"),
|
||
decimal: resultText(result, "decimal"),
|
||
latex: resultText(result, "latex"),
|
||
};
|
||
}
|
||
|
||
function updateCalculatorFields() {
|
||
const operation = $tool("#calc-operation").value;
|
||
const visible = new Set(CALC_FIELDS[operation] || []);
|
||
$$tool("[data-calc-field]").forEach((field) => {
|
||
field.hidden = !visible.has(field.dataset.calcField);
|
||
});
|
||
const meta = CALC_OPERATION_META[operation] || {};
|
||
$tool("#calc-input").placeholder = meta.placeholder || "例如:sqrt(2) + 1/3";
|
||
$tool("#calc-hint").textContent =
|
||
meta.hint || "支持精确常量、受限数学函数和变量;按 Command/Ctrl + Enter 运行。";
|
||
$tool("#calc-order").max = operation === "derivative" ? 5 : 12;
|
||
if (meta.order) $tool("#calc-order").value = meta.order;
|
||
else if (operation === "derivative") $tool("#calc-order").value = 1;
|
||
}
|
||
|
||
async function runCalculator() {
|
||
const button = $tool("#calc-run");
|
||
const operation = $tool("#calc-operation").value;
|
||
const expression = $tool("#calc-input").value.trim();
|
||
button.disabled = true;
|
||
$tool("#calc-history").textContent = "数学内核正在计算…";
|
||
try {
|
||
const payload = await api("toolbox/calculate/", {
|
||
method: "POST",
|
||
body: {
|
||
operation,
|
||
expression,
|
||
variable: $tool("#calc-variable").value,
|
||
variables: $tool("#calc-variables").value,
|
||
order: $tool("#calc-order").value,
|
||
lower: $tool("#calc-lower").value,
|
||
upper: $tool("#calc-upper").value,
|
||
point: $tool("#calc-point").value,
|
||
direction: $tool("#calc-direction").value,
|
||
from_base: $tool("#calc-from-base").value,
|
||
to_base: $tool("#calc-to-base").value,
|
||
from_unit: $tool("#calc-from-unit").value,
|
||
to_unit: $tool("#calc-to-unit").value,
|
||
},
|
||
});
|
||
const result = formatCalculatorResult(payload.result);
|
||
$tool("#calc-history").textContent = `${operation} · ${expression}`;
|
||
$tool("#calc-output").textContent = result.exact || "完成";
|
||
$tool("#calc-decimal").textContent = result.decimal || result.exact || "—";
|
||
$tool("#calc-latex").textContent = result.latex || result.exact || "—";
|
||
$tool("#calc-steps").replaceChildren(
|
||
...(payload.steps || []).map((step) => {
|
||
const item = document.createElement("li");
|
||
item.textContent = step;
|
||
return item;
|
||
})
|
||
);
|
||
} catch (error) {
|
||
$tool("#calc-history").textContent = error.message;
|
||
$tool("#calc-output").textContent = "计算失败";
|
||
$tool("#calc-decimal").textContent = "—";
|
||
$tool("#calc-latex").textContent = "—";
|
||
$tool("#calc-steps").replaceChildren();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function canvasPoint(canvas, event) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
return {
|
||
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
|
||
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
|
||
};
|
||
}
|
||
|
||
function downloadCanvas(canvas, name) {
|
||
const link = document.createElement("a");
|
||
link.download = `${name}-${new Date().toISOString().slice(0, 10)}.png`;
|
||
link.href = canvas.toDataURL("image/png");
|
||
link.click();
|
||
}
|
||
|
||
class Whiteboard {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.context = canvas.getContext("2d");
|
||
this.tool = "pen";
|
||
this.history = [];
|
||
this.drawing = false;
|
||
this.start = null;
|
||
this.preview = null;
|
||
this.onChange = null;
|
||
this.reset();
|
||
canvas.addEventListener("pointerdown", (event) => this.startDrawing(event));
|
||
canvas.addEventListener("pointermove", (event) => this.move(event));
|
||
canvas.addEventListener("pointerup", (event) => this.finish(event));
|
||
canvas.addEventListener("pointercancel", () => this.cancel());
|
||
}
|
||
|
||
reset() {
|
||
this.context.fillStyle = "#ffffff";
|
||
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||
}
|
||
|
||
snapshot() {
|
||
this.history.push(this.canvas.toDataURL("image/png"));
|
||
if (this.history.length > 20) this.history.shift();
|
||
}
|
||
|
||
emitSnapshot() {
|
||
if (!this.onChange) return;
|
||
const data = this.canvas.toDataURL("image/png");
|
||
if (data.length <= 700000) this.onChange(data);
|
||
else showToast("当前画布内容较大,已保留本地编辑但暂停联机同步");
|
||
}
|
||
|
||
applySnapshot(data) {
|
||
if (typeof data !== "string" || !data.startsWith("data:image/")) return;
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
|
||
};
|
||
image.src = data;
|
||
}
|
||
|
||
startDrawing(event) {
|
||
event.preventDefault();
|
||
this.snapshot();
|
||
this.drawing = true;
|
||
this.start = canvasPoint(this.canvas, event);
|
||
if (this.tool === "text") {
|
||
const text = $tool("#whiteboard-text").value.trim();
|
||
if (!text) {
|
||
this.history.pop();
|
||
showToast("先输入要放到白板的文字或公式");
|
||
} else {
|
||
const size = Math.max(18, Number($tool("#whiteboard-size").value) * 5);
|
||
this.context.fillStyle = $tool("#whiteboard-color").value;
|
||
this.context.font = `${size}px "SFMono-Regular", "PingFang SC", sans-serif`;
|
||
this.context.fillText(text, this.start.x, this.start.y);
|
||
this.emitSnapshot();
|
||
}
|
||
this.drawing = false;
|
||
return;
|
||
}
|
||
this.preview = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||
this.canvas.setPointerCapture(event.pointerId);
|
||
this.context.lineCap = "round";
|
||
this.context.lineJoin = "round";
|
||
this.context.lineWidth = Number($tool("#whiteboard-size").value);
|
||
this.context.strokeStyle =
|
||
this.tool === "eraser" ? "#ffffff" : $tool("#whiteboard-color").value;
|
||
if (this.tool === "pen" || this.tool === "eraser") {
|
||
this.context.beginPath();
|
||
this.context.moveTo(this.start.x, this.start.y);
|
||
}
|
||
}
|
||
|
||
move(event) {
|
||
if (!this.drawing) return;
|
||
event.preventDefault();
|
||
const point = canvasPoint(this.canvas, event);
|
||
if (this.tool === "pen" || this.tool === "eraser") {
|
||
this.context.lineTo(point.x, point.y);
|
||
this.context.stroke();
|
||
return;
|
||
}
|
||
this.context.putImageData(this.preview, 0, 0);
|
||
this.context.beginPath();
|
||
if (this.tool === "line") {
|
||
this.context.moveTo(this.start.x, this.start.y);
|
||
this.context.lineTo(point.x, point.y);
|
||
} else {
|
||
this.context.rect(
|
||
this.start.x,
|
||
this.start.y,
|
||
point.x - this.start.x,
|
||
point.y - this.start.y,
|
||
);
|
||
}
|
||
this.context.stroke();
|
||
}
|
||
|
||
finish(event) {
|
||
if (!this.drawing) return;
|
||
this.move(event);
|
||
this.drawing = false;
|
||
this.preview = null;
|
||
this.emitSnapshot();
|
||
}
|
||
|
||
cancel() {
|
||
if (this.preview) this.context.putImageData(this.preview, 0, 0);
|
||
this.drawing = false;
|
||
}
|
||
|
||
undo() {
|
||
const source = this.history.pop();
|
||
if (!source) return;
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
this.context.drawImage(image, 0, 0);
|
||
this.emitSnapshot();
|
||
};
|
||
image.src = source;
|
||
}
|
||
|
||
clear() {
|
||
this.snapshot();
|
||
this.reset();
|
||
this.emitSnapshot();
|
||
}
|
||
|
||
addGrid() {
|
||
this.snapshot();
|
||
const context = this.context;
|
||
context.strokeStyle = "#e1e7e1";
|
||
context.lineWidth = 1;
|
||
for (let x = 0; x <= this.canvas.width; x += 50) {
|
||
context.beginPath();
|
||
context.moveTo(x, 0);
|
||
context.lineTo(x, this.canvas.height);
|
||
context.stroke();
|
||
}
|
||
for (let y = 0; y <= this.canvas.height; y += 50) {
|
||
context.beginPath();
|
||
context.moveTo(0, y);
|
||
context.lineTo(this.canvas.width, y);
|
||
context.stroke();
|
||
}
|
||
context.strokeStyle = "#7a877f";
|
||
context.lineWidth = 2;
|
||
context.beginPath();
|
||
context.moveTo(this.canvas.width / 2, 0);
|
||
context.lineTo(this.canvas.width / 2, this.canvas.height);
|
||
context.moveTo(0, this.canvas.height / 2);
|
||
context.lineTo(this.canvas.width, this.canvas.height / 2);
|
||
context.stroke();
|
||
this.emitSnapshot();
|
||
}
|
||
|
||
addImage(file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
this.snapshot();
|
||
const scale = Math.min(
|
||
1,
|
||
(this.canvas.width * 0.8) / image.width,
|
||
(this.canvas.height * 0.8) / image.height,
|
||
);
|
||
this.context.drawImage(
|
||
image,
|
||
30,
|
||
30,
|
||
image.width * scale,
|
||
image.height * scale,
|
||
);
|
||
this.emitSnapshot();
|
||
};
|
||
image.src = reader.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}
|
||
|
||
class GeometryBoard {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.context = canvas.getContext("2d");
|
||
this.tool = "point";
|
||
this.points = [];
|
||
this.segments = [];
|
||
this.circles = [];
|
||
this.history = [];
|
||
this.pending = [];
|
||
this.dragging = null;
|
||
this.onChange = null;
|
||
canvas.addEventListener("pointerdown", (event) => this.pointerDown(event));
|
||
canvas.addEventListener("pointermove", (event) => this.pointerMove(event));
|
||
canvas.addEventListener("pointerup", (event) => this.pointerUp(event));
|
||
canvas.addEventListener("pointercancel", () => {
|
||
this.dragging = null;
|
||
});
|
||
this.draw();
|
||
}
|
||
|
||
serialize() {
|
||
return {
|
||
points: this.points,
|
||
segments: this.segments,
|
||
circles: this.circles,
|
||
};
|
||
}
|
||
|
||
emitState() {
|
||
if (this.onChange) this.onChange(this.serialize());
|
||
}
|
||
|
||
applyState(payload) {
|
||
if (
|
||
!payload ||
|
||
!Array.isArray(payload.points) ||
|
||
!Array.isArray(payload.segments) ||
|
||
!Array.isArray(payload.circles)
|
||
) {
|
||
return;
|
||
}
|
||
this.points = payload.points.slice(0, 300);
|
||
this.segments = payload.segments.slice(0, 500);
|
||
this.circles = payload.circles.slice(0, 300);
|
||
this.pending = [];
|
||
this.draw();
|
||
}
|
||
|
||
save() {
|
||
this.history.push(
|
||
JSON.stringify({
|
||
points: this.points,
|
||
segments: this.segments,
|
||
circles: this.circles,
|
||
})
|
||
);
|
||
if (this.history.length > 30) this.history.shift();
|
||
}
|
||
|
||
findOrCreate(point) {
|
||
const nearest = this.findNearest(point);
|
||
if (nearest >= 0) return nearest;
|
||
this.points.push(point);
|
||
return this.points.length - 1;
|
||
}
|
||
|
||
findNearest(point) {
|
||
let nearest = -1;
|
||
let distance = 28;
|
||
this.points.forEach((item, index) => {
|
||
const current = Math.hypot(item.x - point.x, item.y - point.y);
|
||
if (current < distance) {
|
||
nearest = index;
|
||
distance = current;
|
||
}
|
||
});
|
||
return nearest;
|
||
}
|
||
|
||
pointerDown(event) {
|
||
if (this.tool !== "move") return;
|
||
event.preventDefault();
|
||
const nearest = this.findNearest(canvasPoint(this.canvas, event));
|
||
if (nearest < 0 || this.points[nearest].derived) return;
|
||
this.save();
|
||
this.dragging = nearest;
|
||
this.canvas.setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
pointerMove(event) {
|
||
if (this.dragging === null) return;
|
||
event.preventDefault();
|
||
const point = canvasPoint(this.canvas, event);
|
||
this.points[this.dragging] = point;
|
||
this.draw();
|
||
}
|
||
|
||
pointerUp(event) {
|
||
if (this.tool === "move") {
|
||
this.pointerMove(event);
|
||
this.dragging = null;
|
||
this.emitState();
|
||
return;
|
||
}
|
||
this.handlePoint(event);
|
||
}
|
||
|
||
handlePoint(event) {
|
||
event.preventDefault();
|
||
const point = canvasPoint(this.canvas, event);
|
||
this.save();
|
||
if (this.tool === "point") {
|
||
this.findOrCreate(point);
|
||
} else {
|
||
this.pending.push(this.findOrCreate(point));
|
||
if (this.pending.length === 2) {
|
||
const [first, second] = this.pending;
|
||
if (first !== second && this.tool === "segment") {
|
||
this.segments.push({ first, second });
|
||
} else if (first !== second && this.tool === "circle") {
|
||
this.circles.push({ center: first, edge: second });
|
||
} else if (first !== second && this.tool === "midpoint") {
|
||
const a = this.points[first];
|
||
const b = this.points[second];
|
||
this.points.push({
|
||
x: (a.x + b.x) / 2,
|
||
y: (a.y + b.y) / 2,
|
||
derived: true,
|
||
parents: [first, second],
|
||
});
|
||
this.segments.push({ first, second, guide: true });
|
||
}
|
||
this.pending = [];
|
||
}
|
||
}
|
||
this.draw();
|
||
this.emitState();
|
||
$tool("#geometry-hint").textContent = this.pending.length
|
||
? "再选择一个点完成构造。"
|
||
: "可继续创建或切换构造工具。";
|
||
}
|
||
|
||
draw() {
|
||
const context = this.context;
|
||
const width = this.canvas.width;
|
||
const height = this.canvas.height;
|
||
this.points.forEach((point) => {
|
||
if (!point.derived || !point.parents) return;
|
||
const first = this.points[point.parents[0]];
|
||
const second = this.points[point.parents[1]];
|
||
point.x = (first.x + second.x) / 2;
|
||
point.y = (first.y + second.y) / 2;
|
||
});
|
||
context.fillStyle = "#fbfbf7";
|
||
context.fillRect(0, 0, width, height);
|
||
context.strokeStyle = "#e7e8e2";
|
||
context.lineWidth = 1;
|
||
for (let x = 0; x <= width; x += 50) {
|
||
context.beginPath();
|
||
context.moveTo(x, 0);
|
||
context.lineTo(x, height);
|
||
context.stroke();
|
||
}
|
||
for (let y = 0; y <= height; y += 50) {
|
||
context.beginPath();
|
||
context.moveTo(0, y);
|
||
context.lineTo(width, y);
|
||
context.stroke();
|
||
}
|
||
this.segments.forEach((segment) => {
|
||
const first = this.points[segment.first];
|
||
const second = this.points[segment.second];
|
||
context.strokeStyle = segment.guide ? "#9ca3af" : "#196548";
|
||
context.setLineDash(segment.guide ? [8, 6] : []);
|
||
context.lineWidth = 3;
|
||
context.beginPath();
|
||
context.moveTo(first.x, first.y);
|
||
context.lineTo(second.x, second.y);
|
||
context.stroke();
|
||
context.setLineDash([]);
|
||
if ($tool("#geometry-labels").checked) {
|
||
context.fillStyle = "#47534c";
|
||
context.font = "18px sans-serif";
|
||
context.fillText(
|
||
Math.hypot(first.x - second.x, first.y - second.y).toFixed(1),
|
||
(first.x + second.x) / 2 + 8,
|
||
(first.y + second.y) / 2 - 8,
|
||
);
|
||
}
|
||
});
|
||
this.circles.forEach((circle) => {
|
||
const center = this.points[circle.center];
|
||
const edge = this.points[circle.edge];
|
||
const radius = Math.hypot(center.x - edge.x, center.y - edge.y);
|
||
context.strokeStyle = "#5d73e8";
|
||
context.lineWidth = 3;
|
||
context.beginPath();
|
||
context.arc(center.x, center.y, radius, 0, Math.PI * 2);
|
||
context.stroke();
|
||
});
|
||
this.points.forEach((point, index) => {
|
||
context.fillStyle = point.derived ? "#d86f45" : "#17211b";
|
||
context.beginPath();
|
||
context.arc(point.x, point.y, 7, 0, Math.PI * 2);
|
||
context.fill();
|
||
if ($tool("#geometry-labels").checked) {
|
||
context.fillStyle = "#17211b";
|
||
context.font = "18px sans-serif";
|
||
context.fillText(
|
||
`${String.fromCharCode(65 + (index % 26))} (${Math.round(point.x)}, ${Math.round(point.y)})`,
|
||
point.x + 10,
|
||
point.y - 10,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
undo() {
|
||
const state = this.history.pop();
|
||
if (!state) return;
|
||
const parsed = JSON.parse(state);
|
||
this.points = parsed.points;
|
||
this.segments = parsed.segments;
|
||
this.circles = parsed.circles;
|
||
this.pending = [];
|
||
this.draw();
|
||
this.emitState();
|
||
}
|
||
|
||
clear() {
|
||
this.save();
|
||
this.points = [];
|
||
this.segments = [];
|
||
this.circles = [];
|
||
this.pending = [];
|
||
this.draw();
|
||
this.emitState();
|
||
}
|
||
}
|
||
|
||
class BoardRealtime {
|
||
constructor() {
|
||
this.session = null;
|
||
this.socket = null;
|
||
this.pollTimer = null;
|
||
}
|
||
|
||
websocketUrl(path) {
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||
return `${protocol}//${window.location.host}${path}`;
|
||
}
|
||
|
||
close() {
|
||
if (this.pollTimer) window.clearInterval(this.pollTimer);
|
||
this.pollTimer = null;
|
||
if (this.socket) {
|
||
this.socket.onclose = null;
|
||
this.socket.close();
|
||
}
|
||
this.socket = null;
|
||
}
|
||
|
||
async refresh() {
|
||
if (!this.session?.session_id) return;
|
||
try {
|
||
this.session = await api(`toolbox/boards/${this.session.session_id}/`);
|
||
this.render();
|
||
} catch (error) {
|
||
if (error.status === 404) this.close();
|
||
}
|
||
}
|
||
|
||
connect() {
|
||
this.close();
|
||
if (!this.session?.websocket_path) return;
|
||
const socket = new WebSocket(this.websocketUrl(this.session.websocket_path));
|
||
this.socket = socket;
|
||
socket.addEventListener("open", () => {
|
||
this.render("实时连接已建立");
|
||
socket.send(JSON.stringify({ type: "ping" }));
|
||
});
|
||
socket.addEventListener("message", async (event) => {
|
||
let message;
|
||
try {
|
||
message = JSON.parse(event.data);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (
|
||
message.type === "canvas" &&
|
||
message.user_id !== String(state.user?.id)
|
||
) {
|
||
whiteboard.applySnapshot(message.payload);
|
||
} else if (
|
||
message.type === "geometry" &&
|
||
message.user_id !== String(state.user?.id)
|
||
) {
|
||
geometry.applyState(message.payload);
|
||
} else if (message.type === "state") {
|
||
await this.refresh();
|
||
if (message.reason === "joined" && this.session?.role === "host") {
|
||
this.send("canvas", whiteboard.canvas.toDataURL("image/png"));
|
||
this.send("geometry", geometry.serialize());
|
||
}
|
||
} else if (message.type === "error") {
|
||
showToast(message.message);
|
||
}
|
||
});
|
||
socket.addEventListener("close", () => {
|
||
this.render("实时连接已断开,正在轮询房间状态");
|
||
});
|
||
this.pollTimer = window.setInterval(() => this.refresh(), 3000);
|
||
}
|
||
|
||
send(type, payload) {
|
||
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
||
this.socket.send(JSON.stringify({ type, payload }));
|
||
}
|
||
|
||
async create() {
|
||
if (!requireAuth()) return;
|
||
const mode = $tool("#board-session-mode").value;
|
||
try {
|
||
this.session = await api("toolbox/boards/", {
|
||
method: "POST",
|
||
body: { mode },
|
||
});
|
||
this.render();
|
||
this.connect();
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
async join(event) {
|
||
event.preventDefault();
|
||
if (!requireAuth()) return;
|
||
const input = $tool("#board-code-input");
|
||
try {
|
||
this.session = await api("toolbox/boards/join/", {
|
||
method: "POST",
|
||
body: { code: input.value },
|
||
});
|
||
input.value = "";
|
||
this.render();
|
||
this.connect();
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
async guess(event) {
|
||
event.preventDefault();
|
||
if (!this.session) return;
|
||
const input = $tool("#board-guess-input");
|
||
try {
|
||
const result = await api(
|
||
`toolbox/boards/${this.session.session_id}/guess/`,
|
||
{
|
||
method: "POST",
|
||
body: { guess: input.value },
|
||
},
|
||
);
|
||
this.session = result;
|
||
showToast(result.message);
|
||
if (result.correct) input.value = "";
|
||
this.render();
|
||
} catch (error) {
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
|
||
render(connectionMessage = "") {
|
||
const status = $tool("#board-session-status");
|
||
const target = $tool("#board-target");
|
||
const guessForm = $tool("#board-guess-form");
|
||
if (!this.session) {
|
||
status.textContent = "当前为本地画板,创建或加入后开始实时同步。";
|
||
target.hidden = true;
|
||
guessForm.hidden = true;
|
||
return;
|
||
}
|
||
const participants = this.session.guest
|
||
? `${this.session.host} 与 ${this.session.guest}`
|
||
: `${this.session.host} 正在等待另一位用户`;
|
||
status.textContent =
|
||
`${this.session.mode_label} · 联机码 ${this.session.code} · ${participants}` +
|
||
(connectionMessage ? ` · ${connectionMessage}` : "");
|
||
target.hidden = !this.session.target;
|
||
target.textContent = this.session.target
|
||
? `本轮数学对象:${this.session.target}`
|
||
: "";
|
||
guessForm.hidden = !(
|
||
this.session.mode === "draw_guess" &&
|
||
this.session.role === "guest" &&
|
||
this.session.status === "active"
|
||
);
|
||
if (this.session.status === "completed") {
|
||
status.textContent +=
|
||
` · 本轮结束,比分 ${this.session.host_score}:${this.session.guest_score}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
let whiteboard;
|
||
let geometry;
|
||
let boardRealtime;
|
||
|
||
function initDrawingTools() {
|
||
whiteboard = new Whiteboard($tool("#whiteboard-canvas"));
|
||
geometry = new GeometryBoard($tool("#geometry-canvas"));
|
||
boardRealtime = new BoardRealtime();
|
||
whiteboard.onChange = (payload) => boardRealtime.send("canvas", payload);
|
||
geometry.onChange = (payload) => boardRealtime.send("geometry", payload);
|
||
$$tool("[data-board-pane-toggle]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const pane = button.dataset.boardPaneToggle;
|
||
$$tool("[data-board-pane-toggle]").forEach((item) => {
|
||
item.classList.toggle("active", item === button);
|
||
});
|
||
$$tool("[data-board-pane]").forEach((item) => {
|
||
item.classList.toggle("active", item.dataset.boardPane === pane);
|
||
});
|
||
if (pane === "geometry") window.setTimeout(() => geometry.draw(), 30);
|
||
});
|
||
});
|
||
$$tool("[data-whiteboard-tool]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
whiteboard.tool = button.dataset.whiteboardTool;
|
||
$$tool("[data-whiteboard-tool]").forEach((item) =>
|
||
item.classList.toggle("active", item === button)
|
||
);
|
||
});
|
||
});
|
||
$tool("#whiteboard-undo").addEventListener("click", () => whiteboard.undo());
|
||
$tool("#whiteboard-clear").addEventListener("click", () => whiteboard.clear());
|
||
$tool("#whiteboard-grid").addEventListener("click", () => whiteboard.addGrid());
|
||
$tool("#whiteboard-image").addEventListener("change", (event) => {
|
||
whiteboard.addImage(event.target.files[0]);
|
||
event.target.value = "";
|
||
});
|
||
$tool("#whiteboard-export").addEventListener("click", () =>
|
||
downloadCanvas(whiteboard.canvas, "hulumath-whiteboard")
|
||
);
|
||
$$tool("[data-geometry-tool]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
geometry.tool = button.dataset.geometryTool;
|
||
geometry.pending = [];
|
||
$$tool("[data-geometry-tool]").forEach((item) =>
|
||
item.classList.toggle("active", item === button)
|
||
);
|
||
});
|
||
});
|
||
$tool("#geometry-labels").addEventListener("change", () => geometry.draw());
|
||
$tool("#geometry-undo").addEventListener("click", () => geometry.undo());
|
||
$tool("#geometry-clear").addEventListener("click", () => geometry.clear());
|
||
$tool("#geometry-export").addEventListener("click", () =>
|
||
downloadCanvas(geometry.canvas, "hulumath-geometry")
|
||
);
|
||
$tool("#board-create").addEventListener("click", () => boardRealtime.create());
|
||
$tool("#board-join-form").addEventListener(
|
||
"submit",
|
||
(event) => boardRealtime.join(event),
|
||
);
|
||
$tool("#board-guess-form").addEventListener(
|
||
"submit",
|
||
(event) => boardRealtime.guess(event),
|
||
);
|
||
$tool("#board-code-input").addEventListener("input", (event) => {
|
||
event.target.value = event.target.value
|
||
.toUpperCase()
|
||
.replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "")
|
||
.slice(0, 6);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
$tool("#calc-operation").addEventListener("change", () => {
|
||
setCalculatorCategory(
|
||
calculatorCategoryFor($tool("#calc-operation").value),
|
||
false,
|
||
);
|
||
});
|
||
$$tool("[data-calc-category]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
setCalculatorCategory(button.dataset.calcCategory);
|
||
});
|
||
});
|
||
$tool("#calc-run").addEventListener("click", runCalculator);
|
||
$tool("#calc-input").addEventListener("keydown", (event) => {
|
||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") runCalculator();
|
||
});
|
||
$$tool("[data-calc-example]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
$tool("#calc-operation").value = button.dataset.calcOperation;
|
||
$tool("#calc-input").value = button.dataset.calcExample;
|
||
setCalculatorCategory(
|
||
calculatorCategoryFor(button.dataset.calcOperation),
|
||
false,
|
||
);
|
||
runCalculator();
|
||
});
|
||
});
|
||
initDrawingTools();
|
||
setCalculatorCategory("algebra", false);
|
||
window.HuluGraph?.init();
|
||
}
|
||
|
||
function activate(tool) {
|
||
if (tool === "graph") window.setTimeout(() => window.HuluGraph?.resize(), 30);
|
||
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
|
||
}
|
||
|
||
window.HuluToolbox = { init, activate, runCalculator };
|
||
})();
|