|
11 | 11 | t |
12 | 12 | } = window.FileShareUtils; |
13 | 13 |
|
| 14 | + const MAX_PATHLESS_PASTED_ADMIN_FILE_BYTES = 50 * 1024 * 1024; |
| 15 | + |
14 | 16 | const state = { |
15 | 17 | role: 'client', |
16 | 18 | items: [], |
|
24 | 26 | selectedAddressUrl: '', |
25 | 27 | statusTimer: null, |
26 | 28 | toastId: 0, |
| 29 | + reconnectToastTimer: null, |
27 | 30 | adminFileSharing: false, |
28 | 31 | adminDragDropBound: false, |
29 | 32 | adminDropFeedbackTimer: null |
30 | 33 | }; |
31 | 34 |
|
| 35 | + function isEditableTarget(target) { |
| 36 | + if (!target) return false; |
| 37 | + return Boolean(target.closest?.('input, textarea, select, [contenteditable="true"]')); |
| 38 | + } |
| 39 | + |
32 | 40 | function showToast(message, type = 'info', duration = 3200) { |
33 | 41 | if (!message) return; |
34 | 42 |
|
|
267 | 275 | state.items = []; |
268 | 276 | state.downloadStats = {}; |
269 | 277 | if (state.events) { |
| 278 | + state.events.__manuallyClosed = true; |
270 | 279 | state.events.close(); |
271 | 280 | state.events = null; |
272 | 281 | } |
273 | 282 | if (state.downloadEvents) { |
| 283 | + state.downloadEvents.__manuallyClosed = true; |
274 | 284 | state.downloadEvents.close(); |
275 | 285 | state.downloadEvents = null; |
276 | 286 | } |
|
391 | 401 |
|
392 | 402 | function connectEvents() { |
393 | 403 | const status = $('status'); |
394 | | - if (state.events) state.events.close(); |
| 404 | + if (state.events) { |
| 405 | + state.events.__manuallyClosed = true; |
| 406 | + state.events.close(); |
| 407 | + } |
395 | 408 | // 共享列表由服务端 SSE 推送,管理端和客户端保持同一份实时视图。 |
396 | 409 | const events = new EventSource(`${state.apiBase}/api/events`); |
397 | 410 | state.events = events; |
398 | | - events.onopen = () => { status.textContent = t('synced'); }; |
| 411 | + events.__opened = false; |
| 412 | + events.__manuallyClosed = false; |
| 413 | + events.onopen = () => { |
| 414 | + events.__opened = true; |
| 415 | + status.textContent = t('synced'); |
| 416 | + }; |
399 | 417 | events.onerror = () => { |
| 418 | + if (events.__manuallyClosed || state.events !== events || (state.role === 'admin' && !state.serverRunning)) return; |
400 | 419 | status.textContent = t('reconnecting'); |
401 | | - showToast(t('reconnectToast'), 'warning', 2200); |
| 420 | + if (events.__opened && !state.reconnectToastTimer) { |
| 421 | + showToast(t('reconnectToast'), 'warning', 2200); |
| 422 | + state.reconnectToastTimer = window.setTimeout(() => { |
| 423 | + state.reconnectToastTimer = null; |
| 424 | + }, 3000); |
| 425 | + } |
402 | 426 | }; |
403 | 427 | events.onmessage = (event) => { |
404 | 428 | state.items = JSON.parse(event.data); |
|
410 | 434 | } |
411 | 435 |
|
412 | 436 | function connectDownloadEvents() { |
413 | | - if (state.downloadEvents) state.downloadEvents.close(); |
| 437 | + if (state.downloadEvents) { |
| 438 | + state.downloadEvents.__manuallyClosed = true; |
| 439 | + state.downloadEvents.close(); |
| 440 | + } |
414 | 441 | const events = new EventSource(`${state.apiBase}/api/download-events`); |
415 | 442 | state.downloadEvents = events; |
416 | 443 | events.onerror = () => { |
| 444 | + if (events.__manuallyClosed || state.downloadEvents !== events) return; |
417 | 445 | state.downloadStats = {}; |
418 | 446 | updateDownloadStatuses(); |
419 | 447 | }; |
|
544 | 572 | showToast(message, 'success', 10000); |
545 | 573 | } |
546 | 574 |
|
| 575 | + async function publishTextContent(content) { |
| 576 | + const text = String(content || '').trim(); |
| 577 | + if (!text) return; |
| 578 | + |
| 579 | + await request('/api/text', { |
| 580 | + method: 'POST', |
| 581 | + headers: { 'content-type': 'application/json' }, |
| 582 | + body: JSON.stringify({ content: text, source: state.role }) |
| 583 | + }); |
| 584 | + } |
| 585 | + |
| 586 | + async function uploadFiles(files, options = {}) { |
| 587 | + const pickedFiles = Array.from(files || []).filter((file) => file?.size > 0); |
| 588 | + if (!pickedFiles.length) return []; |
| 589 | + |
| 590 | + options.onStart?.(pickedFiles); |
| 591 | + try { |
| 592 | + const data = new FormData(); |
| 593 | + data.append('source', state.role); |
| 594 | + for (const file of pickedFiles) { |
| 595 | + data.append('file', file, file.name || 'file'); |
| 596 | + } |
| 597 | + const result = await request('/api/upload', { method: 'POST', body: data }); |
| 598 | + options.onSuccess?.(pickedFiles, result); |
| 599 | + return result; |
| 600 | + } catch (error) { |
| 601 | + options.onError?.(error); |
| 602 | + throw error; |
| 603 | + } finally { |
| 604 | + options.onDone?.(); |
| 605 | + } |
| 606 | + } |
| 607 | + |
| 608 | + function fileToBase64(file) { |
| 609 | + return new Promise((resolve, reject) => { |
| 610 | + const reader = new FileReader(); |
| 611 | + reader.addEventListener('load', () => { |
| 612 | + const result = String(reader.result || ''); |
| 613 | + resolve(result.includes(',') ? result.split(',').pop() : result); |
| 614 | + }); |
| 615 | + reader.addEventListener('error', () => reject(reader.error || new Error(t('uploadFailed')))); |
| 616 | + reader.readAsDataURL(file); |
| 617 | + }); |
| 618 | + } |
| 619 | + |
| 620 | + function localPathFromFile(file) { |
| 621 | + const candidates = [ |
| 622 | + file?.path, |
| 623 | + file?.filepath, |
| 624 | + file?.mozFullPath, |
| 625 | + file?.webkitRelativePath |
| 626 | + ]; |
| 627 | + return candidates.find((value) => ( |
| 628 | + typeof value === 'string' |
| 629 | + && (value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')) |
| 630 | + )) || ''; |
| 631 | + } |
| 632 | + |
| 633 | + function hasDirectoryItems(items) { |
| 634 | + return Array.from(items || []).some((item) => { |
| 635 | + const entry = item?.webkitGetAsEntry?.(); |
| 636 | + return Boolean(entry?.isDirectory); |
| 637 | + }); |
| 638 | + } |
| 639 | + |
| 640 | + async function sharePastedAdminFiles(files, options = {}) { |
| 641 | + const pickedFiles = Array.from(files || []).filter((file) => file?.size > 0); |
| 642 | + if (!pickedFiles.length) return 0; |
| 643 | + |
| 644 | + options.onStart?.(pickedFiles); |
| 645 | + try { |
| 646 | + const localPaths = pickedFiles.map(localPathFromFile).filter(Boolean); |
| 647 | + const filesWithoutPath = pickedFiles.filter((file) => !localPathFromFile(file)); |
| 648 | + const oversizedFile = filesWithoutPath.find((file) => file.size > MAX_PATHLESS_PASTED_ADMIN_FILE_BYTES); |
| 649 | + if (oversizedFile) { |
| 650 | + throw new Error(t('pasteFileTooLarge', { size: '50MB' })); |
| 651 | + } |
| 652 | + let count = 0; |
| 653 | + |
| 654 | + if (localPaths.length) { |
| 655 | + await addAdminLocalFiles(localPaths); |
| 656 | + count += localPaths.length; |
| 657 | + } |
| 658 | + |
| 659 | + const payload = []; |
| 660 | + for (const file of filesWithoutPath) { |
| 661 | + payload.push({ |
| 662 | + name: file.name || 'file', |
| 663 | + data: await fileToBase64(file) |
| 664 | + }); |
| 665 | + } |
| 666 | + |
| 667 | + if (payload.length) { |
| 668 | + count += await window.__TAURI__.core.invoke('share_pasted_admin_files', { pathlessFiles: payload }); |
| 669 | + } |
| 670 | + options.onSuccess?.(pickedFiles, count); |
| 671 | + return count; |
| 672 | + } catch (error) { |
| 673 | + options.onError?.(error); |
| 674 | + throw error; |
| 675 | + } finally { |
| 676 | + options.onDone?.(); |
| 677 | + } |
| 678 | + } |
| 679 | + |
547 | 680 | async function bindAdminFileDrop() { |
548 | 681 | if (state.role !== 'admin' || state.adminDragDropBound) return; |
549 | 682 |
|
|
630 | 763 | }); |
631 | 764 | } |
632 | 765 |
|
| 766 | + function bindPasteSharing() { |
| 767 | + document.addEventListener('paste', (event) => { |
| 768 | + const clipboard = event.clipboardData; |
| 769 | + if (!clipboard) return; |
| 770 | + |
| 771 | + if (hasDirectoryItems(clipboard.items)) { |
| 772 | + event.preventDefault(); |
| 773 | + showToast(t('unsupportedShareType'), 'warning'); |
| 774 | + return; |
| 775 | + } |
| 776 | + |
| 777 | + const files = Array.from(clipboard.files || []).filter((file) => file.size > 0); |
| 778 | + if (files.length) { |
| 779 | + event.preventDefault(); |
| 780 | + const adminPaste = state.role === 'admin' && window.__TAURI__?.core?.invoke; |
| 781 | + const shareFiles = adminPaste ? sharePastedAdminFiles : uploadFiles; |
| 782 | + |
| 783 | + if (adminPaste) setAdminDropPresentation('pending'); |
| 784 | + shareFiles(files, { |
| 785 | + onStart: () => showToast(t('pastingFiles', { count: files.length })), |
| 786 | + onSuccess: (_files, count) => { |
| 787 | + const sharedCount = Number(count) || files.length; |
| 788 | + if (adminPaste) flashAdminDropSuccess(sharedCount); |
| 789 | + showToast(t('pasteFileDone', { count: sharedCount }), 'success'); |
| 790 | + }, |
| 791 | + onError: (error) => { |
| 792 | + if (adminPaste) setAdminDropPresentation('idle'); |
| 793 | + showToast(error?.message || String(error) || t('uploadFailed'), 'error'); |
| 794 | + } |
| 795 | + }).catch(() => {}); |
| 796 | + return; |
| 797 | + } |
| 798 | + |
| 799 | + if (Array.from(clipboard.items || []).some((item) => item.kind === 'file')) { |
| 800 | + event.preventDefault(); |
| 801 | + showToast(t('unsupportedShareType'), 'warning'); |
| 802 | + return; |
| 803 | + } |
| 804 | + |
| 805 | + if (isEditableTarget(event.target)) return; |
| 806 | + |
| 807 | + const text = clipboard.getData('text/plain'); |
| 808 | + if (!text.trim()) return; |
| 809 | + |
| 810 | + event.preventDefault(); |
| 811 | + publishTextContent(text) |
| 812 | + .then(() => showToast(t('pasteTextDone'), 'success')) |
| 813 | + .catch((error) => showToast(error?.message || String(error), 'error')); |
| 814 | + }); |
| 815 | + } |
| 816 | + |
633 | 817 | async function bindTauriEvents() { |
634 | 818 | const events = window.__TAURI__?.event; |
635 | 819 | if (!events?.listen) return; |
|
700 | 884 | event.preventDefault(); |
701 | 885 | const content = $('textContent').value.trim(); |
702 | 886 | if (!content) return; |
703 | | - await request('/api/text', { |
704 | | - method: 'POST', |
705 | | - headers: { 'content-type': 'application/json' }, |
706 | | - body: JSON.stringify({ content, source: state.role }) |
707 | | - }); |
| 887 | + await publishTextContent(content); |
708 | 888 | event.target.reset(); |
709 | 889 | event.target.closest('dialog')?.close(); |
710 | 890 | }); |
|
737 | 917 | dropZone.classList.add('uploading'); |
738 | 918 | } |
739 | 919 | try { |
740 | | - const data = new FormData(); |
741 | | - data.append('source', state.role); |
742 | | - for (const file of files) { |
743 | | - data.append('file', file, file.name); |
744 | | - } |
745 | | - await request('/api/upload', { method: 'POST', body: data }); |
| 920 | + await uploadFiles(files); |
746 | 921 | fileForm.reset(); |
747 | 922 | updateFileHint(t('uploadDone')); |
748 | 923 | window.setTimeout(() => updateFileHint(), 900); |
|
772 | 947 | ['dragenter', 'dragover'].forEach((name) => { |
773 | 948 | dropZone.addEventListener(name, (event) => { |
774 | 949 | event.preventDefault(); |
| 950 | + if (hasDirectoryItems(event.dataTransfer?.items)) { |
| 951 | + dropZone.classList.remove('dragging'); |
| 952 | + return; |
| 953 | + } |
775 | 954 | dropZone.classList.add('dragging'); |
776 | 955 | }); |
777 | 956 | }); |
|
782 | 961 | }); |
783 | 962 | }); |
784 | 963 | dropZone.addEventListener('drop', (event) => { |
| 964 | + if (hasDirectoryItems(event.dataTransfer?.items)) { |
| 965 | + showToast(t('unsupportedShareType'), 'warning'); |
| 966 | + return; |
| 967 | + } |
785 | 968 | if (event.dataTransfer?.files?.length) { |
786 | 969 | fileInput.files = event.dataTransfer.files; |
787 | 970 | uploadSelectedFiles().catch((error) => showToast(error.message, 'error')); |
|
878 | 1061 | state.apiBase = options.apiBase || ''; |
879 | 1062 | bindQrPreview(); |
880 | 1063 | bindForms(); |
| 1064 | + bindPasteSharing(); |
881 | 1065 | if (state.role === 'admin' && state.isTauri) { |
882 | 1066 | bindServerControls(); |
883 | 1067 | bindTauriEvents(); |
|
0 commit comments