feat: add collaborative math board and draw guess mode
This commit is contained in:
@@ -214,12 +214,16 @@ button { color: inherit; }
|
||||
.symbol-card strong { display: block; color: var(--green); font: 29px Georgia, serif; }.symbol-card b { display: block; margin-top: 10px; }.symbol-card small { display: block; margin-top: 5px; color: var(--muted); }.symbol-card code { display: inline-block; margin-top: 12px; padding: 4px 7px; border-radius: 6px; background: #f0f1eb; color: #405048; }
|
||||
.graph-shell { display: grid; grid-template-columns: 245px minmax(0, 1fr); gap: 20px; }
|
||||
.graph-controls { display: flex; flex-direction: column; gap: 17px; }.graph-controls label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; }.graph-controls p { color: #b84136; font-size: 12px; }.graph-controls textarea { min-height: 100px; resize: vertical; font: 13px/1.6 "SFMono-Regular", Consolas, monospace; }
|
||||
.function-syntax-help { border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; background: #f5f7f1; }.function-syntax-help summary { color: var(--green); font-weight: 700; cursor: pointer; }.function-syntax-help p { margin: 9px 0 0; color: var(--muted); line-height: 1.7; }.function-syntax-help code { color: var(--ink); }
|
||||
.check-row { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 8px !important; }.check-row input { margin: 0; }
|
||||
.graph-analysis { padding: 11px; border-radius: 10px; background: #eef1eb; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
#graph-canvas { width: 100%; height: auto; border: 1px solid var(--line); border-radius: 17px; background: #fbfbf7; }
|
||||
.drawing-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; }.drawing-toolbar button, .drawing-toolbar label { border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; background: white; color: var(--muted); cursor: pointer; }.drawing-toolbar button.active { border-color: var(--ink); background: var(--ink); color: white; }.drawing-toolbar .primary-button { margin-left: auto; border: 0; background: var(--green); color: white; }.drawing-toolbar label { display: flex; align-items: center; gap: 7px; font-size: 11px; }.drawing-toolbar input[type=color] { width: 28px; height: 24px; padding: 0; border: 0; background: transparent; }
|
||||
.toolbar-text-input { flex: 1 1 190px; min-width: 150px; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.drawing-toolbar .file-tool input { display: none; }
|
||||
.canvas-stage { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }.canvas-stage canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }.geometry-stage { background: #fbfbf7; }.canvas-hint { color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.board-mode-switch { display: flex; gap: 8px; margin-bottom: 12px; }.board-mode-switch button { border: 1px solid var(--line); border-radius: 99px; padding: 9px 16px; background: white; cursor: pointer; }.board-mode-switch button.active { border-color: var(--ink); background: var(--ink); color: white; }
|
||||
.board-online-panel { display: grid; grid-template-columns: auto minmax(250px, 1fr); gap: 10px 16px; align-items: center; margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(120deg, rgba(25,101,72,.06), rgba(204,232,91,.1)); }.board-online-panel > div, .board-online-panel form { display: flex; gap: 8px; }.board-online-panel select, .board-online-panel input { min-width: 0; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; }.board-online-panel p { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 11px; }.board-online-panel > strong { grid-column: 1 / -1; color: var(--green); }.board-online-panel [hidden] { display: none !important; }
|
||||
.board-pane { display: none; }.board-pane.active { display: block; }
|
||||
.discover-title { padding-bottom: 24px; }
|
||||
.ability-map { border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; background: rgba(255,255,252,.64); overflow: hidden; }
|
||||
.map-heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; }.map-heading h2 { margin: 0 0 6px; font: 30px Georgia, serif; }.map-heading p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
@@ -290,6 +294,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); }
|
||||
.content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; }
|
||||
.preview-pane { min-height: 320px; }.match-history-item { align-items: flex-start; flex-direction: column; }.match-history-item > div:last-child { text-align: left; }
|
||||
.match-mode-switch { grid-template-columns: 1fr; }
|
||||
.board-online-panel { grid-template-columns: 1fr; }.board-online-panel > div, .board-online-panel form { display: grid; grid-template-columns: 1fr; }.board-online-panel p, .board-online-panel > strong { grid-column: 1; }
|
||||
.tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; }
|
||||
.math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; }
|
||||
.challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
@@ -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