feat: add collaborative math board and draw guess mode
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user