From 4ceba6a60977c1e2b73643c293444ee6fd5ee608 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 24 May 2025 18:38:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=8C=E8=AF=AD=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 40 ++++++++-- server/server.js | 17 ++++- vite.config.js | 48 ++++++++++++ web/NodeCrypt.js | 89 +++++++++++++++++++++- web/chat.js | 31 ++++++++ web/main.js | 176 ++++++++++++++++++++++++++++++------------- web/room.js | 40 ++++++++++ web/ui.js | 43 +++++++++++ web/util.avatar.js | 9 ++- web/util.dom.js | 18 +++++ web/util.emoji.js | 15 +++- web/util.image.js | 39 +++++++++- web/util.settings.js | 37 ++++++++- web/util.string.js | 14 ++-- 14 files changed, 542 insertions(+), 74 deletions(-) diff --git a/Dockerfile b/Dockerfile index 68a2491..920fe9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,64 @@ +# Use a Node.js 18 Alpine base image for the backend builder +# 使用 Node.js 18 Alpine 基础镜像作为后端构建器 FROM docker.1ms.run/node:18-alpine AS backend-builder WORKDIR /app -# 只复制package.json先安装依赖,利用Docker缓存层 +# Copy package.json first to leverage Docker's caching +# 首先复制 package.json 以利用 Docker 的缓存 COPY server/package.json ./server/ +# Install production dependencies +# 安装生产依赖 RUN cd server && npm install --production --no-package-lock --no-audit -# 只复制server.js文件 +# Copy only the server.js file +# 仅复制 server.js 文件 COPY server/server.js ./server/ +# Frontend build stage # 前端构建阶段 FROM docker.1ms.run/node:18-alpine AS frontend-builder WORKDIR /app -# 复制前端项目依赖和配置文件 +# Copy frontend dependencies and configuration files +# 复制前端依赖和配置文件 COPY package.json package-lock.json* ./ +# Install dependencies # 安装依赖 RUN npm ci --no-audit || npm install --no-audit +# Copy source code and configuration files # 复制源代码和配置文件 COPY vite.config.js ./ COPY web/ ./web/ +# Build the frontend # 构建前端 RUN npm run build:docker +# Second stage: Minimal image # 第二阶段:极小镜像 FROM docker.1ms.run/alpine:3.16 -# 安装最小化版本的Node.js和Nginx +# Install minimal Node.js and Nginx +# 安装最小化版本的 Node.js 和 Nginx RUN apk add --no-cache nodejs nginx && \ mkdir -p /app/server /app/web /run/nginx && \ - # 清理apk缓存 + # Clean up apk cache + # 清理 apk 缓存 rm -rf /var/cache/apk/* +# Copy server files and static files # 复制服务器文件和静态文件 COPY --from=backend-builder /app/server/node_modules /app/server/node_modules COPY --from=backend-builder /app/server/*.js /app/server/ -# 从前端构建阶段复制构建好的文件,而不是复制dist目录 +# Copy built frontend files from the frontend build stage +# 从前端构建阶段复制构建好的文件,而不是复制 dist 目录 COPY --from=frontend-builder /app/dist/ /app/web/ -# 优化的Nginx配置 +# Optimized Nginx configuration +# 优化的 Nginx 配置 RUN cat > /etc/nginx/nginx.conf <<'EOF' worker_processes 1; worker_rlimit_nofile 512; @@ -53,6 +70,7 @@ http { include mime.types; default_type application/octet-stream; + # Optimization settings # 优化设置 sendfile on; tcp_nopush on; @@ -62,10 +80,12 @@ http { client_max_body_size 1M; client_body_buffer_size 128k; - # 禁用访问日志以减少I/O + # Disable access logs to reduce I/O + # 禁用访问日志以减少 I/O access_log off; error_log /dev/null; + # Disable unnecessary features # 禁用不需要的功能 server_tokens off; @@ -74,6 +94,7 @@ http { server_name localhost; # Serve static files + # 提供静态文件服务 location / { root /app/web; index index.html; @@ -81,6 +102,7 @@ http { } # Proxy WebSocket requests to Node.js backend + # 将 WebSocket 请求代理到 Node.js 后端 location /ws { proxy_pass http://127.0.0.1:8088; proxy_http_version 1.1; @@ -94,9 +116,11 @@ EOF EXPOSE 80 +# Set low memory environment variables and remove unsupported options # 设置低内存环境变量,去除不支持的选项 ENV NODE_OPTIONS="--max-old-space-size=64" \ NODE_ENV="production" +# Run in the foreground and combine commands to reduce the number of processes # 使用前台运行并合并命令减少进程数 CMD ["sh", "-c", "node --expose-gc --unhandled-rejections=strict /app/server/server.js & nginx -g 'daemon off;'"] \ No newline at end of file diff --git a/server/server.js b/server/server.js index b5196d2..90a981e 100644 --- a/server/server.js +++ b/server/server.js @@ -5,6 +5,8 @@ const crypto = require('crypto'); const ws = require('ws'); +// Generate a new RSA key pair +// 生成一个新的 RSA 密钥对 const generateRSAKeyPair = () => { try { console.log('Generating new RSA keypair...'); @@ -62,6 +64,8 @@ console.log('server started', config.wsHost, config.wsPort); var clients = {}; var channels = {}; +// WebSocket server connection event handler +// WebSocket 服务器连接事件处理程序 wss.on('connection', (connection) => { if ( @@ -256,6 +260,8 @@ wss.on('connection', (connection) => { }); +// Process encrypted messages +// 处理加密消息 const processEncryptedMessage = (clientId, message) => { let decrypted = null; @@ -288,6 +294,8 @@ const processEncryptedMessage = (clientId, message) => { } }; +// Handle channel join requests +// 处理加入频道请求 const handleJoinChannel = (clientId, decrypted) => { if ( !isString(decrypted.p) || @@ -314,6 +322,8 @@ const handleJoinChannel = (clientId, decrypted) => { } }; +// Handle client messages +// 处理客户端消息 const handleClientMessage = (clientId, decrypted) => { if ( !isString(decrypted.p) || @@ -345,6 +355,8 @@ const handleClientMessage = (clientId, decrypted) => { } }; +// Handle channel messages +// 处理频道消息 const handleChannelMessage = (clientId, decrypted) => { if ( !isObject(decrypted.p) || @@ -381,6 +393,8 @@ const handleChannelMessage = (clientId, decrypted) => { } }; +// Broadcast member list to channel +// 向频道广播成员列表 const broadcastMemberList = (channel) => { try { const members = channels[channel]; @@ -409,7 +423,8 @@ const broadcastMemberList = (channel) => { - +// Log events with timestamps and levels +// 记录带时间戳和级别的事件 const logEvent = (source, message, level) => { if ( level !== 'debug' || diff --git a/vite.config.js b/vite.config.js index 17a384d..520d875 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,22 +2,50 @@ import { defineConfig } from 'vite'; export default defineConfig({ + // Root directory + // 根目录 root: 'web', + // Base URL + // 基础 URL base: './', + // Plugins + // 插件 plugins: [], + // Build options + // 构建选项 build: { + // Output directory + // 输出目录 outDir: '../dist', + // Empty output directory + // 清空输出目录 emptyOutDir: true, + // Minify + // 压缩 minify: 'terser', + // Terser options + // Terser 选项 terserOptions: { compress: { + // Drop console statements + // 删除 console 语句 drop_console: false, + // Drop debugger statements + // 删除 debugger 语句 drop_debugger: false } }, + // Rollup options + // Rollup 选项 rollupOptions: { + // Input file + // 输入文件 input: 'web/index.html', + // Output options + // 输出选项 output: { + // Manual chunks + // 手动分块 manualChunks: (id) => { if (id.includes('node_modules')) { if (/aes-js|elliptic|js-chacha20|js-sha256/.test(id)) { @@ -38,20 +66,40 @@ export default defineConfig({ }, }, }, + // Sourcemap + // Sourcemap sourcemap: false, + // CSS code split + // CSS 代码分割 cssCodeSplit: true, + // Chunk size warning limit + // 代码块大小警告限制 chunkSizeWarningLimit: 1000, }, + // Resolve options + // 解析选项 resolve: { + // Alias + // 别名 alias: { buffer: 'buffer', }, }, + // Server options + // 服务器选项 server: { + // HMR + // 热模块替换 hmr: true, + // Open browser + // 打开浏览器 open: true, }, + // Optimize dependencies + // 优化依赖 optimizeDeps: { + // Include + // 包含 include: ['buffer', 'aes-js', 'elliptic', 'js-chacha20', 'js-sha256', '@dicebear/core', '@dicebear/micah'], }, }); \ No newline at end of file diff --git a/web/NodeCrypt.js b/web/NodeCrypt.js index 983bde6..900e394 100644 --- a/web/NodeCrypt.js +++ b/web/NodeCrypt.js @@ -1,4 +1,7 @@ -import { +// NodeCrypt core cryptographic client for secure chat +// NodeCrypt 安全聊天的核心加密客户端 + +import { sha256 } from 'js-sha256'; import { @@ -12,7 +15,12 @@ import { Buffer } from 'buffer'; window.Buffer = Buffer; + +// Main NodeCrypt class for secure communication +// 用于安全通信的 NodeCrypt 主类 class NodeCrypt { + // Initialize NodeCrypt instance + // 初始化 NodeCrypt 实例 constructor(config = {}, callbacks = {}) { this.config = { rsaPublic: config.rsaPublic || '', @@ -63,6 +71,9 @@ class NodeCrypt { this.encryptClientMessage = this.encryptClientMessage.bind(this); this.decryptClientMessage = this.decryptClientMessage.bind(this) } + + // Set user credentials (username, channel, password) + // 设置用户凭证(用户名、频道、密码) setCredentials(username, channel, password) { this.logEvent('setCredentials'); try { @@ -77,6 +88,9 @@ class NodeCrypt { } return (true) } + + // Connect to the server + // 连接到服务器 connect() { if (!this.credentials) { return (false) @@ -99,6 +113,9 @@ class NodeCrypt { } return (true) } + + // Clean up and disconnect + // 清理并断开连接 destruct() { this.logEvent('destruct'); this.stopReconnect(); @@ -139,6 +156,9 @@ class NodeCrypt { this.channel = {}; return (true) } + + // WebSocket open event handler + // WebSocket 连接打开事件处理 async onOpen() { this.logEvent('onOpen'); this.startPing(); @@ -153,6 +173,9 @@ class NodeCrypt { this.logEvent('onOpen', error, 'error') } } + + // WebSocket message event handler + // WebSocket 消息事件处理 async onMessage(event) { if (!event || !this.isString(event.data)) { return @@ -330,6 +353,9 @@ class NodeCrypt { } } } + + // WebSocket error event handler + // WebSocket 错误事件处理 async onError(event) { this.logEvent('onError', event, 'error'); this.disconnect(); @@ -344,6 +370,9 @@ class NodeCrypt { } } } + + // WebSocket close event handler + // WebSocket 关闭事件处理 async onClose(event) { this.logEvent('onClose', event); this.disconnect(); @@ -358,6 +387,9 @@ class NodeCrypt { } } } + + // Log events for debugging + // 记录事件日志用于调试 logEvent(source, message, level) { if (this.config.debug) { const date = new Date(), @@ -365,12 +397,21 @@ class NodeCrypt { console.log('[' + dateString + ']', (level ? level.toUpperCase() : 'INFO'), source + (message ? ':' : ''), (message ? message : '')) } } + + // Check if connection is open + // 检查连接是否已打开 isOpen() { return (this.connection && this.connection.readyState && this.connection.readyState === WebSocket.OPEN ? true : false) } + + // Check if connection is closed + // 检查连接是否已关闭 isClosed() { return (!this.connection || !this.connection.readyState || this.connection.readyState === WebSocket.CLOSED ? true : false) } + + // Start reconnect timer + // 启动重连定时器 startReconnect() { this.stopReconnect(); this.logEvent('startReconnect'); @@ -379,6 +420,9 @@ class NodeCrypt { this.connect() }, this.config.reconnectDelay) } + + // Stop reconnect timer + // 停止重连定时器 stopReconnect() { if (this.reconnect) { this.logEvent('stopReconnect'); @@ -386,6 +430,9 @@ class NodeCrypt { this.reconnect = null } } + + // Start ping timer + // 启动心跳定时器 startPing() { this.stopPing(); this.logEvent('startPing'); @@ -393,6 +440,9 @@ class NodeCrypt { this.sendMessage('ping') }, this.config.pingInterval) } + + // Stop ping timer + // 停止心跳定时器 stopPing() { if (this.ping) { this.logEvent('stopPing'); @@ -400,6 +450,9 @@ class NodeCrypt { this.ping = null } } + + // Disconnect from server + // 从服务器断开连接 disconnect() { this.stopReconnect(); this.stopPing(); @@ -412,6 +465,9 @@ class NodeCrypt { } } } + + // Send a message to the server + // 向服务器发送消息 sendMessage(message) { try { if (this.isOpen()) { @@ -423,6 +479,9 @@ class NodeCrypt { } return (false) } + + // Send a message to all channels + // 向所有频道发送消息 sendChannelMessage(type, data) { if (this.serverShared) { try { @@ -456,6 +515,9 @@ class NodeCrypt { } return (false) } + + // Encrypt a message for the server + // 加密发送给服务器的消息 encryptServerMessage(message, key) { let encrypted = ''; try { @@ -471,6 +533,9 @@ class NodeCrypt { } return (encrypted) } + + // Decrypt a message from the server + // 解密来自服务器的消息 decryptServerMessage(message, key) { let decrypted = {}; try { @@ -482,6 +547,9 @@ class NodeCrypt { } return (decrypted) } + + // Encrypt a message for a client + // 加密发送给客户端的消息 encryptClientMessage(message, key) { let encrypted = ''; try { @@ -498,6 +566,9 @@ class NodeCrypt { } return (encrypted) } + + // Decrypt a message from a client + // 解密来自客户端的消息 decryptClientMessage(message, key) { let decrypted = {}; try { @@ -509,6 +580,9 @@ class NodeCrypt { } return (decrypted) } + + // XOR two hex strings + // 对两个十六进制字符串进行异或 xorHex(a, b) { let result = '', hexLength = Math.min(a.length, b.length); @@ -517,15 +591,27 @@ class NodeCrypt { } return (result) } + + // Check if value is a string + // 检查值是否为字符串 isString(value) { return (value && Object.prototype.toString.call(value) === '[object String]' ? true : false) } + + // Check if value is an array + // 检查值是否为数组 isArray(value) { return (value && Object.prototype.toString.call(value) === '[object Array]' ? true : false) } + + // Check if value is an object + // 检查值是否为对象 isObject(value) { return (value && Object.prototype.toString.call(value) === '[object Object]' ? true : false) } + + // Handle server public key + // 处理服务器公钥 async handleServerKey(serverKey) { this.logEvent('handleServerKey', 'Received server key'); localStorage.removeItem(this.SERVER_KEY_STORAGE); @@ -534,6 +620,7 @@ class NodeCrypt { return true } }; + if (typeof window !== 'undefined') { window.NodeCrypt = NodeCrypt } \ No newline at end of file diff --git a/web/chat.js b/web/chat.js index 3885675..632b1d8 100644 --- a/web/chat.js +++ b/web/chat.js @@ -1,3 +1,5 @@ +// Import necessary modules +// 导入必要的模块 import { createAvatarSVG } from './util.avatar.js'; @@ -17,6 +19,9 @@ import { addClass, removeClass } from './util.dom.js'; + +// Render the chat area +// 渲染聊天区域 export function renderChatArea() { const chatArea = $id('chat-area'); if (!chatArea) return; @@ -31,6 +36,9 @@ export function renderChatArea() { else addOtherMsg(m.text, m.userName, m.avatar, true, m.msgType || 'text', m.timestamp) }) } + +// Add a message to the chat area +// 添加消息到聊天区域 export function addMsg(text, isHistory = false, msgType = 'text', timestamp = null) { let ts = isHistory ? timestamp : (timestamp || Date.now()); if (!ts) return; @@ -60,6 +68,9 @@ export function addMsg(text, isHistory = false, msgType = 'text', timestamp = nu chatArea.appendChild(div); chatArea.scrollTop = chatArea.scrollHeight } + +// Add a message from another user to the chat area +// 添加来自其他用户的消息到聊天区域 export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false, msgType = 'text', timestamp = null) { if (!userName && activeRoomIndex >= 0) { const rd = roomsData[activeRoomIndex]; @@ -109,6 +120,9 @@ export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false, chatArea.appendChild(bubbleWrap); chatArea.scrollTop = chatArea.scrollHeight } + +// Add a system message to the chat area +// 添加系统消息到聊天区域 export function addSystemMsg(text, isHistory = false, timestamp = null) { if (!isHistory && activeRoomIndex >= 0) { const ts = timestamp || Date.now(); @@ -127,6 +141,9 @@ export function addSystemMsg(text, isHistory = false, timestamp = null) { chatArea.appendChild(div); chatArea.scrollTop = chatArea.scrollHeight } + +// Update the style of the chat input area +// 更新聊天输入区域的样式 export function updateChatInputStyle() { const rd = roomsData[activeRoomIndex]; const chatInputArea = $('.chat-input-area'); @@ -145,6 +162,9 @@ export function updateChatInputStyle() { const html = inputMessageInput.innerHTML.replace(/(\s*)?/gi, '').replace(/ /g, '').replace(/\u200B/g, '').trim(); placeholder.style.opacity = (html === '') ? '1' : '0' } + +// Setup image preview functionality +// 设置图片预览功能 export function setupImagePreview() { on($id('chat-area'), 'click', function(e) { const target = e.target; @@ -153,6 +173,9 @@ export function setupImagePreview() { } }) } + +// Show the image modal +// 显示图片模态框 export function showImageModal(src) { const modal = createElement('div', { class: 'img-modal-bg' @@ -230,6 +253,9 @@ export function showImageModal(src) { on($('.img-modal-close', modal), 'click', cleanup); updateTransform() } + +// Automatically adjust the height of the input area +// 自动调整输入区域的高度 export function autoGrowInput() { const input = $('.input-message-input'); if (!input) return; @@ -237,6 +263,8 @@ export function autoGrowInput() { input.style.height = input.scrollHeight + 'px' } +// Handle pasting text as plain text +// 处理粘贴为纯文本 function handlePasteAsPlainText(element) { if (!element) return; on(element, 'paste', function(e) { @@ -262,6 +290,9 @@ function handlePasteAsPlainText(element) { } }) } + +// Setup input placeholder functionality +// 设置输入框占位符功能 export function setupInputPlaceholder() { const input = $('.input-message-input'); const placeholder = $('.input-field-placeholder'); diff --git a/web/main.js b/web/main.js index 503f6fb..dcaa942 100644 --- a/web/main.js +++ b/web/main.js @@ -1,96 +1,146 @@ +// 导入 NodeCrypt 模块(加密功能模块) +// Import the NodeCrypt module (used for encryption) import './NodeCrypt.js'; + +// 从 util.image.js 中导入设置图片发送的函数 +// Import setupImageSend function from util.image.js import { setupImageSend } from './util.image.js'; + +// 从 util.emoji.js 中导入设置表情选择器的函数 +// Import setupEmojiPicker function from util.emoji.js import { setupEmojiPicker } from './util.emoji.js'; + +// 从 util.settings.js 中导入设置面板的功能函数 +// Import functions for settings panel from util.settings.js import { - openSettingsPanel, - closeSettingsPanel, - initSettings, - notifyMessage + openSettingsPanel, // 打开设置面板 / Open settings panel + closeSettingsPanel, // 关闭设置面板 / Close settings panel + initSettings, // 初始化设置 / Initialize settings + notifyMessage // 通知信息提示 / Display notification message } from './util.settings.js'; + +// 从 util.dom.js 中导入常用 DOM 操作函数 +// Import common DOM manipulation functions from util.dom.js import { - $, - $id, - removeClass + $, // 简化的 document.querySelector / Simplified selector + $id, // document.getElementById 的简写 / Shortcut for getElementById + removeClass // 移除类名 / Remove a CSS class } from './util.dom.js'; + +// 从 room.js 中导入房间管理相关变量和函数 +// Import room-related variables and functions from room.js import { - roomsData, - activeRoomIndex, - joinRoom + roomsData, // 当前所有房间的数据 / Data of all rooms + activeRoomIndex, // 当前激活的房间索引 / Index of the active room + joinRoom // 加入房间的函数 / Function to join a room } from './room.js'; + +// 从 chat.js 中导入聊天功能相关的函数 +// Import chat-related functions from chat.js import { - addMsg, - addSystemMsg, - setupImagePreview, - setupInputPlaceholder + addMsg, // 添加普通消息到聊天窗口 / Add a normal message to chat + addSystemMsg, // 添加系统消息 / Add a system message + setupImagePreview, // 设置图片预览功能 / Setup image preview + setupInputPlaceholder // 设置输入框的占位提示 / Setup placeholder for input box } from './chat.js'; + +// 从 ui.js 中导入 UI 界面相关的功能 +// Import user interface functions from ui.js import { - renderUserList, - renderMainHeader, - setupMoreBtnMenu, - preventSpaceInput, - loginFormHandler, - openLoginModal, - setupTabs, - autofillRoomPwd + renderUserList, // 渲染用户列表 / Render user list + renderMainHeader, // 渲染主标题栏 / Render main header + setupMoreBtnMenu, // 设置更多按钮的下拉菜单 / Setup "more" button menu + preventSpaceInput, // 防止输入空格 / Prevent space input in form fields + loginFormHandler, // 登录表单提交处理器 / Login form handler + openLoginModal, // 打开登录窗口 / Open login modal + setupTabs, // 设置页面标签切换 / Setup tab switching + autofillRoomPwd // 自动填充房间密码 / Autofill room password } from './ui.js'; + +// 设置全局配置参数 +// Set global configuration parameters window.config = { - wsAddress: 'wss://crypt.works/ws', - debug: false + wsAddress: 'wss://crypt.works/ws', // WebSocket 服务器地址 / WebSocket server address + debug: false // 是否开启调试模式 / Enable debug mode }; + +// 把一些函数挂载到 window 对象上供其他模块使用 +// Expose functions to the global window object for accessibility window.addSystemMsg = addSystemMsg; window.joinRoom = joinRoom; window.notifyMessage = notifyMessage; window.setupEmojiPicker = setupEmojiPicker; + +// 当 DOM 内容加载完成后执行初始化逻辑 +// Run initialization logic when the DOM content is fully loaded window.addEventListener('DOMContentLoaded', () => { - const loginContainer = $id('login-container'); - const chatContainer = $id('chat-container'); - const loginForm = $id('login-form'); + const loginContainer = $id('login-container'); // 登录容器 / Login container + const chatContainer = $id('chat-container'); // 聊天容器 / Chat container + const loginForm = $id('login-form'); // 登录表单 / Login form + if (loginForm) { + // 监听登录表单提交事件 / Listen to login form submission loginForm.addEventListener('submit', loginFormHandler(null)) } - const joinBtn = $('.join-room'); + + const joinBtn = $('.join-room'); // 加入房间按钮 / Join room button if (joinBtn) { - joinBtn.onclick = openLoginModal + joinBtn.onclick = openLoginModal; // 点击打开登录窗口 / Click to open login modal } + + // 阻止用户输入用户名、房间名和密码时输入空格 + // Prevent space input for username, room name, and password fields preventSpaceInput($id('userName')); preventSpaceInput($id('roomName')); preventSpaceInput($id('password')); + + // 初始化辅助功能和界面设置 + // Initialize autofill, input placeholders, and menus autofillRoomPwd(); setupInputPlaceholder(); setupMoreBtnMenu(); setupImagePreview(); setupEmojiPicker(); initSettings(); - const settingsBtn = $id('settings-btn'); + + const settingsBtn = $id('settings-btn'); // 设置按钮 / Settings button if (settingsBtn) { settingsBtn.onclick = (e) => { - e.stopPropagation(); - openSettingsPanel() + e.stopPropagation(); // 阻止事件冒泡 / Stop event from bubbling + openSettingsPanel(); // 打开设置面板 / Open settings panel } } + + // 点击其他地方时关闭设置面板 + // Close settings panel when clicking outside document.addEventListener('click', (ev) => { const panel = $id('settings-panel'); if (panel && panel.style.display === 'block') { const card = panel.querySelector('.settings-panel-card'); if (card && !card.contains(ev.target) && ev.target.id !== 'settings-btn') { - closeSettingsPanel() + closeSettingsPanel(); } } }); - const input = document.querySelector('.input-message-input'); + + const input = document.querySelector('.input-message-input'); // 消息输入框 / Message input box if (input) { - input.focus(); + input.focus(); // 自动聚焦 / Auto focus input.addEventListener('keydown', (e) => { + // 按下 Enter 键并且不按 Shift,表示发送消息 + // Pressing Enter (without Shift) sends the message if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - const text = input.innerText.trim(); - const rd = roomsData[activeRoomIndex]; + const text = input.innerText.trim(); // 获取输入的文本 / Get input text + const rd = roomsData[activeRoomIndex]; // 当前房间数据 / Current room data if (text && rd && rd.chat) { if (rd.privateChatTargetId) { + // 私聊消息加密并发送 + // Encrypt and send private message const targetClient = rd.chat.channel[rd.privateChatTargetId]; if (targetClient && targetClient.shared) { const clientMessagePayload = { @@ -106,28 +156,37 @@ window.addEventListener('DOMContentLoaded', () => { }; const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared); rd.chat.sendMessage(encryptedMessageForServer); - addMsg(text, false, 'text_private') + addMsg(text, false, 'text_private'); } else { addSystemMsg(`Cannot send private message to ${rd.privateChatTargetName}.User might not be fully connected.`) } } else { + // 公共频道消息发送 + // Send public message rd.chat.sendChannelMessage('text', text); - addMsg(text) + addMsg(text); } + // 清空输入框并触发 input 事件 + // Clear input and trigger input event input.innerText = ''; - input.dispatchEvent(new Event('input')) + input.dispatchEvent(new Event('input')); } } - }) + }); } + + // 设置发送图片功能 + // Setup image sending functionality setupImageSend({ - inputSelector: '.input-message-input', - attachBtnSelector: '.chat-attach-btn', - fileInputSelector: '.new-message-wrapper input[type="file"]', + inputSelector: '.input-message-input', // 消息输入框选择器 / Message input selector + attachBtnSelector: '.chat-attach-btn', // 附件按钮选择器 / Attach button selector + fileInputSelector: '.new-message-wrapper input[type="file"]', // 文件输入框选择器 / File input selector onSend: (dataUrl) => { const rd = roomsData[activeRoomIndex]; if (rd && rd.chat) { if (rd.privateChatTargetId) { + // 私聊图片加密并发送 + // Encrypt and send private image const targetClient = rd.chat.channel[rd.privateChatTargetId]; if (targetClient && targetClient.shared) { const clientMessagePayload = { @@ -143,41 +202,56 @@ window.addEventListener('DOMContentLoaded', () => { }; const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared); rd.chat.sendMessage(encryptedMessageForServer); - addMsg(dataUrl, false, 'image_private') + addMsg(dataUrl, false, 'image_private'); } else { addSystemMsg(`Cannot send private image to ${rd.privateChatTargetName}.User might not be fully connected.`) } } else { + // 公共频道发送图片 + // Send image to public channel rd.chat.sendChannelMessage('image', dataUrl); - addMsg(dataUrl, false, 'image') + addMsg(dataUrl, false, 'image'); } } } }); + + // 判断是否为移动端 + // Check if the device is mobile const isMobile = () => window.innerWidth <= 768; + + // 渲染主界面元素 + // Render main UI elements renderMainHeader(); renderUserList(); setupTabs(); + const roomList = $id('room-list'); const sidebar = $id('sidebar'); const rightbar = $id('rightbar'); const sidebarMask = $id('mobile-sidebar-mask'); const rightbarMask = $id('mobile-rightbar-mask'); + + // 在移动端点击房间列表后关闭侧边栏 + // On mobile, clicking room list closes sidebar if (roomList) { roomList.addEventListener('click', () => { if (isMobile()) { sidebar?.classList.remove('mobile-open'); - sidebarMask?.classList.remove('active') + sidebarMask?.classList.remove('active'); } - }) + }); } + + // 在移动端点击成员标签后关闭右侧面板 + // On mobile, clicking member tabs closes right panel const memberTabs = $id('member-tabs'); if (memberTabs) { memberTabs.addEventListener('click', () => { if (isMobile()) { removeClass(rightbar, 'mobile-open'); - removeClass(rightbarMask, 'active') + removeClass(rightbarMask, 'active'); } - }) + }); } -}); \ No newline at end of file +}); diff --git a/web/room.js b/web/room.js index 7fb092e..ac7ecd2 100644 --- a/web/room.js +++ b/web/room.js @@ -1,3 +1,6 @@ +// Room management logic for NodeCrypt web client +// NodeCrypt 网页客户端的房间管理逻辑 + import { createAvatarSVG } from './util.avatar.js'; @@ -19,6 +22,9 @@ import { } from './util.dom.js'; let roomsData = []; let activeRoomIndex = -1; + +// Get a new room data object +// 获取一个新的房间数据对象 export function getNewRoomData() { return { roomName: '', @@ -35,6 +41,9 @@ export function getNewRoomData() { privateChatTargetName: null } } + +// Switch to another room by index +// 切换到指定索引的房间 export function switchRoom(index) { if (index < 0 || index >= roomsData.length) return; activeRoomIndex = index; @@ -49,6 +58,9 @@ export function switchRoom(index) { renderChatArea(); updateChatInputStyle() } + +// Set the sidebar avatar +// 设置侧边栏头像 export function setSidebarAvatar(userName) { if (!userName) return; const svg = createAvatarSVG(userName); @@ -58,6 +70,9 @@ export function setSidebarAvatar(userName) { el.innerHTML = cleanSvg } } + +// Render the room list +// 渲染房间列表 export function renderRooms(activeId = 0) { const roomList = $id('room-list'); roomList.innerHTML = ''; @@ -75,6 +90,9 @@ export function renderRooms(activeId = 0) { roomList.appendChild(div) }) } + +// Join a room +// 加入一个房间 export function joinRoom(userName, roomName, password, modal = null, onResult) { const newRd = getNewRoomData(); newRd.roomName = roomName; @@ -120,6 +138,9 @@ export function joinRoom(userName, roomName, password, modal = null, onResult) { chatInst.connect(); roomsData[idx].chat = chatInst } + +// Handle the client list update +// 处理客户端列表更新 export function handleClientList(idx, list, selfId) { const rd = roomsData[idx]; if (!rd) return; @@ -146,6 +167,9 @@ export function handleClientList(idx, list, selfId) { rd.knownUserIds = new Set(list.map(u => u.clientId)) } } + +// Handle client secured event +// 处理客户端安全连接事件 export function handleClientSecured(idx, user) { const rd = roomsData[idx]; if (!rd) return; @@ -178,6 +202,9 @@ export function handleClientSecured(idx, user) { } } } + +// Handle client left event +// 处理客户端离开事件 export function handleClientLeft(idx, clientId) { const rd = roomsData[idx]; if (!rd) return; @@ -203,6 +230,9 @@ export function handleClientLeft(idx, clientId) { renderMainHeader() } } + +// Handle client message event +// 处理客户端消息事件 export function handleClientMessage(idx, msg) { const newRd = roomsData[idx]; if (!newRd) return; @@ -239,6 +269,9 @@ export function handleClientMessage(idx, msg) { renderChatArea() } } + +// Toggle private chat with a user +// 切换与某用户的私聊 export function togglePrivateChat(targetId, targetName) { const rd = roomsData[activeRoomIndex]; if (!rd) return; @@ -252,6 +285,9 @@ export function togglePrivateChat(targetId, targetName) { renderUserList(); updateChatInputStyle() } + +// Set the status bar text +// 设置状态栏文本 export function setStatus(text) { let statusBar = $id('status-bar'); if (!statusBar) { @@ -263,6 +299,9 @@ export function setStatus(text) { } statusBar.innerText = text } + +// Exit the current room +// 退出当前房间 export function exitRoom() { if (activeRoomIndex >= 0 && roomsData[activeRoomIndex]) { const chatInst = roomsData[activeRoomIndex].chat; @@ -282,6 +321,7 @@ export function exitRoom() { } return false } + export { roomsData, activeRoomIndex diff --git a/web/ui.js b/web/ui.js index fef4960..e54103a 100644 --- a/web/ui.js +++ b/web/ui.js @@ -1,3 +1,6 @@ +// UI logic for NodeCrypt web client +// NodeCrypt 网页客户端的 UI 逻辑 + import { createAvatarSVG } from './util.avatar.js'; @@ -13,6 +16,9 @@ import { import { $id } from './util.dom.js'; + +// Render the main header +// 渲染主标题栏 export function renderMainHeader() { const rd = roomsData[activeRoomIndex]; let roomName = rd ? rd.roomName : 'Room'; @@ -25,6 +31,9 @@ export function renderMainHeader() { setupMoreBtnMenu(); setupMobileUIHandlers() } + +// Setup mobile UI event handlers +// 设置移动端 UI 事件处理 export function setupMobileUIHandlers() { const sidebar = document.getElementById('sidebar'); const rightbar = document.getElementById('rightbar'); @@ -90,6 +99,9 @@ export function setupMobileUIHandlers() { } }) } + +// Render the user/member list +// 渲染用户/成员列表 export function renderUserList(updateHeader = false) { const userListEl = $id('member-list'); if (!userListEl) return; @@ -104,6 +116,9 @@ export function renderUserList(updateHeader = false) { renderMainHeader() } } + +// Create a user list item +// 创建一个用户列表项 export function createUserItem(user, isMe) { const div = document.createElement('div'); const rd = roomsData[activeRoomIndex]; @@ -123,6 +138,9 @@ export function createUserItem(user, isMe) { } return div } + +// Setup the 'more' button menu +// 设置“更多”按钮菜单 export function setupMoreBtnMenu() { const btn = $id('more-btn'); const menu = $id('more-menu'); @@ -131,6 +149,8 @@ export function setupMoreBtnMenu() { let animating = false; let isMouseMoveBound = false; + // Mouse move event for closing menu if mouse leaves + // 鼠标移出菜单区域时关闭菜单 function onMouseMove(ev) { if (!menuRect) return; const mx = ev.clientX, @@ -144,6 +164,8 @@ export function setupMoreBtnMenu() { } } + // Bind mousemove event + // 绑定鼠标移动事件 function bindMouseMove() { if (!isMouseMoveBound) { window.addEventListener('mousemove', onMouseMove); @@ -151,6 +173,8 @@ export function setupMoreBtnMenu() { } } + // Unbind mousemove event + // 解绑鼠标移动事件 function unbindMouseMove() { if (isMouseMoveBound) { window.removeEventListener('mousemove', onMouseMove); @@ -158,6 +182,8 @@ export function setupMoreBtnMenu() { } } + // Open the menu + // 打开菜单 function openMenu() { menu.classList.remove('close'); menu.classList.add('open'); @@ -166,6 +192,8 @@ export function setupMoreBtnMenu() { bindMouseMove() } + // Close the menu + // 关闭菜单 function closeMenu() { if (animating) return; animating = true; @@ -226,6 +254,9 @@ export function setupMoreBtnMenu() { animating = false }) } + +// Prevent space and special character input +// 禁止输入空格和特殊字符 export function preventSpaceInput(input) { if (!input) return; input.addEventListener('keydown', function(e) { @@ -239,6 +270,9 @@ export function preventSpaceInput(input) { }) }) } + +// Login form submit handler +// 登录表单提交处理函数 export function loginFormHandler(modal) { return function(e) { e.preventDefault(); @@ -296,6 +330,9 @@ export function loginFormHandler(modal) { }) } } + +// Open the login modal dialog +// 打开登录弹窗 export function openLoginModal() { const modal = document.createElement('div'); modal.className = 'login-modal'; @@ -309,6 +346,9 @@ export function openLoginModal() { form.addEventListener('submit', loginFormHandler(modal)); autofillRoomPwd('userName-modal') } + +// Setup member list tabs +// 设置成员列表标签页 export function setupTabs() { const tabs = document.getElementById("member-tabs").children; for (let i = 0; i < tabs.length; i++) { @@ -318,6 +358,9 @@ export function setupTabs() { } } } + +// Autofill room and password from URL +// 从 URL 自动填充房间和密码 export function autofillRoomPwd(formPrefix = '') { const params = new URLSearchParams(window.location.search); const room = params.get('node'); diff --git a/web/util.avatar.js b/web/util.avatar.js index 9a33644..f1a410d 100644 --- a/web/util.avatar.js +++ b/web/util.avatar.js @@ -1,12 +1,19 @@ +// Import dicebear avatar libraries +// 导入 dicebear 头像库 import * as dicebearCore from '@dicebear/core'; import * as dicebearMicah from '@dicebear/micah'; +// Predefined background colors +// 预设背景色数组 const bgColors = ["f87171", "fb923c", "09acf4", "fb923c", "f472b6", "a78bfa", "34d399"]; - +// Pick a color based on seed string +// 根据种子字符串选择颜色 function pickColor(seed) { let hash = 0; for (let i = 0; i < seed.length; i++) hash = seed.charCodeAt(i) + ((hash << 5) - hash); return bgColors[Math.abs(hash) % bgColors.length] } +// Create SVG avatar for user name +// 为用户名生成 SVG 头像 export function createAvatarSVG(userName) { return dicebearCore.createAvatar(dicebearMicah, { seed: userName, diff --git a/web/util.dom.js b/web/util.dom.js index aa74d64..a8002a7 100644 --- a/web/util.dom.js +++ b/web/util.dom.js @@ -1,12 +1,20 @@ +// Query single DOM element +// 查询单个 DOM 元素 export function $(selector, parent = document) { return parent.querySelector(selector) } +// Query multiple DOM elements +// 查询多个 DOM 元素 export function $$(selector, parent = document) { return parent.querySelectorAll(selector) } +// Get element by ID +// 通过 ID 获取元素 export function $id(id) { return document.getElementById(id) } +// Create a DOM element with attributes and content +// 创建带属性和内容的 DOM 元素 export function createElement(tag, attrs = {}, content = '') { const el = document.createElement(tag); Object.entries(attrs).forEach(([key, value]) => { @@ -27,20 +35,30 @@ export function createElement(tag, attrs = {}, content = '') { } return el } +// Add event listener +// 添加事件监听器 export function on(target, event, handler, options) { const el = typeof target === 'string' ? $(target) : target; if (el) el.addEventListener(event, handler, options) } +// Remove event listener +// 移除事件监听器 export function off(target, event, handler) { const el = typeof target === 'string' ? $(target) : target; if (el) el.removeEventListener(event, handler) } +// Add class(es) to element +// 为元素添加类名 export function addClass(el, ...classNames) { el.classList.add(...classNames) } +// Remove class(es) from element +// 移除元素的类名 export function removeClass(el, ...classNames) { el.classList.remove(...classNames) } +// Toggle class on element +// 切换元素的类名 export function toggleClass(el, className, force) { el.classList.toggle(className, force) } \ No newline at end of file diff --git a/web/util.emoji.js b/web/util.emoji.js index 32fe257..60cd1bb 100644 --- a/web/util.emoji.js +++ b/web/util.emoji.js @@ -1,8 +1,12 @@ +// Import DOM helpers +// 导入 DOM 辅助函数 import { $, on } from './util.dom.js'; import 'emoji-picker-element'; +// Add emoji picker styles to document +// 向文档添加 emoji 选择器样式 const addEmojiPickerStyles = () => { if (document.querySelector('#emoji-picker-styles')) return; const style = document.createElement('style'); @@ -10,6 +14,8 @@ const addEmojiPickerStyles = () => { style.textContent = `emoji-picker{--background:#fff;--border-color:rgba(0,0,0,0.1);--border-radius:10px;--emoji-padding:0.4rem;--category-emoji-size:1.2rem;--font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;position:absolute;bottom:60px;left:22px;z-index:5;box-shadow:0 3px 12px rgba(0,0,0,0.15);animation:emoji-panel-fade-in 0.18s;width:320px;display:none}@keyframes emoji-panel-fade-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}`; document.head.appendChild(style) }; +// Setup emoji picker for chat input +// 为聊天输入框设置 emoji 选择器 export function setupEmojiPicker({ btnSelector = '.chat-emoji-btn', inputSelector = '.input-message-input' @@ -25,14 +31,20 @@ export function setupEmojiPicker({ picker.style.display = 'none'; btn.parentNode.style.position = 'relative'; btn.parentNode.appendChild(picker); + // Emoji click event + // 监听 emoji 点击事件 picker.addEventListener('emoji-click', event => { insertEmoji(input, event.detail.unicode); picker.style.display = 'none' }); + // Button click toggles picker + // 按钮点击切换选择器显示 on(btn, 'click', (ev) => { ev.stopPropagation(); picker.style.display = picker.style.display === 'none' ? 'block' : 'none' }); + // Hide picker when clicking outside + // 点击外部隐藏选择器 on(document, 'click', (ev) => { if (!picker.contains(ev.target) && ev.target !== btn) { picker.style.display = 'none' @@ -43,7 +55,8 @@ export function setupEmojiPicker({ console.error('Failed to initialize emoji picker:', error) } } - +// Insert emoji into input at cursor +// 在光标处插入 emoji 到输入框 function insertEmoji(input, emoji) { input.focus(); if (document.getSelection && window.getSelection) { diff --git a/web/util.image.js b/web/util.image.js index 5a06ad6..5091ec6 100644 --- a/web/util.image.js +++ b/web/util.image.js @@ -1,42 +1,59 @@ +// Import DOM helpers +// 导入 DOM 辅助函数 import { $, on, createElement } from './util.dom.js'; +// Process and compress image file +// 处理并压缩图片文件 export async function processImage(file, callback) { + // Create image element for loading file + // 创建图片元素用于加载文件 const img = new Image(); img.onload = function() { - const maxW = 1280, - maxH = 1280; + const maxW = 1280, // Max width + maxH = 1280; // 最大宽度/高度 let w = img.naturalWidth, h = img.naturalHeight; + // Resize if too large + // 如果图片过大则缩放 if (w > maxW || h > maxH) { const scale = Math.min(maxW / w, maxH / h); w = Math.round(w * scale); h = Math.round(h * scale) } + // Create canvas for drawing image + // 创建画布用于绘制图片 const canvas = createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); let dataUrl; + // Export as webp + // 导出为 webp dataUrl = canvas.toDataURL('image/webp', 0.80); callback(dataUrl) }; + // Read file as data URL + // 以 dataURL 方式读取文件 const reader = new FileReader(); reader.onload = function(e) { img.src = e.target.result }; reader.readAsDataURL(file) } - +// Translate message key +// 翻译消息 key function t(key) { const messages = { - tooLarge: 'Image is too large (over 5MB)', + tooLarge: 'Image is too large (over 5MB)', // 图片过大(超过5MB) }; return messages[key] || key } +// Setup image send logic for UI +// 设置图片发送相关 UI 逻辑 export function setupImageSend({ inputSelector, attachBtnSelector, @@ -48,11 +65,19 @@ export function setupImageSend({ const fileInput = $(fileInputSelector); if (fileInput) fileInput.setAttribute('accept', 'image/*'); if (attachBtn && fileInput) { + // Click attach triggers file input + // 点击附件按钮触发文件选择 on(attachBtn, 'click', () => fileInput.click()); + // Handle file input change + // 处理文件选择变化 on(fileInput, 'change', async function() { if (!fileInput.files || !fileInput.files.length) return; const file = fileInput.files[0]; + // Only allow image files + // 只允许图片文件 if (!file.type.startsWith('image/')) return; + // Check file size + // 检查文件大小 if (file.size > 5 * 1024 * 1024) { alert(t('tooLarge')); return @@ -64,12 +89,18 @@ export function setupImageSend({ }) } if (input) { + // Paste image from clipboard + // 从剪贴板粘贴图片 on(input, 'paste', function(e) { if (!e.clipboardData) return; for (const item of e.clipboardData.items) { + // Only handle image type + // 只处理图片类型 if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (!file) continue; + // Check file size + // 检查文件大小 if (file.size > 5 * 1024 * 1024) { alert(t('tooLarge')); continue diff --git a/web/util.settings.js b/web/util.settings.js index 42216a4..81a76f7 100644 --- a/web/util.settings.js +++ b/web/util.settings.js @@ -1,3 +1,5 @@ +// Import DOM utility functions +// 导入 DOM 工具函数 import { $, $$, @@ -8,11 +10,15 @@ import { addClass, removeClass } from './util.dom.js'; +// Default settings +// 默认设置 const DEFAULT_SETTINGS = { notify: false, sound: false }; +// Load settings from localStorage +// 从 localStorage 加载设置 function loadSettings() { let s = localStorage.getItem('settings'); try { @@ -26,6 +32,8 @@ function loadSettings() { } } +// Save settings to localStorage +// 保存设置到 localStorage function saveSettings(settings) { const { notify, @@ -37,10 +45,14 @@ function saveSettings(settings) { })) } +// Apply settings to the document +// 应用设置到文档 function applySettings(settings) { document.documentElement.lang = 'en' } +// Ask for browser notification permission +// 请求浏览器通知权限 function askNotificationPermission(callback) { if (Notification.requestPermission.length === 0) { Notification.requestPermission().then(callback) @@ -49,6 +61,8 @@ function askNotificationPermission(callback) { } } +// Setup the settings panel UI +// 设置设置面板 UI function setupSettingsPanel() { let panel = $id('settings-panel'); if (!panel) { @@ -99,13 +113,15 @@ function setupSettingsPanel() { settings.sound = e.target.checked; if (settings.sound) { settings.notify = false; - if (notifyCheckbox) notifyCheckbox.checked = false + if (notifyCheckbox) notifyCheckbox.checked = false; } saveSettings(settings); applySettings(settings) - }) + }); } +// Open the settings panel +// 打开设置面板 function openSettingsPanel() { setupSettingsPanel(); const panel = $id('settings-panel'); @@ -129,6 +145,7 @@ function openSettingsPanel() { const mx = ev.clientX, my = ev.clientY; const inCard = mx >= cardRect.left - safe && mx <= cardRect.right + safe && my >= cardRect.top - safe && my <= cardRect.bottom + safe; + const btnRect = btn.getBoundingClientRect(); const inBtn = mx >= btnRect.left - safe && mx <= btnRect.right + safe && my >= btnRect.top - safe && my <= btnRect.bottom + safe; if (!inCard && !inBtn) { closeSettingsPanel() @@ -152,6 +169,8 @@ function openSettingsPanel() { panel._unbind = unbindMouseMove } +// Close the settings panel +// 关闭设置面板 function closeSettingsPanel() { const panel = $id('settings-panel'); if (!panel) return; @@ -166,16 +185,25 @@ function closeSettingsPanel() { }, 180) } +// Initialize settings on page load +// 页面加载时初始化设置 function initSettings() { const settings = loadSettings(); applySettings(settings) } + +// Maximum notification text length +// 通知文本最大长度 const MAX_NOTIFY_TEXT_LEN = 100; +// Truncate text for notifications +// 截断通知文本 function truncateText(text) { return text.length > MAX_NOTIFY_TEXT_LEN ? text.slice(0, MAX_NOTIFY_TEXT_LEN) + '...' : text } +// Play sound notification +// 播放声音通知 function playSoundNotification() { try { const ctx = new(window.AudioContext || window.webkitAudioContext)(); @@ -195,6 +223,8 @@ function playSoundNotification() { } } +// Show desktop notification +// 显示桌面通知 function showDesktopNotification(roomName, text, msgType, sender) { if (!('Notification' in window) || Notification.permission !== 'granted') return; let body; @@ -216,6 +246,9 @@ function showDesktopNotification(roomName, text, msgType, sender) { body }) } + +// Notify message entry point +// 通知消息主入口 export function notifyMessage(roomName, msgType, text, sender) { const settings = loadSettings(); if (settings.notify) { diff --git a/web/util.string.js b/web/util.string.js index d625f96..cecb190 100644 --- a/web/util.string.js +++ b/web/util.string.js @@ -1,15 +1,19 @@ +// Escape HTML special characters +// 转义 HTML 特殊字符 export function escapeHTML(str) { if (typeof str !== 'string') return ''; return str.replace(/[&<>"']/g, function(c) { return { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\'': ''' + '&': '&', // & to & // & 转为 & + '<': '<', // < to < // < 转为 < + '>': '>', // > to > // > 转为 > + '"': '"', // " to " // " 转为 " + '\'': ''' // ' to ' // ' 转为 ' } [c] }) } +// Convert text to HTML, preserving line breaks +// 将文本转换为 HTML,保留换行符 export function textToHTML(text) { if (typeof text !== 'string') return ''; return escapeHTML(text).replace(/\n/g, '
')