拙劣的实现文件分卷压缩传输
This commit is contained in:
1 parent
8fd3731819
commit
608174cabb
9 files changed
+700
-143
No files matched your search
@@ -897,3 +897,132 @@ body {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* File message styles */
|
||||
/* 文件消息样式 */
|
||||
.file-message {
|
||||
max-width: 300px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin: 4px 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-volumes {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.file-progress-container {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #30a8f7, #4fc3f7);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-download-btn {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: #30a8f7;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-download-btn:hover {
|
||||
background: #2196f3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.file-download-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.file-download-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* File message in bubbles */
|
||||
.bubble .file-message {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.bubble.me .file-message {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.bubble.other .file-message {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bubble.private-message .file-message {
|
||||
background: rgba(255, 248, 220, 0.9);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
@@ -332,11 +332,10 @@ class NodeCrypt {
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!this.channel[serverDecrypted.c].username) {
|
||||
} if (!this.channel[serverDecrypted.c].username) {
|
||||
return
|
||||
}
|
||||
if (clientDecrypted.a === 'm' && this.isString(clientDecrypted.t) && this.isString(clientDecrypted.d)) {
|
||||
if (clientDecrypted.a === 'm' && this.isString(clientDecrypted.t) && (this.isString(clientDecrypted.d) || this.isObject(clientDecrypted.d))) {
|
||||
if (this.callbacks.onClientMessage) {
|
||||
try {
|
||||
this.callbacks.onClientMessage({
|
||||
|
||||
+138
-4
@@ -19,6 +19,9 @@ import {
|
||||
addClass,
|
||||
removeClass
|
||||
} from './util.dom.js';
|
||||
import {
|
||||
formatFileSize
|
||||
} from './util.file.js';
|
||||
|
||||
// Render the chat area
|
||||
// 渲染聊天区域
|
||||
@@ -54,11 +57,13 @@ export function addMsg(text, isHistory = false, msgType = 'text', timestamp = nu
|
||||
if (!chatArea) return;
|
||||
const 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 = '';
|
||||
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">`
|
||||
} else if (msgType === 'file' || msgType === 'file_private') {
|
||||
// Handle file messages
|
||||
contentHtml = renderFileMessage(text, true);
|
||||
} else {
|
||||
contentHtml = textToHTML(text)
|
||||
}
|
||||
@@ -95,11 +100,13 @@ export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false,
|
||||
if (!chatArea) return;
|
||||
const bubbleWrap = createElement('div', {
|
||||
class: 'bubble-other-wrap'
|
||||
});
|
||||
let contentHtml = '';
|
||||
}); let contentHtml = '';
|
||||
if (msgType === 'image' || msgType === 'image_private') {
|
||||
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 {
|
||||
contentHtml = textToHTML(msg)
|
||||
}
|
||||
@@ -254,6 +261,133 @@ export function showImageModal(src) {
|
||||
updateTransform()
|
||||
}
|
||||
|
||||
// Render file message content
|
||||
// 渲染文件消息内容
|
||||
function renderFileMessage(fileData, isSender) {
|
||||
const {
|
||||
fileId,
|
||||
fileName,
|
||||
originalSize,
|
||||
totalVolumes
|
||||
} = fileData;
|
||||
const safeFileName = escapeHTML(fileName);
|
||||
const formattedSize = formatFileSize(originalSize);
|
||||
|
||||
// Determine file icon based on extension
|
||||
// 根据扩展名确定文件图标
|
||||
const fileExtension = fileName.split('.').pop().toLowerCase();
|
||||
let fileIcon = '📄'; // Default document icon 默认文档图标
|
||||
|
||||
const iconMap = {
|
||||
// Images
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'png': '🖼️',
|
||||
'gif': '🖼️',
|
||||
'bmp': '🖼️',
|
||||
'svg': '🖼️',
|
||||
'webp': '🖼️',
|
||||
// Videos
|
||||
'mp4': '🎬',
|
||||
'avi': '🎬',
|
||||
'mov': '🎬',
|
||||
'mkv': '🎬',
|
||||
'wmv': '🎬',
|
||||
'flv': '🎬',
|
||||
'webm': '🎬',
|
||||
// Audio
|
||||
'mp3': '🎵',
|
||||
'wav': '🎵',
|
||||
'flac': '🎵',
|
||||
'aac': '🎵',
|
||||
'ogg': '🎵',
|
||||
'm4a': '🎵',
|
||||
// Documents
|
||||
'pdf': '📕',
|
||||
'doc': '📄',
|
||||
'docx': '📄',
|
||||
'txt': '📄',
|
||||
'rtf': '📄',
|
||||
'xls': '📊',
|
||||
'xlsx': '📊',
|
||||
'csv': '📊',
|
||||
'ppt': '📈',
|
||||
'pptx': '📈',
|
||||
// Archives
|
||||
'zip': '🗜️',
|
||||
'rar': '🗜️',
|
||||
'7z': '🗜️',
|
||||
'tar': '🗜️',
|
||||
'gz': '🗜️',
|
||||
// Code
|
||||
'js': '💻',
|
||||
'html': '💻',
|
||||
'css': '💻',
|
||||
'py': '💻',
|
||||
'java': '💻',
|
||||
'cpp': '💻',
|
||||
'c': '💻',
|
||||
'php': '💻',
|
||||
'rb': '💻',
|
||||
'go': '💻',
|
||||
'ts': '💻',
|
||||
'json': '💻',
|
||||
'xml': '💻'
|
||||
};
|
||||
if (iconMap[fileExtension]) {
|
||||
fileIcon = iconMap[fileExtension];
|
||||
}
|
||||
|
||||
// Check actual file transfer status
|
||||
const transfer = window.fileTransfers ? window.fileTransfers.get(fileId) : null;
|
||||
let statusText = 'Waiting...';
|
||||
let progressWidth = '0%';
|
||||
let downloadBtnStyle = 'display: none;';
|
||||
|
||||
if (transfer) {
|
||||
if (transfer.status === 'sending') {
|
||||
const progress = (transfer.sentVolumes / transfer.totalVolumes) * 100;
|
||||
progressWidth = `${progress}%`;
|
||||
statusText = `Sending ${transfer.sentVolumes}/${transfer.totalVolumes}`;
|
||||
} else if (transfer.status === 'receiving') {
|
||||
const progress = (transfer.receivedVolumes.size / transfer.totalVolumes) * 100;
|
||||
progressWidth = `${progress}%`;
|
||||
statusText = `Receiving ${transfer.receivedVolumes.size}/${transfer.totalVolumes}`;
|
||||
} else if (transfer.status === 'completed') {
|
||||
progressWidth = '100%';
|
||||
statusText = '✓ Completed';
|
||||
downloadBtnStyle = isSender ? 'display: none;' : 'display: inline-block;';
|
||||
}
|
||||
} else if (isSender) {
|
||||
statusText = 'Sent';
|
||||
progressWidth = '100%';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="file-message" data-file-id="${fileId}">
|
||||
<div class="file-info">
|
||||
<div class="file-icon">${fileIcon}</div>
|
||||
<div class="file-details">
|
||||
<div class="file-name" title="${safeFileName}">${safeFileName}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">${formattedSize}</span>
|
||||
<span class="file-volumes">${totalVolumes} volumes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-progress-container">
|
||||
<div class="file-progress-bar">
|
||||
<div class="file-progress" style="width: ${progressWidth}"></div>
|
||||
</div>
|
||||
<div class="file-status">${statusText}</div>
|
||||
</div>
|
||||
<button class="file-download-btn" style="${downloadBtnStyle}" onclick="window.downloadFile('${fileId}')">
|
||||
📥 Download
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Automatically adjust the height of the input area
|
||||
// 自动调整输入区域的高度
|
||||
export function autoGrowInput() {
|
||||
|
||||
+30
-19
@@ -2,11 +2,13 @@
|
||||
// Import the NodeCrypt module (used for encryption)
|
||||
import './NodeCrypt.js';
|
||||
|
||||
// 从 util.image.js 中导入设置图片发送的函数
|
||||
// Import setupImageSend function from util.image.js
|
||||
// 从 util.file.js 中导入设置文件发送的函数
|
||||
// Import setupFileSend function from util.file.js
|
||||
import {
|
||||
setupImageSend
|
||||
} from './util.image.js';
|
||||
setupFileSend,
|
||||
handleFileMessage,
|
||||
downloadFile
|
||||
} from './util.file.js';
|
||||
|
||||
// 从 util.emoji.js 中导入设置表情选择器的函数
|
||||
// Import setupEmojiPicker function from util.emoji.js
|
||||
@@ -75,6 +77,8 @@ window.addSystemMsg = addSystemMsg;
|
||||
window.joinRoom = joinRoom;
|
||||
window.notifyMessage = notifyMessage;
|
||||
window.setupEmojiPicker = setupEmojiPicker;
|
||||
window.handleFileMessage = handleFileMessage;
|
||||
window.downloadFile = downloadFile;
|
||||
|
||||
// 当 DOM 内容加载完成后执行初始化逻辑
|
||||
// Run initialization logic when the DOM content is fully loaded
|
||||
@@ -176,25 +180,24 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 设置发送图片功能
|
||||
// Setup image sending functionality
|
||||
setupImageSend({
|
||||
// 设置发送文件功能
|
||||
// Setup file sending functionality
|
||||
setupFileSend({
|
||||
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) => {
|
||||
onSend: (message) => {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
if (rd && rd.chat) {
|
||||
if (rd.privateChatTargetId) {
|
||||
// 私聊图片加密并发送
|
||||
// Encrypt and send private image
|
||||
// 私聊文件加密并发送
|
||||
// Encrypt and send private file message
|
||||
const targetClient = rd.chat.channel[rd.privateChatTargetId];
|
||||
if (targetClient && targetClient.shared) {
|
||||
const clientMessagePayload = {
|
||||
a: 'm',
|
||||
t: 'image_private',
|
||||
d: dataUrl
|
||||
t: message.type + '_private',
|
||||
d: message
|
||||
};
|
||||
const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared);
|
||||
const serverRelayPayload = {
|
||||
@@ -204,15 +207,23 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared);
|
||||
rd.chat.sendMessage(encryptedMessageForServer);
|
||||
addMsg(dataUrl, false, 'image_private');
|
||||
|
||||
// 添加到自己的聊天记录
|
||||
if (message.type === 'file_start') {
|
||||
addMsg(message, false, 'file_private');
|
||||
}
|
||||
} else {
|
||||
addSystemMsg(`Cannot send private image to ${rd.privateChatTargetName}.User might not be fully connected.`)
|
||||
addSystemMsg(`Cannot send private file to ${rd.privateChatTargetName}. User might not be fully connected.`)
|
||||
}
|
||||
} else {
|
||||
// 公共频道发送图片
|
||||
// Send image to public channel
|
||||
rd.chat.sendChannelMessage('image', dataUrl);
|
||||
addMsg(dataUrl, false, 'image');
|
||||
// 公共频道文件发送
|
||||
// Send file to public channel
|
||||
rd.chat.sendChannelMessage(message.type, message);
|
||||
|
||||
// 添加到自己的聊天记录
|
||||
if (message.type === 'file_start') {
|
||||
addMsg(message, false, 'file');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,10 +242,52 @@ export function handleClientMessage(idx, msg) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let msgType = msg.type || 'text';
|
||||
|
||||
// Handle file messages
|
||||
if (msgType.startsWith('file_')) {
|
||||
// Import handleFileMessage function if needed
|
||||
if (window.handleFileMessage) {
|
||||
window.handleFileMessage(msg.data, msgType.includes('_private'));
|
||||
}
|
||||
|
||||
// Only add file_start messages to chat history
|
||||
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)
|
||||
}
|
||||
|
||||
if (activeRoomIndex !== idx) {
|
||||
roomsData[idx].unreadCount = (roomsData[idx].unreadCount || 0) + 1;
|
||||
renderRooms(activeRoomIndex)
|
||||
} else {
|
||||
renderChatArea()
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle legacy image detection
|
||||
if (!msgType.includes('_private') && msg.data && msg.data.startsWith('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
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// Import necessary modules
|
||||
// 导入必要的模块
|
||||
import {
|
||||
$,
|
||||
$id,
|
||||
createElement,
|
||||
on,
|
||||
addClass,
|
||||
removeClass
|
||||
} from './util.dom.js';
|
||||
import { deflate } from 'fflate';
|
||||
|
||||
// File transfer state management
|
||||
// 文件传输状态管理
|
||||
window.fileTransfers = new Map();
|
||||
|
||||
// Generate unique file ID
|
||||
// 生成唯一文件ID
|
||||
function generateFileId() {
|
||||
return 'file_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// Compress file into volumes
|
||||
// 将文件压缩为分卷
|
||||
async function compressFileToVolumes(file, volumeSize = 128 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const arrayBuffer = new Uint8Array(e.target.result);
|
||||
|
||||
// Compress the file data
|
||||
deflate(arrayBuffer, { level: 6 }, (err, compressed) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
// Split into volumes
|
||||
const volumes = [];
|
||||
for (let i = 0; i < compressed.length; i += volumeSize) {
|
||||
const volume = compressed.slice(i, i + volumeSize);
|
||||
// Convert to base64 without using spread operator to avoid stack overflow
|
||||
let binaryString = '';
|
||||
for (let j = 0; j < volume.length; j++) {
|
||||
binaryString += String.fromCharCode(volume[j]);
|
||||
}
|
||||
volumes.push(btoa(binaryString));
|
||||
}
|
||||
|
||||
resolve({
|
||||
volumes,
|
||||
originalSize: file.size,
|
||||
compressedSize: compressed.length
|
||||
});
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Decompress volumes back to file
|
||||
// 将分卷解压回文件
|
||||
async function decompressVolumesToFile(volumes, fileName) { try {
|
||||
// Combine all volumes
|
||||
const combinedData = volumes.map(volume => {
|
||||
const binaryString = atob(volume);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
});
|
||||
|
||||
const totalLength = combinedData.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const compressed = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const data of combinedData) {
|
||||
compressed.set(data, offset);
|
||||
offset += data.length;
|
||||
}
|
||||
|
||||
// Decompress
|
||||
return new Promise((resolve, reject) => {
|
||||
import('fflate').then(({ inflate }) => {
|
||||
inflate(compressed, (err, decompressed) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([decompressed]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Decompression error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Setup file sending functionality
|
||||
// 设置文件发送功能
|
||||
export function setupFileSend({
|
||||
inputSelector,
|
||||
attachBtnSelector,
|
||||
fileInputSelector,
|
||||
onSend
|
||||
}) {
|
||||
const attachBtn = document.querySelector(attachBtnSelector);
|
||||
const fileInput = document.querySelector(fileInputSelector);
|
||||
|
||||
if (attachBtn && fileInput) {
|
||||
// 移除文件类型限制,接受所有文件
|
||||
// Remove file type restriction, accept all files
|
||||
fileInput.accept = '*/*';
|
||||
|
||||
// 点击附件按钮触发文件选择
|
||||
// Click attach button to trigger file selection
|
||||
attachBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// 处理文件选择
|
||||
// Handle file selection
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
const fileId = generateFileId();
|
||||
|
||||
try {
|
||||
// 显示压缩进度
|
||||
window.addSystemMsg(`Compressing ${file.name}...`);
|
||||
|
||||
// 压缩文件为分卷
|
||||
const { volumes, originalSize, compressedSize } = await compressFileToVolumes(file);
|
||||
|
||||
// 创建文件传输状态
|
||||
const fileTransfer = {
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
originalSize,
|
||||
compressedSize,
|
||||
totalVolumes: volumes.length,
|
||||
sentVolumes: 0,
|
||||
status: 'sending'
|
||||
};
|
||||
|
||||
window.fileTransfers.set(fileId, fileTransfer);
|
||||
|
||||
// 发送文件开始消息
|
||||
onSend({
|
||||
type: 'file_start',
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
originalSize,
|
||||
compressedSize,
|
||||
totalVolumes: volumes.length
|
||||
});
|
||||
|
||||
// 逐个发送分卷
|
||||
for (let i = 0; i < volumes.length; i++) {
|
||||
onSend({
|
||||
type: 'file_volume',
|
||||
fileId,
|
||||
volumeIndex: i,
|
||||
volumeData: volumes[i],
|
||||
isLast: i === volumes.length - 1
|
||||
});
|
||||
|
||||
// 更新发送进度
|
||||
fileTransfer.sentVolumes = i + 1;
|
||||
updateFileProgress(fileId);
|
||||
|
||||
// 添加小延迟避免消息过快
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// 发送完成消息
|
||||
onSend({
|
||||
type: 'file_complete',
|
||||
fileId
|
||||
});
|
||||
|
||||
fileTransfer.status = 'completed';
|
||||
updateFileProgress(fileId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('File compression error:', error);
|
||||
window.addSystemMsg(`Failed to compress ${file.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空文件输入
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update file progress in chat
|
||||
// 更新聊天中的文件进度
|
||||
function updateFileProgress(fileId) {
|
||||
const transfer = window.fileTransfers.get(fileId);
|
||||
if (!transfer) return;
|
||||
|
||||
const elements = document.querySelectorAll(`[data-file-id="${fileId}"]`);
|
||||
elements.forEach(element => {
|
||||
const progressBar = element.querySelector('.file-progress');
|
||||
const statusText = element.querySelector('.file-status');
|
||||
const downloadBtn = element.querySelector('.file-download-btn');
|
||||
|
||||
if (transfer.status === 'sending') {
|
||||
const progress = (transfer.sentVolumes / transfer.totalVolumes) * 100;
|
||||
if (progressBar) progressBar.style.width = `${progress}%`;
|
||||
if (statusText) statusText.textContent = `Sending ${transfer.sentVolumes}/${transfer.totalVolumes}`;
|
||||
} else if (transfer.status === 'receiving') {
|
||||
const progress = (transfer.receivedVolumes.size / transfer.totalVolumes) * 100;
|
||||
if (progressBar) progressBar.style.width = `${progress}%`;
|
||||
if (statusText) statusText.textContent = `Receiving ${transfer.receivedVolumes.size}/${transfer.totalVolumes}`;
|
||||
} else if (transfer.status === 'completed') {
|
||||
if (progressBar) progressBar.style.width = '100%';
|
||||
if (statusText) statusText.textContent = '✓ Completed';
|
||||
if (downloadBtn) {
|
||||
downloadBtn.style.display = 'inline-block';
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle incoming file messages
|
||||
// 处理接收到的文件消息
|
||||
export function handleFileMessage(message, isPrivate = false) {
|
||||
const { type, fileId } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'file_start':
|
||||
handleFileStart(message, isPrivate);
|
||||
break;
|
||||
case 'file_volume':
|
||||
handleFileVolume(message);
|
||||
break;
|
||||
case 'file_complete':
|
||||
handleFileComplete(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file start message
|
||||
// 处理文件开始消息
|
||||
function handleFileStart(message, isPrivate) {
|
||||
const { fileId, fileName, originalSize, compressedSize, totalVolumes } = message;
|
||||
|
||||
const fileTransfer = {
|
||||
fileId,
|
||||
fileName,
|
||||
originalSize,
|
||||
compressedSize,
|
||||
totalVolumes,
|
||||
receivedVolumes: new Set(),
|
||||
volumeData: new Array(totalVolumes),
|
||||
status: 'receiving'
|
||||
};
|
||||
|
||||
window.fileTransfers.set(fileId, fileTransfer);
|
||||
|
||||
// 添加文件消息到聊天
|
||||
if (window.addOtherMsg) {
|
||||
window.addOtherMsg({
|
||||
type: 'file',
|
||||
fileId,
|
||||
fileName,
|
||||
originalSize,
|
||||
totalVolumes
|
||||
}, '', '', false, isPrivate ? 'file_private' : 'file');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file volume message
|
||||
// 处理文件分卷消息
|
||||
function handleFileVolume(message) {
|
||||
const { fileId, volumeIndex, volumeData } = message;
|
||||
const transfer = window.fileTransfers.get(fileId);
|
||||
|
||||
if (!transfer) return;
|
||||
|
||||
transfer.receivedVolumes.add(volumeIndex);
|
||||
transfer.volumeData[volumeIndex] = volumeData;
|
||||
|
||||
updateFileProgress(fileId);
|
||||
}
|
||||
|
||||
// Handle file complete message
|
||||
// 处理文件完成消息
|
||||
function handleFileComplete(message) {
|
||||
const { fileId } = message;
|
||||
const transfer = window.fileTransfers.get(fileId);
|
||||
|
||||
if (!transfer) return;
|
||||
|
||||
// 检查是否所有分卷都已接收
|
||||
if (transfer.receivedVolumes.size === transfer.totalVolumes) {
|
||||
transfer.status = 'completed';
|
||||
updateFileProgress(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// Download file from volumes
|
||||
// 从分卷下载文件
|
||||
export async function downloadFile(fileId) {
|
||||
const transfer = window.fileTransfers.get(fileId);
|
||||
if (!transfer || transfer.status !== 'completed') return;
|
||||
|
||||
try {
|
||||
await decompressVolumesToFile(transfer.volumeData, transfer.fileName);
|
||||
window.addSystemMsg(`Downloaded ${transfer.fileName}`);
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
window.addSystemMsg(`Failed to download ${transfer.fileName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size
|
||||
// 格式化文件大小
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Legacy image send function for backward compatibility
|
||||
// 向后兼容的图片发送函数
|
||||
export function setupImageSend(config) {
|
||||
setupFileSend(config);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// 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
|
||||
// 导出为 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)', // 图片过大(超过5MB)
|
||||
};
|
||||
return messages[key] || key
|
||||
}
|
||||
// Setup image send logic for UI
|
||||
// 设置图片发送相关 UI 逻辑
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -16,6 +16,7 @@
|
||||
"dompurify": "^3.2.6",
|
||||
"elliptic": "^6.6.1",
|
||||
"emoji-picker-element": "^1.26.3",
|
||||
"fflate": "^0.8.2",
|
||||
"js-chacha20": "^1.1.0",
|
||||
"js-sha256": "^0.11.0"
|
||||
},
|
||||
@@ -2083,6 +2084,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"dompurify": "^3.2.6",
|
||||
"elliptic": "^6.6.1",
|
||||
"emoji-picker-element": "^1.26.3",
|
||||
"fflate": "^0.8.2",
|
||||
"js-chacha20": "^1.1.0",
|
||||
"js-sha256": "^0.11.0"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user