feat: add collaborative math board and draw guess mode

This commit is contained in:
2026-08-10 00:47:03 +08:00
parent 40e9462842
commit b8916180a9
16 changed files with 890 additions and 36 deletions
+253 -1
View File
@@ -273,6 +273,7 @@
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));
@@ -290,6 +291,23 @@
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();
@@ -305,6 +323,7 @@
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;
@@ -352,6 +371,7 @@
this.move(event);
this.drawing = false;
this.preview = null;
this.emitSnapshot();
}
cancel() {
@@ -366,6 +386,7 @@
image.onload = () => {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.drawImage(image, 0, 0);
this.emitSnapshot();
};
image.src = source;
}
@@ -373,6 +394,7 @@
clear() {
this.snapshot();
this.reset();
this.emitSnapshot();
}
addGrid() {
@@ -400,6 +422,7 @@
context.moveTo(0, this.canvas.height / 2);
context.lineTo(this.canvas.width, this.canvas.height / 2);
context.stroke();
this.emitSnapshot();
}
addImage(file) {
@@ -421,6 +444,7 @@
image.width * scale,
image.height * scale,
);
this.emitSnapshot();
};
image.src = reader.result;
};
@@ -439,6 +463,7 @@
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));
@@ -448,6 +473,34 @@
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({
@@ -501,6 +554,7 @@
if (this.tool === "move") {
this.pointerMove(event);
this.dragging = null;
this.emitState();
return;
}
this.handlePoint(event);
@@ -535,6 +589,7 @@
}
}
this.draw();
this.emitState();
$tool("#geometry-hint").textContent = this.pending.length
? "再选择一个点完成构造。"
: "可继续创建或切换构造工具。";
@@ -624,6 +679,7 @@
this.circles = parsed.circles;
this.pending = [];
this.draw();
this.emitState();
}
clear() {
@@ -633,15 +689,196 @@
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;
@@ -675,6 +912,21 @@
$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() {
@@ -703,7 +955,7 @@
function activate(tool) {
if (tool === "graph") window.setTimeout(drawGraph, 30);
if (tool === "geometry") window.setTimeout(() => geometry.draw(), 30);
if (tool === "whiteboard") window.setTimeout(() => geometry.draw(), 30);
}
window.HuluToolbox = { init, activate, runCalculator, drawGraph };