diff --git a/README.md b/README.md index 1ea723b..be9302a 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ - **文件浏览页** `GET /files`(Basic Auth,同 docs):列出/下载/**硬删除**已上传文件;删除后不再显示。 管理 API:`GET /api/admin/files`、`GET /api/admin/files/{id}`、`GET /api/admin/files/{id}/download`、 `DELETE /api/admin/files/{id}`(均 Basic Auth)。 -- **共享白板** `GET /wb/{id}`(公开,不存在则新建):Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。 +- **共享记事本(白板)** `GET /wb/{id}`(公开,不存在则新建):纯文本实时协作 + **清空 / 复制文本**,兼容移动端。 实时同步走 `WS /ws/wb/{id}`(**心跳 3s,连续 5 次丢失判失活并移除**)。 -- **白板管理页** `GET /wb-admin`(Basic Auth,同 docs):查看创建时间/修改次数/上次修改时间/删除。 +- **白板管理页** `GET /wb-admin`(Basic Auth,同 docs):查看创建时间/编辑次数/上次修改时间/删除。 管理 API:`GET /api/admin/wb`、`DELETE /api/admin/wb/{id}`(均 Basic Auth)。 - **反向隧道反代**:`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。 - **内置 SFTP/SSH 服务器**(asyncssh),支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。 @@ -168,20 +168,24 @@ SSH 服务器(2022)同时承载 SFTP 文件暂存与反向隧道。隧道 us - **删除后不再显示**:列表每次进入或删除后重新 fetch,前端不缓存;DB 行已删,列表自然不含。 - 公开 `/api/files` 系列(user.py 依赖的查重/查询/下载)保留不变。 -## 共享白板 +## 共享记事本(白板) 白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配 `[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存 -(存 MySQL `whiteboard` 表),进程重启后内容仍在。 +(存 MySQL `whiteboard` 表,`content` TEXT 列),进程重启后内容仍在。 ### 实时同步与心跳 - 连接:`WS /ws/wb/{id}`(公开)。JSON 文本帧协议: - client -> server:`{"type":"hello","client_id":"..."}`(首帧,可选)、 - `{"type":"ping"}`(心跳)、`{"type":"stroke","stroke":{points,color,width}}`、`{"type":"clear"}` - - server -> client:`{"type":"init","strokes":[...],"stroke_count":n}`、`{"type":"pong"}`、 - `{"type":"stroke","stroke":{...},"client_id":"..."}`(广播给他人,不含发送者)、 + `{"type":"ping"}`(心跳)、`{"type":"edit","content":"..."}`(debounce 后发完整文本)、 + `{"type":"clear"}` + - server -> client:`{"type":"init","content":"...","version":n,"edit_count":m}`、 + `{"type":"pong"}`、`{"type":"update","content":"...","version":n,"client_id":"..."}`(广播给他人,不含发送者)、 `{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}` +- **同步策略**:客户端本地编辑后 debounce 400ms 发完整文本,服务端存为新版本(version+1) + 并广播给同 board 的其他在线连接。其他端用最长公共前后缀算出变更区间,仅替换该区间并 + 保留本地光标位置(在变更区间前不动,在后平移,在区间内移到末尾)。 - **心跳**:客户端每 `whiteboard.heartbeat_interval_seconds`(默认 3s)发一次 `ping`,服务端回 `pong` 并刷新计时。后台 reaper 每秒扫描,连续 `heartbeat_miss_threshold`(默认 5)次未收到心跳 (即 15s)判失活,**关闭该连接并从 hub 移除**。 @@ -197,7 +201,7 @@ SSH 服务器(2022)同时承载 SFTP 文件暂存与反向隧道。隧道 us - `GET /wb-admin`(Basic Auth,同 docs)渲染 `static/whiteboard_admin.html`。 - 管理 API(均 Basic Auth): - - `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}` + - `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, edit_count, created_at, updated_at}]}` - `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。 ## 临时文件清理 diff --git a/app/controllers/whiteboard_controller.py b/app/controllers/whiteboard_controller.py index 25e8bc2..45347cd 100644 --- a/app/controllers/whiteboard_controller.py +++ b/app/controllers/whiteboard_controller.py @@ -86,18 +86,18 @@ def delete_whiteboard( @router.websocket("/ws/wb/{board_id}") async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: - """白板实时协作端点。 + """白板实时协作端点(文本记事本)。 协议(JSON 文本帧): client -> server: {"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成) {"type":"ping"} 心跳,服务端回 pong 并刷新计时 - {"type":"stroke","stroke":{...}} 新增笔画,持久化并广播给他人 + {"type":"edit","content":"..."} debounce 后发完整文本,持久化并广播给他人 {"type":"clear"} 清空,持久化并广播给所有人 server -> client: - {"type":"init","strokes":[...],"stroke_count":n} + {"type":"init","content":"...","version":n,"edit_count":m} {"type":"pong"} - {"type":"stroke","stroke":{...},"client_id":"..."} + {"type":"update","content":"...","version":n,"client_id":"..."} 文本变更 {"type":"cleared","client_id":"..."} {"type":"error","msg":"..."} """ @@ -115,7 +115,6 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: client_id = _extract_client_id(first) or uuid.uuid4().hex[:12] # 校验 board_id 并加载白板(不存在则新建) - from ..database import get_session_local try: board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id)) except HTTPException as exc: @@ -128,8 +127,9 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: await hub.register(conn) await _safe_send(websocket, { "type": "init", - "strokes": board.strokes, - "stroke_count": board.stroke_count, + "content": board.content, + "version": board.version, + "edit_count": board.edit_count, }) # 主循环:收消息 -> 处理 -> 广播 @@ -146,17 +146,22 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: continue # 任何有效业务帧都视为活性证据 conn.touch() - if mtype == "stroke": - stroke = msg.get("stroke") or {} + if mtype == "edit": + content = msg.get("content") + if not isinstance(content, str): + await _safe_send(websocket, {"type": "error", "msg": "content 必须是字符串"}) + continue try: - _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).append_stroke(board_id, stroke)) + out = _with_db( + lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).update_content(board_id, content) + ) except HTTPException as exc: await _safe_send(websocket, {"type": "error", "msg": exc.detail}) continue - # 广播给他人(发送者本地已画,不回推) + # 广播给他人(发送者本地已更新,不回推) await hub.broadcast( board_id, - {"type": "stroke", "stroke": stroke, "client_id": client_id}, + {"type": "update", "content": out.content, "version": out.version, "client_id": client_id}, exclude=conn, ) elif mtype == "clear": diff --git a/app/dao/whiteboard_dao.py b/app/dao/whiteboard_dao.py index fb1e542..ad92cfd 100644 --- a/app/dao/whiteboard_dao.py +++ b/app/dao/whiteboard_dao.py @@ -1,13 +1,11 @@ -"""Whiteboard 的 DAO。 +"""Whiteboard 的 DAO(文本记事本)。 所有写操作均在该层 commit,service 不直接操作 session。 -get_or_create 用于「访问即新建」语义(路由 GET /whiteboard/{id} 不存在则建)。 +get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存在则建)。 """ from __future__ import annotations -from typing import Any - from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -33,7 +31,7 @@ class WhiteboardDAO: board = self.get(board_id) if board is not None: return board - board = Whiteboard(board_id=board_id, strokes=[], stroke_count=0) + board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0) try: return self.create(board) except Exception: @@ -41,24 +39,14 @@ class WhiteboardDAO: self.db.rollback() return self.get(board_id) # type: ignore[return-value] - def append_strokes(self, board_id: str, new_strokes: list[Any]) -> Whiteboard | None: - """把新笔画追加到 strokes 数组尾部,stroke_count 自增。""" + def update_content(self, board_id: str, content: str) -> Whiteboard | None: + """整体替换文本内容,version +1、edit_count +1。""" board = self.get(board_id) if board is None: return None - board.strokes = [*board.strokes, *new_strokes] - board.stroke_count = (board.stroke_count or 0) + len(new_strokes) - self.db.commit() - self.db.refresh(board) - return board - - def replace_strokes(self, board_id: str, strokes: list[Any]) -> Whiteboard | None: - """整体替换 strokes(清空时传 []),stroke_count 自增 1。""" - board = self.get(board_id) - if board is None: - return None - board.strokes = list(strokes) - board.stroke_count = (board.stroke_count or 0) + 1 + board.content = content + board.version = (board.version or 0) + 1 + board.edit_count = (board.edit_count or 0) + 1 self.db.commit() self.db.refresh(board) return board diff --git a/app/models/whiteboard.py b/app/models/whiteboard.py index 9ac6f38..68260f7 100644 --- a/app/models/whiteboard.py +++ b/app/models/whiteboard.py @@ -1,15 +1,16 @@ -"""共享白板实体。 +"""共享白板实体(文本记事本)。 -一个白板由 board_id 唯一标识(用户可读的 url id),strokes 以 JSON 列保存全部笔画。 -白板长期留存,进程重启后仍可恢复;实时协作由 WebSocket hub 在内存中维护在线连接, -笔画变更经 service 落库后由 hub 广播给同 board 的其它在线连接。 +一个白板由 board_id 唯一标识(用户可读的 url id),content 存完整文本, +version 是乐观锁版本号(每次编辑 +1)。白板长期留存,进程重启后仍可恢复; +实时协作由 WebSocket hub 在内存中维护在线连接,文本变更经 service 落库后 +由 hub 广播给同 board 的其它在线连接。 """ from __future__ import annotations from datetime import datetime -from sqlalchemy import BigInteger, DateTime, Integer, JSON, String, func +from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column from ..database import Base @@ -21,10 +22,12 @@ class Whiteboard(Base): id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) # 用户可读的 url id([a-zA-Z0-9_-]{1,64}),全局唯一 board_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) - # 笔画数组 [{points:[[x,y],...], color, width}, ...] - strokes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) - # 修改次数:每次新增笔画或清空 +1,供管理页统计 - stroke_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 白板文本内容(记事本) + content: Mapped[str] = mapped_column(Text, nullable=False, default="") + # 乐观锁版本号:每次编辑 +1 + version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 编辑次数(累计修改次数,含清空),供管理页统计 + edit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), nullable=False ) @@ -35,5 +38,5 @@ class Whiteboard(Base): def __repr__(self) -> str: # pragma: no cover return ( f"" + f"len={len(self.content)} v={self.version} mods={self.edit_count}>" ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 980df3a..1602f78 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -10,8 +10,6 @@ from .file import FileListResponse, FileUploadResponse, UploadedFileOut from .system import DiskUsage, MemoryUsage, SystemStatus from .tunnel import TunnelStatusResponse from .whiteboard import ( - Stroke, - StrokeOp, WhiteboardListItem, WhiteboardListResponse, WhiteboardOut, @@ -26,8 +24,6 @@ __all__ = [ "FileUploadResponse", "MemoryUsage", "SessionStatusResponse", - "Stroke", - "StrokeOp", "SystemStatus", "TunnelStatusResponse", "UploadedFileOut", diff --git a/app/schemas/whiteboard.py b/app/schemas/whiteboard.py index 1c41dd9..9676518 100644 --- a/app/schemas/whiteboard.py +++ b/app/schemas/whiteboard.py @@ -1,27 +1,19 @@ -"""白板接口 DTO。""" +"""白板接口 DTO(文本记事本)。""" from __future__ import annotations from datetime import datetime -from typing import Any from pydantic import BaseModel, Field -class Stroke(BaseModel): - """一条笔画:点序列 + 样式。结构宽松(Any)以兼容前端扩展字段。""" - - points: list[list[float]] = Field(default_factory=list, description="[[x,y],...]") - color: str = Field(default="#1565c0", description="笔画颜色") - width: float = Field(default=3, description="笔画宽度") - - class WhiteboardOut(BaseModel): - """白板完整内容(GET /whiteboard/{id} 与 WS init 帧)。""" + """白板完整内容(GET /api/wb/{id} 与 WS init 帧)。""" board_id: str - strokes: list[Any] = Field(default_factory=list, description="笔画数组") - stroke_count: int = Field(0, description="累计修改次数") + content: str = Field("", description="白板文本内容") + version: int = Field(0, description="乐观锁版本号,每次编辑 +1") + edit_count: int = Field(0, description="累计编辑次数") created_at: datetime updated_at: datetime @@ -32,7 +24,7 @@ class WhiteboardListItem(BaseModel): """管理页列表项。""" board_id: str - stroke_count: int + edit_count: int created_at: datetime updated_at: datetime @@ -44,10 +36,3 @@ class WhiteboardListResponse(BaseModel): total: int items: list[WhiteboardListItem] - - -class StrokeOp(BaseModel): - """WS 笔画操作(type=stroke 时携带)。""" - - type: str = Field(..., description="add / clear") - stroke: dict[str, Any] | None = Field(None, description="type=add 时携带的笔画对象") diff --git a/app/services/whiteboard_service.py b/app/services/whiteboard_service.py index ccf02e8..65bfde1 100644 --- a/app/services/whiteboard_service.py +++ b/app/services/whiteboard_service.py @@ -1,4 +1,4 @@ -"""白板服务:CRUD + 笔画操作。 +"""白板服务(文本记事本):CRUD + 文本更新 + 清空。 不持有 WebSocket 连接状态(那是 hub 的职责);删除白板时通过可选的 hub 回调 通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub @@ -8,7 +8,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from fastapi import HTTPException @@ -30,6 +30,8 @@ class WhiteboardService: cfg = get_settings().whiteboard self.max_board_id_length = cfg.max_board_id_length self.list_limit = cfg.list_limit + # 文本内容长度上限(防滥用) + self.max_content_length = 256 * 1024 # ---------------- 校验 ---------------- @@ -42,6 +44,15 @@ class WhiteboardService: ): raise HTTPException(400, "board_id 非法(仅允许字母数字下划线短横线,1-64 字符)") + def validate_content(self, content: str) -> None: + if not isinstance(content, str): + raise HTTPException(400, "content 必须是字符串") + if len(content) > self.max_content_length: + raise HTTPException( + 413, + f"文本过长({len(content)} > {self.max_content_length}),请缩减内容", + ) + # ---------------- 读 ---------------- def get_or_create(self, board_id: str) -> WhiteboardOut: @@ -59,21 +70,18 @@ class WhiteboardService: # ---------------- 写 ---------------- - def append_stroke(self, board_id: str, stroke: dict[str, Any]) -> WhiteboardOut: - """追加一条笔画并返回最新状态。""" + def update_content(self, board_id: str, content: str) -> WhiteboardOut: + """整体替换文本内容(客户端 debounce 后发完整文本)。""" self.validate_board_id(board_id) - board = self.dao.append_strokes(board_id, [stroke]) + self.validate_content(content) + board = self.dao.update_content(board_id, content) if board is None: raise HTTPException(404, "白板不存在") return WhiteboardOut.model_validate(board) def clear(self, board_id: str) -> WhiteboardOut: - """清空白板;stroke_count 仍自增以记录这次修改。""" - self.validate_board_id(board_id) - board = self.dao.replace_strokes(board_id, []) - if board is None: - raise HTTPException(404, "白板不存在") - return WhiteboardOut.model_validate(board) + """清空白板(内容置空),edit_count 仍自增以记录这次修改。""" + return self.update_content(board_id, "") def delete(self, board_id: str) -> bool: """删除白板;同时通知 hub 踢出该 board 的所有在线连接。""" diff --git a/sql/schema.sql b/sql/schema.sql index 4139d09..2b1888d 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -53,13 +53,15 @@ CREATE TABLE IF NOT EXISTS `tunnel_session` ( KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- 共享白板表。由 app/services/whiteboard_service.py 使用。 --- 白板长期留存,strokes 以 JSON 保存全部笔画;实时同步由 WebSocket hub 在内存维护。 +-- 共享白板表(文本记事本)。由 app/services/whiteboard_service.py 使用。 +-- content 存完整文本,version 是乐观锁版本号(每次编辑 +1),edit_count 累计编辑次数。 +-- 白板长期留存,实时同步由 WebSocket hub 在内存维护在线连接。 CREATE TABLE IF NOT EXISTS `whiteboard` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id,[a-zA-Z0-9_-]{1,64}', - `strokes` JSON NOT NULL COMMENT '笔画数组 [{points,color,width}, ...]', - `stroke_count` INT NOT NULL DEFAULT 0 COMMENT '累计修改次数(新增笔画/清空各 +1)', + `content` MEDIUMTEXT NOT NULL COMMENT '白板文本内容', + `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次编辑 +1', + `edit_count` INT NOT NULL DEFAULT 0 COMMENT '累计编辑次数(含清空)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), diff --git a/static/whiteboard.css b/static/whiteboard.css index d81493a..20830c3 100644 --- a/static/whiteboard.css +++ b/static/whiteboard.css @@ -1,4 +1,4 @@ -/* 白板页专属样式:全屏画布、悬浮工具栏、移动端适配。 */ +/* 记事本页专属样式:全屏 textarea、悬浮工具栏、移动端适配。 */ :root { --bar-h: 52px; } body { overflow: hidden; background: var(--bg); } .wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; } @@ -14,22 +14,20 @@ body { overflow: hidden; background: var(--bg); } .wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); } .wb-online { color: var(--success); font-size: 0.7em; } .wb-online.off { color: var(--text-dim); } -.wb-tool { display: inline-flex; align-items: center; gap: 0.3em; font-size: 0.85em; color: var(--text-dim); } -.wb-tool input[type="color"] { width: 28px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: transparent; cursor: pointer; } -.wb-tool input[type="range"] { width: 80px; accent-color: var(--primary); } -.wb-width-val { width: 1.4em; text-align: center; } .wb-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; } .wb-stage { position: relative; flex: 1; overflow: hidden; } -#canvas { - position: absolute; inset: 0; width: 100%; height: 100%; - display: block; touch-action: none; cursor: crosshair; - background: - linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 24px 24px, - linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 24px 24px, - var(--surface); - background-blend-mode: normal; +.wb-editor { + position: absolute; inset: 0; + width: 100%; height: 100%; + display: block; resize: none; border: none; outline: none; + padding: 1em 1.2em; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, "JetBrains Mono", monospace; + font-size: 14px; line-height: 1.6; + background: var(--surface); color: var(--text); + touch-action: manipulation; } +.wb-editor::placeholder { color: var(--text-dim); } .wb-status { position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%); background: var(--surface); border: 1px solid var(--border); @@ -44,7 +42,7 @@ body { overflow: hidden; background: var(--bg); } @media (max-width: 640px) { .wb-bar { padding: 0.4em 0.5em; gap: 0.4em; } .wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .wb-tool input[type="range"] { width: 56px; } .wb-bar .btn { padding: 0.45em 0.7em; } .wb-title { display: none; } + .wb-editor { font-size: 15px; padding: 0.8em; } } diff --git a/static/whiteboard.html b/static/whiteboard.html index 0d96662..4cbacda 100644 --- a/static/whiteboard.html +++ b/static/whiteboard.html @@ -4,7 +4,7 @@ -白板 - zikai +记事本 - zikai @@ -12,24 +12,17 @@
- 白板 - + 记事本 +
- - - - + +
- +
连接中…
diff --git a/static/whiteboard.js b/static/whiteboard.js index 183f34e..eff2d7f 100644 --- a/static/whiteboard.js +++ b/static/whiteboard.js @@ -1,35 +1,26 @@ -/* 白板:Canvas 绘画 + WebSocket 实时同步 + 心跳。 - - 笔画以 {points:[[x,y],...], color, width} 表示,增量广播。 - - 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。 - - 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布。 - - 兼容鼠标 + 触摸:统一用 pointer events,touch-action:none 防滚动缩放。 */ +/* 记事本:textarea + WebSocket 实时同步 + 心跳。 + - 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端。 + - 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)。 + - 收到 cleared 清空本地 textarea。 + - 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。 */ (function () { "use strict"; - const { el, toast, copyText } = window.ZK; + const { toast, copyText } = window.ZK; // ---------- 从 URL 解析 board_id ---------- - // 路径形如 /wb/{id};id 为 [a-zA-Z0-9_-]{1,64} const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/); let boardId = m ? decodeURIComponent(m[1]) : "default"; - // 合法性兜底:前端非法字符直接回退到 default,真正校验在服务端 if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default"; document.getElementById("boardId").textContent = boardId; // ---------- DOM ---------- - const canvas = document.getElementById("canvas"); - const ctx = canvas.getContext("2d"); - const colorInput = document.getElementById("color"); - const widthInput = document.getElementById("width"); - const widthVal = document.getElementById("widthVal"); + const editor = document.getElementById("editor"); const clearBtn = document.getElementById("clearBtn"); const copyBtn = document.getElementById("copyBtn"); const statusEl = document.getElementById("status"); const onlineEl = document.getElementById("online"); // ---------- 状态 ---------- - let strokes = []; // 已确认的笔画 - let current = null; // 正在画的笔画(本地未提交) - let drawing = false; let ws = null; let clientId = localStorage.getItem("wb_cid") || ""; if (!clientId) { @@ -39,111 +30,99 @@ let heartbeatTimer = null; let reconnectTimer = null; let connected = false; + let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发) + let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环 + let debounceTimer = null; - // ---------- 画布尺寸 ---------- - function resize() { - const dpr = window.devicePixelRatio || 1; - const w = canvas.clientWidth; - const h = canvas.clientHeight; - canvas.width = Math.max(1, Math.floor(w * dpr)); - canvas.height = Math.max(1, Math.floor(h * dpr)); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - redraw(); - } - window.addEventListener("resize", resize); - - // ---------- 绘制 ---------- - function drawStroke(s) { - if (!s || !s.points || s.points.length < 1) return; - ctx.strokeStyle = s.color || "#1565c0"; - ctx.lineWidth = Number(s.width) || 3; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - const pts = s.points; - ctx.beginPath(); - ctx.moveTo(pts[0][0], pts[0][1]); - if (pts.length === 1) { - // 单点:画一个小圆点 - ctx.arc(pts[0][0], pts[0][1], (ctx.lineWidth || 3) / 2, 0, Math.PI * 2); - ctx.fillStyle = ctx.strokeStyle; - ctx.fill(); - return; - } - for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); - ctx.stroke(); - } - - function redraw() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - for (const s of strokes) drawStroke(s); - if (current) drawStroke(current); - } - - // ---------- 指针事件 ---------- - function pos(e) { - const r = canvas.getBoundingClientRect(); - return [e.clientX - r.left, e.clientY - r.top]; - } - - canvas.addEventListener("pointerdown", (e) => { - if (!connected) { flashStatus("未连接,正在重连…"); return; } - e.preventDefault(); - canvas.setPointerCapture(e.pointerId); - drawing = true; - current = { points: [pos(e)], color: colorInput.value, width: Number(widthInput.value) }; - drawStroke(current); - }); - canvas.addEventListener("pointermove", (e) => { - if (!drawing) return; - e.preventDefault(); - const p = pos(e); - const last = current.points[current.points.length - 1]; - // 跳过过近的点,减少数据量 - if (Math.hypot(p[0] - last[0], p[1] - last[1]) < 1.5) return; - current.points.push(p); - // 增量画最后一段 - ctx.strokeStyle = current.color; - ctx.lineWidth = current.width; - ctx.lineCap = "round"; ctx.lineJoin = "round"; - ctx.beginPath(); - ctx.moveTo(last[0], last[1]); - ctx.lineTo(p[0], p[1]); - ctx.stroke(); - }); - function endStroke(e) { - if (!drawing) return; - drawing = false; - if (e && e.pointerId !== undefined) { - try { canvas.releasePointerCapture(e.pointerId); } catch {} - } - if (current && current.points.length) { - strokes.push(current); - send({ type: "stroke", stroke: current }); - } - current = null; - } - canvas.addEventListener("pointerup", endStroke); - canvas.addEventListener("pointercancel", endStroke); - canvas.addEventListener("pointerleave", (e) => { - // 仅在抬起时结束;离开但按住不放不结束(pointer capture 已处理) - if (!drawing) return; - if (e.buttons === 0) endStroke(e); + // ---------- 本地编辑 -> debounce -> 发送 ---------- + editor.addEventListener("input", () => { + if (suppressInput) return; + scheduleSend(); }); - widthInput.addEventListener("input", () => (widthVal.textContent = widthInput.value)); + function scheduleSend() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + const text = editor.value; + if (text === lastSentText) return; + lastSentText = text; + send({ type: "edit", content: text }); + }, 400); + } + + function flushSend() { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + const text = editor.value; + if (text !== lastSentText) { + lastSentText = text; + send({ type: "edit", content: text }); + } + } + } clearBtn.addEventListener("click", () => { if (!connected) { flashStatus("未连接"); return; } - if (!confirm("确定清空白板?所有人的内容都会被清除。")) return; + if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return; send({ type: "clear" }); }); copyBtn.addEventListener("click", async () => { - const url = `${location.origin}/wb/${boardId}`; - const ok = await copyText(url); - toast(ok ? "链接已复制" : "复制失败"); + const text = editor.value; + if (!text) { toast("内容为空"); return; } + const ok = await copyText(text); + toast(ok ? "已复制全部文本" : "复制失败"); }); + // ---------- 应用远端更新(保留光标) ---------- + // 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。 + function applyRemoteUpdate(newText) { + const oldText = editor.value; + if (newText === oldText) return; + + const selStart = editor.selectionStart; + const selEnd = editor.selectionEnd; + + // 算公共前缀 + let prefix = 0; + const minLen = Math.min(oldText.length, newText.length); + while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++; + + // 算公共后缀(不能与前缀重叠) + let suffixOld = oldText.length; + let suffixNew = newText.length; + while (suffixOld > prefix && suffixNew > prefix && oldText[suffixOld - 1] === newText[suffixNew - 1]) { + suffixOld--; + suffixNew--; + } + + suppressInput = true; + // 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew) + editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end"); + suppressInput = false; + lastSentText = editor.value; + + // 调整光标:若光标在变更区间之前,不动;在之后,平移差值;在区间内,移到区间末尾 + const delta = (suffixNew - prefix) - (suffixOld - prefix); + let newStart = selStart, newEnd = selEnd; + if (selStart <= prefix) { + // 光标在变更前,不变 + } else if (selStart >= suffixOld) { + // 光标在变更后,平移 + newStart = selStart + delta; + newEnd = selEnd + delta; + } else { + // 光标在变更区间内,移到区间末尾 + newStart = newEnd = suffixNew; + } + try { + editor.setSelectionRange(newStart, newEnd); + } catch {} + editor.focus(); + } + // ---------- WebSocket ---------- function wsUrl() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; @@ -160,7 +139,7 @@ } ws.onopen = () => { connected = true; - setStatus("已连接", true); + setStatus("已连接", false); onlineEl.classList.remove("off"); send({ type: "hello", client_id: clientId }); startHeartbeat(); @@ -175,23 +154,26 @@ try { msg = JSON.parse(raw); } catch { return; } switch (msg.type) { case "init": - strokes = Array.isArray(msg.strokes) ? msg.strokes : []; - redraw(); - setStatus(`已同步 ${strokes.length} 笔`, true); + suppressInput = true; + editor.value = msg.content || ""; + lastSentText = editor.value; + suppressInput = false; + editor.focus(); + setStatus("已同步", false); break; case "pong": - // 心跳回声,保持连接 break; - case "stroke": - if (msg.client_id === clientId) break; // 自己的,已本地画 - strokes.push(msg.stroke); - drawStroke(msg.stroke); + case "update": + if (msg.client_id === clientId) break; // 自己的,已本地更新 + applyRemoteUpdate(msg.content || ""); + flashStatus("对方有更新"); break; case "cleared": - strokes = []; - current = null; - redraw(); - flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板"); + suppressInput = true; + editor.value = ""; + lastSentText = ""; + suppressInput = false; + flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了内容"); break; case "error": flashStatus(msg.msg || "错误", true); @@ -244,26 +226,31 @@ // ---------- 启动 ---------- // 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } }) - .then((r) => r.ok ? r.json() : null) + .then((r) => (r.ok ? r.json() : null)) .then((body) => { - if (body && Array.isArray(body.strokes)) { - strokes = body.strokes; - redraw(); + if (body && typeof body.content === "string") { + editor.value = body.content; + lastSentText = body.content; } - resize(); connect(); }) - .catch(() => { resize(); connect(); }); + .catch(() => { connect(); }); - // 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连 + // 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑 document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) { - if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - connect(); + if (document.visibilityState === "visible") { + if (!ws || ws.readyState !== WebSocket.OPEN) { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + connect(); + } + } else { + flushSend(); } }); + // 离开前 flush 未发送编辑 window.addEventListener("beforeunload", () => { + flushSend(); stopHeartbeat(); if (reconnectTimer) clearTimeout(reconnectTimer); try { ws && ws.close(); } catch {} diff --git a/static/whiteboard_admin.js b/static/whiteboard_admin.js index d5cca7b..4cc05fb 100644 --- a/static/whiteboard_admin.js +++ b/static/whiteboard_admin.js @@ -49,7 +49,7 @@ el("td", { class: "col-id" }, el("a", { class: "bid link", href: `/wb/${b.board_id}`, target: "_blank" }, b.board_id) ), - el("td", { class: "col-mods mono" }, String(b.stroke_count ?? 0)), + el("td", { class: "col-mods mono" }, String(b.edit_count ?? 0)), el("td", { class: "col-created muted" }, fmtTime(b.created_at)), el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)), el("td", { class: "col-act" }, diff --git a/tests/manual_whiteboard_ws.py b/tests/manual_whiteboard_ws.py index ef26532..0901d22 100644 --- a/tests/manual_whiteboard_ws.py +++ b/tests/manual_whiteboard_ws.py @@ -1,18 +1,20 @@ -"""白板 WebSocket 端到端烟测:两客户端实时同步 + 心跳 + 清空。 +"""白板 WebSocket 端到端烟测(文本记事本):两客户端实时同步 + 心跳 + 清空。 验证: -1. 客户端 A 连入 -> 收到 init +1. 客户端 A 连入 -> 收到 init(含 content/version) 2. 客户端 B 连入 -> 收到 init -3. A 画一笔 -> B 收到 stroke 广播(A 不收自己的) +3. A 编辑文本 -> B 收到 update(A 不收自己的) 4. 心跳 ping -> pong 5. A 清空 -> A、B 都收到 cleared -6. 停发心跳的连接会被服务端 reaper 移除(15s,这里只验证 ping/pong 即可,reaper 已在 hub 单测覆盖) +6. 持久化:重连后 init 应返回清空后的内容 +7. edit_count 累计 """ from __future__ import annotations import asyncio import json +import urllib.request import websockets @@ -31,7 +33,6 @@ async def recv_msg(ws, timeout=2.0) -> dict | None: async def main() -> None: async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \ websockets.connect(f"{BASE_WS}/{BOARD}") as b: - # hello await a.send(json.dumps({"type": "hello", "client_id": "A"})) await b.send(json.dumps({"type": "hello", "client_id": "B"})) @@ -42,18 +43,18 @@ async def main() -> None: assert init_a and init_a["type"] == "init" assert init_b and init_b["type"] == "init" - # A 画一笔 - stroke = {"points": [[10, 10], [20, 20]], "color": "#1565c0", "width": 3} - await a.send(json.dumps({"type": "stroke", "stroke": stroke})) - # A 不应收到自己的(排除发送者) + # A 编辑文本 + text = "Hello, this is a shared note.\nSecond line." + await a.send(json.dumps({"type": "edit", "content": text})) + # A 不应收到自己的 update echo = await recv_msg(a, timeout=1.0) print("A self-echo (expect None):", echo) - assert echo is None, "发送者不应收到自己的 stroke" - # B 应收到 + assert echo is None, "发送者不应收到自己的 update" + # B 应收到 update got = await recv_msg(b) - print("B recv:", got.get("type") if got else None, "client_id=", got.get("client_id") if got else None) - assert got and got["type"] == "stroke" and got["client_id"] == "A" - assert got["stroke"] == stroke + print("B recv:", got.get("type") if got else None, "content=", repr(got.get("content")) if got else None) + assert got and got["type"] == "update" and got["client_id"] == "A" + assert got["content"] == text # 心跳 await a.send(json.dumps({"type": "ping"})) @@ -70,20 +71,19 @@ async def main() -> None: assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B" assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B" - # 验证持久化:重连后 init 应 strokes 为空(已清空) + # 验证持久化:重连后 init 应 content 为空(已清空) async with websockets.connect(f"{BASE_WS}/{BOARD}") as c: await c.send(json.dumps({"type": "hello", "client_id": "C"})) init_c = await recv_msg(c) - print("C init after clear, strokes=", init_c.get("strokes") if init_c else None) + print("C init after clear, content=", repr(init_c.get("content")) if init_c else None) assert init_c and init_c["type"] == "init" - assert init_c["strokes"] == [], "清空后重连应得到空 strokes" + assert init_c["content"] == "", "清空后重连应得到空 content" - # 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2) - import urllib.request + # 验证 edit_count 累计(1 次编辑 + 1 次清空 = 2) with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r: meta = json.load(r) - print("stroke_count after ops:", meta["stroke_count"]) - assert meta["stroke_count"] == 2, "1 笔 + 1 清空 = 2 次修改" + print("edit_count after ops:", meta["edit_count"]) + assert meta["edit_count"] == 2, "1 编辑 + 1 清空 = 2 次" print("\nWS 端到端全部通过 ✅")