diff --git a/client/css/style.css b/client/css/style.css index 19a25d0..8228d85 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1665,3 +1665,129 @@ body { left: 100%; } +/* Image preview in input */ +/* 输入框图片预览样式 */ +.input-image-preview { + position: relative; + display: inline-block; /* 改为 inline-block 以便并排显示 */ + margin: 4px; /* 统一 margin */ + border-radius: 8px; + overflow: visible; /* 允许移除按钮稍微超出 */ + background: #f8f9fa; + border: 1px solid #e9ecef; /* 边框调整 */ + transition: all 0.2s ease; + max-width: 80px; /* 限制预览容器的最大宽度 */ +} + +.input-image-preview:hover { + border-color: #007bff; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1); /* 调整阴影 */ +} + +.input-image-preview-img { + display: block; + width: 100%; /* 图片宽度充满容器 */ + height: 60px; /* 固定高度 */ + object-fit: cover; + border-radius: 6px; /* 轻微圆角 */ +} + +.input-image-remove-btn { + position: absolute; + top: -10px; /* 调整位置使其更易点击 */ + right: -10px; /* 调整位置使其更易点击 */ + width: 22px; /* 增大点击区域 */ + height: 22px; /* 增大点击区域 */ + border: none; + border-radius: 50%; + background: #dc3545; + color: white; + font-size: 12px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.input-image-remove-btn:hover { + background: #c82333; + transform: scale(1.1); +} + +.input-image-remove-btn:active { + transform: scale(0.95); +} + +/* Image message in chat bubble */ +/* 聊天气泡中的图片消息样式 */ +.bubble-img { + max-width: 300px; + width: 100%; + height: auto; + max-height: 200px; + object-fit: contain; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + display: block; + margin: 4px 0; +} + +.bubble-img:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.bubble-content .bubble-img:first-child { + margin-top: 0; +} + +.bubble-content .bubble-img:last-child { + margin-bottom: 0; +} + +/* Mixed content (text + image) */ +/* 混合内容(文字 + 图片)样式 */ +.bubble-content.mixed-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bubble-content.mixed-content .message-text { + margin: 0; +} + +.bubble-content.mixed-content .bubble-img { + margin: 0; +} + +@media (max-width: 768px) { + .input-image-preview-img { + width: 60px; + height: 45px; + } + + .bubble-img { + max-width: 250px; + max-height: 180px; + } +} + +@media (max-width: 480px) { + .input-image-preview-img { + width: 50px; + height: 38px; + } + + .bubble-img { + max-width: 200px; + max-height: 150px; + } +} + diff --git a/client/js/chat.js b/client/js/chat.js index 5b31a65..5624ee9 100644 --- a/client/js/chat.js +++ b/client/js/chat.js @@ -16,6 +16,7 @@ import { $id, createElement, on, + off, addClass, removeClass } from './util.dom.js'; @@ -56,10 +57,48 @@ export function addMsg(text, isHistory = false, msgType = 'text', timestamp = nu if (!chatArea) return; let className = 'bubble me' + (msgType.includes('_private') ? ' private-message' : ''); const date = new Date(ts); - const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0'); let contentHtml = ''; - if (msgType === 'image' || msgType === 'image_private') { - const safeImgSrc = escapeHTML(text).replace(/javascript:/gi, ''); - contentHtml = `image` + const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0'); let contentHtml = ''; if (msgType === 'image' || msgType === 'image_private') { + // Handle image messages (can contain both text and images) + if (typeof text === 'object' && text.images && Array.isArray(text.images)) { + // New multi-image format: {text: "", images: ["data:image...", "data:image..."]} + const messageText = text.text ? textToHTML(text.text) : ''; + const imageElements = text.images.map(imgData => { + const safeImgSrc = escapeHTML(imgData).replace(/javascript:/gi, ''); + return `image`; + }).join(''); + if (messageText && imageElements) { + // Mixed content: text + images + contentHtml = `
+
${messageText}
+ ${imageElements} +
`; + } else if (imageElements) { + // Images only + contentHtml = imageElements; + } else { + // Fallback to text only + contentHtml = messageText; + } + } else if (typeof text === 'object' && text.image) { + // Legacy single image format: {text: "", image: "data:image..."} + const safeImgSrc = escapeHTML(text.image).replace(/javascript:/gi, ''); + const messageText = text.text ? textToHTML(text.text) : ''; + + if (messageText) { + // Mixed content: text + image + contentHtml = `
+
${messageText}
+ image +
`; + } else { + // Image only + contentHtml = `image`; + } + } else { + // Legacy format: plain image data URL + const safeImgSrc = escapeHTML(text).replace(/javascript:/gi, ''); + contentHtml = `image`; + } } else if (msgType === 'file' || msgType === 'file_private') { // Handle file messages contentHtml = renderFileMessage(text, true); @@ -101,10 +140,48 @@ export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false, if (!chatArea) return; const bubbleWrap = createElement('div', { class: 'bubble-other-wrap' - }); let contentHtml = ''; - if (msgType === 'image' || msgType === 'image_private') { - const safeImgSrc = escapeHTML(msg).replace(/javascript:/gi, ''); - contentHtml = `image` + }); let contentHtml = ''; if (msgType === 'image' || msgType === 'image_private') { + // Handle image messages (can contain both text and images) + if (typeof msg === 'object' && msg.images && Array.isArray(msg.images)) { + // New multi-image format: {text: "", images: ["data:image...", "data:image..."]} + const messageText = msg.text ? textToHTML(msg.text) : ''; + const imageElements = msg.images.map(imgData => { + const safeImgSrc = escapeHTML(imgData).replace(/javascript:/gi, ''); + return `image`; + }).join(''); + if (messageText && imageElements) { + // Mixed content: text + images + contentHtml = `
+
${messageText}
+ ${imageElements} +
`; + } else if (imageElements) { + // Images only + contentHtml = imageElements; + } else { + // Fallback to text only + contentHtml = messageText; + } + } else if (typeof msg === 'object' && msg.image) { + // Legacy single image format: {text: "", image: "data:image..."} + const safeImgSrc = escapeHTML(msg.image).replace(/javascript:/gi, ''); + const messageText = msg.text ? textToHTML(msg.text) : ''; + + if (messageText) { + // Mixed content: text + image + contentHtml = `
+
${messageText}
+ image +
`; + } else { + // Image only + contentHtml = `image`; + } + } else { + // Legacy format: plain image data URL + const safeImgSrc = escapeHTML(msg).replace(/javascript:/gi, ''); + contentHtml = `image`; + } } else if (msgType === 'file' || msgType === 'file_private') { // Handle file messages contentHtml = renderFileMessage(msg, false); } else { diff --git a/client/js/main.js b/client/js/main.js index d097f63..a682e9f 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -10,6 +10,12 @@ import { downloadFile } from './util.file.js'; +// 从 util.image.js 中导入图片处理功能 +// Import image processing functions from util.image.js +import { + setupImagePaste +} from './util.image.js'; + // 从 util.emoji.js 中导入设置表情选择器的函数 // Import setupEmojiPicker function from util.emoji.js import { @@ -51,9 +57,11 @@ import { // Import chat-related functions from chat.js import { addMsg, // 添加普通消息到聊天窗口 / Add a normal message to chat + addOtherMsg, // 添加其他用户消息 / Add message from other users addSystemMsg, // 添加系统消息 / Add a system message setupImagePreview, // 设置图片预览功能 / Setup image preview - setupInputPlaceholder // 设置输入框的占位提示 / Setup placeholder for input box + setupInputPlaceholder, // 设置输入框的占位提示 / Setup placeholder for input box + autoGrowInput // 自动调整输入框高度 / Auto adjust input height } from './chat.js'; // 从 ui.js 中导入 UI 界面相关的功能 @@ -81,6 +89,7 @@ window.config = { // 把一些函数挂载到 window 对象上供其他模块使用 // Expose functions to the global window object for accessibility window.addSystemMsg = addSystemMsg; +window.addOtherMsg = addOtherMsg; window.joinRoom = joinRoom; window.notifyMessage = notifyMessage; window.setupEmojiPicker = setupEmojiPicker; @@ -137,8 +146,12 @@ window.addEventListener('DOMContentLoaded', () => { } // 点击其他地方时关闭设置面板 (已移除,因为现在使用侧边栏形式) // Close settings panel when clicking outside (removed since we now use sidebar format) - const input = document.querySelector('.input-message-input'); // 消息输入框 / Message input box + + // 设置图片粘贴功能 + // Setup image paste functionality + const imagePasteHandler = setupImagePaste('.input-message-input'); + if (input) { input.focus(); // 自动聚焦 / Auto focus input.addEventListener('keydown', (e) => { @@ -146,45 +159,108 @@ window.addEventListener('DOMContentLoaded', () => { // Pressing Enter (without Shift) sends the message if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - 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 = { - a: 'm', - t: 'text_private', - d: text - }; - const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared); - const serverRelayPayload = { - a: 'c', - p: encryptedClientMessage, - c: rd.privateChatTargetId - }; - const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared); - rd.chat.sendMessage(encryptedMessageForServer); - 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); - } - // 清空输入框并触发 input 事件 - // Clear input and trigger input event - input.innerText = ''; - input.dispatchEvent(new Event('input')); - } + sendMessage(); } }); } + + // 发送消息的统一函数 + // Unified function to send messages + function sendMessage() { + const text = input.innerText.trim(); // 获取输入的文本 / Get input text + const images = imagePasteHandler ? imagePasteHandler.getCurrentImages() : []; // 获取所有图片 + + if (!text && images.length === 0) return; // 如果没有文本且没有图片,则不发送 + const rd = roomsData[activeRoomIndex]; // 当前房间数据 / Current room data + + if (rd && rd.chat) { + if (images.length > 0) { + // 发送包含图片的消息 (支持多图和文字合并) + // Send message with images (supports multiple images and text combined) + const messageContent = { + text: text || '', // 包含文字内容,如果有的话 + images: images // 包含所有图片数据 + }; + + if (rd.privateChatTargetId) { + // 私聊图片消息加密并发送 + // Encrypt and send private image message + const targetClient = rd.chat.channel[rd.privateChatTargetId]; + if (targetClient && targetClient.shared) { + const clientMessagePayload = { + a: 'm', + t: 'image_private', + d: messageContent + }; + const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared); + const serverRelayPayload = { + a: 'c', + p: encryptedClientMessage, + c: rd.privateChatTargetId + }; + const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared); + rd.chat.sendMessage(encryptedMessageForServer); + addMsg(messageContent, false, 'image_private'); + } else { + addSystemMsg(`Cannot send private message to ${rd.privateChatTargetName}.User might not be fully connected.`) + } + } else { + // 公共频道图片消息发送 + // Send image message to public channel + rd.chat.sendChannelMessage('image', messageContent); + addMsg(messageContent, false, 'image'); + } + + imagePasteHandler.clearImages(); // 清除所有图片预览 + } else if (text) { + // 发送纯文本消息 + // Send text-only message + if (rd.privateChatTargetId) { + // 私聊消息加密并发送 + // Encrypt and send private message + const targetClient = rd.chat.channel[rd.privateChatTargetId]; + if (targetClient && targetClient.shared) { + const clientMessagePayload = { + a: 'm', + t: 'text_private', + d: text + }; + const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared); + const serverRelayPayload = { + a: 'c', + p: encryptedClientMessage, + c: rd.privateChatTargetId + }; + const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared); + rd.chat.sendMessage(encryptedMessageForServer); + 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); } + } + + // 清空输入框并触发 input 事件 + // Clear input and trigger input event + input.innerHTML = ''; // 清空输入框内容 / Clear input field content + if (imagePasteHandler && typeof imagePasteHandler.refreshPlaceholder === 'function') { + imagePasteHandler.refreshPlaceholder(); // 更新 placeholder 状态 + } + autoGrowInput(); // 调整输入框高度 + } + } + + // 为发送按钮添加点击事件 + // Add click event for send button + const sendButton = document.querySelector('.send-message-btn'); + if (sendButton) { + sendButton.addEventListener('click', sendMessage); + } + // 设置发送文件功能 // Setup file sending functionality setupFileSend({ @@ -230,10 +306,10 @@ window.addEventListener('DOMContentLoaded', () => { addMsg(message, false, 'file'); } } - } - } + } } }); + // 判断是否为移动端 // Check if the device is mobile const isMobile = () => window.innerWidth <= 768; diff --git a/client/js/room.js b/client/js/room.js index f5af706..00117c1 100644 --- a/client/js/room.js +++ b/client/js/room.js @@ -244,7 +244,6 @@ export function handleClientMessage(idx, msg) { } let msgType = msg.type || 'text'; - // Handle file messages if (msgType.startsWith('file_')) { // Import handleFileMessage function if needed @@ -252,22 +251,14 @@ export function handleClientMessage(idx, msg) { window.handleFileMessage(msg.data, msgType.includes('_private')); } - // Only add file_start messages to chat history + // Send notification but don't add to messages array + // (util.file.js already handles display via addOtherMsg) if (msgType === 'file_start' || msgType === 'file_start_private') { let realUserName = msg.userName; if (!realUserName && msg.clientId && newRd.userMap[msg.clientId]) { realUserName = newRd.userMap[msg.clientId].userName || newRd.userMap[msg.clientId].username || newRd.userMap[msg.clientId].name } - roomsData[idx].messages.push({ - type: 'other', - text: msg.data, - userName: realUserName, - avatar: realUserName, - msgType: msgType.includes('_private') ? 'file_private' : 'file', - timestamp: Date.now() - }); - const notificationMsgType = msgType.includes('_private') ? 'private file' : 'file'; if (window.notifyMessage) { window.notifyMessage(newRd.roomName, notificationMsgType, `${msg.data.fileName}`, realUserName) @@ -276,30 +267,42 @@ export function handleClientMessage(idx, msg) { if (activeRoomIndex !== idx) { roomsData[idx].unreadCount = (roomsData[idx].unreadCount || 0) + 1; renderRooms(activeRoomIndex) - } else { - renderChatArea() } + // No need to call renderChatArea() since util.file.js handles display } - return; + return; // Don't process file messages further } - - // Handle legacy image detection - if (!msgType.includes('_private') && msg.data && msg.data.startsWith('data:image/')) { - msgType = 'image' + // Handle image messages (both new and legacy formats) + if (msgType === 'image' || msgType === 'image_private') { + // Already has correct type + } else if (!msgType.includes('_private')) { + // Handle legacy image detection + if (msg.data && typeof msg.data === 'string' && msg.data.startsWith('data:image/')) { + msgType = 'image'; + } else if (msg.data && typeof msg.data === 'object' && msg.data.image) { + msgType = 'image'; + } } - let realUserName = msg.userName; if (!realUserName && msg.clientId && newRd.userMap[msg.clientId]) { realUserName = newRd.userMap[msg.clientId].userName || newRd.userMap[msg.clientId].username || newRd.userMap[msg.clientId].name } + + // Add message to messages array for chat history roomsData[idx].messages.push({ type: 'other', text: msg.data, userName: realUserName, avatar: realUserName, - msgType, + msgType: msgType, timestamp: Date.now() }); + + // Add message to chat display using addOtherMsg + if (window.addOtherMsg) { + window.addOtherMsg(msg.data, realUserName, realUserName, false, msgType); + } + const notificationMsgType = msgType.includes('_private') ? `private ${msgType.split('_')[0]}` : msgType; if (window.notifyMessage) { window.notifyMessage(newRd.roomName, notificationMsgType, msg.data, realUserName) @@ -307,8 +310,6 @@ export function handleClientMessage(idx, msg) { if (activeRoomIndex !== idx) { roomsData[idx].unreadCount = (roomsData[idx].unreadCount || 0) + 1; renderRooms(activeRoomIndex) - } else { - renderChatArea() } } diff --git a/client/js/util.image.js b/client/js/util.image.js new file mode 100644 index 0000000..20479ce --- /dev/null +++ b/client/js/util.image.js @@ -0,0 +1,234 @@ +// 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, // 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 with 90% quality + // 导出为 webp,90% 质量 + dataUrl = canvas.toDataURL('image/webp', 0.90); + 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 8MB)', // 图片过大(超过8MB) + }; + return messages[key] || key +} + +// Create image preview element for input +// 为输入框创建图片预览元素 +function createImagePreview(dataUrl) { + const preview = createElement('div', { + class: 'input-image-preview' + }); + + const img = createElement('img', { + src: dataUrl, + class: 'input-image-preview-img' + }); + + const removeBtn = createElement('button', { + class: 'input-image-remove-btn', + type: 'button' + }, '×'); + + preview.appendChild(img); + preview.appendChild(removeBtn); + + return { preview, removeBtn }; +} + +// Setup image paste functionality +// 设置图片粘贴功能 +export function setupImagePaste(inputSelector) { + const input = $(inputSelector); + if (!input) return; + + let currentImageDatas = []; // 支持多张图片 + const imagePreviewContainer = createElement('div', { class: 'image-preview-container' }); + input.parentNode.insertBefore(imagePreviewContainer, input); + + // Function to update placeholder visibility + function updatePlaceholderVisibility() { + const placeholder = input.parentNode.querySelector('.input-field-placeholder'); + if (!placeholder) return; + + const hasText = input.innerText.trim().length > 0; + const hasImages = currentImageDatas.length > 0; + + if (hasText || hasImages) { + placeholder.style.opacity = '0'; + input.classList.remove('is-empty'); // 确保移除 is-empty + } else { + placeholder.style.opacity = '1'; + input.classList.add('is-empty'); // 确保添加 is-empty + } + } + + // Initial check for placeholder + updatePlaceholderVisibility(); + + // Listen to input events to update placeholder + on(input, 'input', updatePlaceholderVisibility); + on(input, 'focus', updatePlaceholderVisibility); + on(input, 'blur', () => { + // Defer blur check slightly to allow click on remove button + setTimeout(updatePlaceholderVisibility, 100); + }); + + on(input, 'paste', function(e) { + if (!e.clipboardData) return; + + let imageProcessed = false; + for (const item of e.clipboardData.items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (!file) continue; + + if (file.size > 8 * 1024 * 1024) { + alert(t('tooLarge')); + continue; + } + + processImage(file, (dataUrl) => { + const { preview, removeBtn } = createImagePreview(dataUrl); + imagePreviewContainer.appendChild(preview); + currentImageDatas.push({ dataUrl, previewElement: preview }); + + on(removeBtn, 'click', () => { + preview.remove(); + currentImageDatas = currentImageDatas.filter(img => img.dataUrl !== dataUrl); + updatePlaceholderVisibility(); + }); + updatePlaceholderVisibility(); + }); + imageProcessed = true; + } + } + if (imageProcessed) { + e.preventDefault(); + } + }); + return { + getCurrentImages: () => currentImageDatas.map(img => img.dataUrl), // 返回所有图片数据 + clearImages: () => { + imagePreviewContainer.innerHTML = ''; // 清空预览容器 + currentImageDatas = []; + updatePlaceholderVisibility(); + }, + refreshPlaceholder: updatePlaceholderVisibility // 暴露 placeholder 更新函数 + }; +} + +// Setup legacy image send functionality (for backward compatibility) +// 设置旧版图片发送功能(向后兼容) +export function setupImageSend({ + inputSelector, + attachBtnSelector, + fileInputSelector, + onSend +}) { + const input = $(inputSelector); + const attachBtn = $(attachBtnSelector); + 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 + } + + processImage(file, (dataUrl) => { + if (typeof onSend === 'function') onSend(dataUrl) + }); + fileInput.value = '' + }) + } + + 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 + } + + processImage(file, (dataUrl) => { + if (typeof onSend === 'function') onSend(dataUrl) + }); + e.preventDefault(); + break + } + } + }) + } +}