添加图片预览和粘贴功能,支持多图和文字混合发送

This commit is contained in:
shuaiplus committed 2025-05-31 16:15:44 +08:00
1 parent b0ffe3a198
commit 62f5b3d3d9
5 files changed
+584 -70

No files matched your search

+126
View File
@@ -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;
}
}
+85 -8
View File
@@ -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 = `<img src="${safeImgSrc}"alt="image"class="bubble-img">`
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 `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}).join('');
if (messageText && imageElements) {
// Mixed content: text + images
contentHtml = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
${imageElements}
</div>`;
} 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 = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
<img src="${safeImgSrc}" alt="image" class="bubble-img">
</div>`;
} else {
// Image only
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else {
// Legacy format: plain image data URL
const safeImgSrc = escapeHTML(text).replace(/javascript:/gi, '');
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} 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 = `<img src="${safeImgSrc}"alt="image"class="bubble-img">`
}); 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 `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}).join('');
if (messageText && imageElements) {
// Mixed content: text + images
contentHtml = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
${imageElements}
</div>`;
} 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 = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
<img src="${safeImgSrc}" alt="image" class="bubble-img">
</div>`;
} else {
// Image only
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else {
// Legacy format: plain image data URL
const safeImgSrc = escapeHTML(msg).replace(/javascript:/gi, '');
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else if (msgType === 'file' || msgType === 'file_private') {
// Handle file messages
contentHtml = renderFileMessage(msg, false); } else {
+116 -40
View File
@@ -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;
+23 -22
View File
@@ -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()
}
}
+234
View File
@@ -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
}
}
})
}
}