Skip to content

Commit 8531fda

Browse files
committed
feat: add paste sharing
1 parent aef7552 commit 8531fda

11 files changed

Lines changed: 354 additions & 30 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fileshare",
3-
"version": "1.0.8",
3+
"version": "1.0.9",
44
"private": true,
55
"license": "MIT",
66
"author": "Trifolium Wang <trifolium.wang@gmail.com>",

public/app-core.js

Lines changed: 199 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
t
1212
} = window.FileShareUtils;
1313

14+
const MAX_PATHLESS_PASTED_ADMIN_FILE_BYTES = 50 * 1024 * 1024;
15+
1416
const state = {
1517
role: 'client',
1618
items: [],
@@ -24,11 +26,17 @@
2426
selectedAddressUrl: '',
2527
statusTimer: null,
2628
toastId: 0,
29+
reconnectToastTimer: null,
2730
adminFileSharing: false,
2831
adminDragDropBound: false,
2932
adminDropFeedbackTimer: null
3033
};
3134

35+
function isEditableTarget(target) {
36+
if (!target) return false;
37+
return Boolean(target.closest?.('input, textarea, select, [contenteditable="true"]'));
38+
}
39+
3240
function showToast(message, type = 'info', duration = 3200) {
3341
if (!message) return;
3442

@@ -267,10 +275,12 @@
267275
state.items = [];
268276
state.downloadStats = {};
269277
if (state.events) {
278+
state.events.__manuallyClosed = true;
270279
state.events.close();
271280
state.events = null;
272281
}
273282
if (state.downloadEvents) {
283+
state.downloadEvents.__manuallyClosed = true;
274284
state.downloadEvents.close();
275285
state.downloadEvents = null;
276286
}
@@ -391,14 +401,28 @@
391401

392402
function connectEvents() {
393403
const status = $('status');
394-
if (state.events) state.events.close();
404+
if (state.events) {
405+
state.events.__manuallyClosed = true;
406+
state.events.close();
407+
}
395408
// 共享列表由服务端 SSE 推送,管理端和客户端保持同一份实时视图。
396409
const events = new EventSource(`${state.apiBase}/api/events`);
397410
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+
};
399417
events.onerror = () => {
418+
if (events.__manuallyClosed || state.events !== events || (state.role === 'admin' && !state.serverRunning)) return;
400419
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+
}
402426
};
403427
events.onmessage = (event) => {
404428
state.items = JSON.parse(event.data);
@@ -410,10 +434,14 @@
410434
}
411435

412436
function connectDownloadEvents() {
413-
if (state.downloadEvents) state.downloadEvents.close();
437+
if (state.downloadEvents) {
438+
state.downloadEvents.__manuallyClosed = true;
439+
state.downloadEvents.close();
440+
}
414441
const events = new EventSource(`${state.apiBase}/api/download-events`);
415442
state.downloadEvents = events;
416443
events.onerror = () => {
444+
if (events.__manuallyClosed || state.downloadEvents !== events) return;
417445
state.downloadStats = {};
418446
updateDownloadStatuses();
419447
};
@@ -544,6 +572,111 @@
544572
showToast(message, 'success', 10000);
545573
}
546574

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+
547680
async function bindAdminFileDrop() {
548681
if (state.role !== 'admin' || state.adminDragDropBound) return;
549682

@@ -630,6 +763,57 @@
630763
});
631764
}
632765

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+
633817
async function bindTauriEvents() {
634818
const events = window.__TAURI__?.event;
635819
if (!events?.listen) return;
@@ -700,11 +884,7 @@
700884
event.preventDefault();
701885
const content = $('textContent').value.trim();
702886
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);
708888
event.target.reset();
709889
event.target.closest('dialog')?.close();
710890
});
@@ -737,12 +917,7 @@
737917
dropZone.classList.add('uploading');
738918
}
739919
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);
746921
fileForm.reset();
747922
updateFileHint(t('uploadDone'));
748923
window.setTimeout(() => updateFileHint(), 900);
@@ -772,6 +947,10 @@
772947
['dragenter', 'dragover'].forEach((name) => {
773948
dropZone.addEventListener(name, (event) => {
774949
event.preventDefault();
950+
if (hasDirectoryItems(event.dataTransfer?.items)) {
951+
dropZone.classList.remove('dragging');
952+
return;
953+
}
775954
dropZone.classList.add('dragging');
776955
});
777956
});
@@ -782,6 +961,10 @@
782961
});
783962
});
784963
dropZone.addEventListener('drop', (event) => {
964+
if (hasDirectoryItems(event.dataTransfer?.items)) {
965+
showToast(t('unsupportedShareType'), 'warning');
966+
return;
967+
}
785968
if (event.dataTransfer?.files?.length) {
786969
fileInput.files = event.dataTransfer.files;
787970
uploadSelectedFiles().catch((error) => showToast(error.message, 'error'));
@@ -878,6 +1061,7 @@
8781061
state.apiBase = options.apiBase || '';
8791062
bindQrPreview();
8801063
bindForms();
1064+
bindPasteSharing();
8811065
if (state.role === 'admin' && state.isTauri) {
8821066
bindServerControls();
8831067
bindTauriEvents();

public/i18n.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@
7575
uploadingFiles: '正在上传 {count} 个文件,请保持页面打开',
7676
uploadDone: '上传完成',
7777
uploadFailed: '上传失败,请重新选择',
78+
pastingFiles: '正在分享粘贴的 {count} 个文件',
79+
pasteFileDone: '已分享 {count} 个粘贴文件',
80+
pasteTextDone: '已分享粘贴文本',
81+
pasteFileTooLarge: '无本机路径的粘贴文件最大支持 {size},请改用拖拽或选择文件',
82+
unsupportedShareType: '暂不支持分享目录或该类型内容',
7883
openLocationFailed: '无法打开文件位置',
7984
openFailed: '打开失败:{message}',
8085
sourceMissing: '源文件已不存在'
@@ -154,6 +159,11 @@
154159
uploadingFiles: 'Uploading {count}...',
155160
uploadDone: 'Uploaded',
156161
uploadFailed: 'Upload failed',
162+
pastingFiles: 'Sharing {count} pasted file(s)',
163+
pasteFileDone: 'Shared {count} pasted file(s)',
164+
pasteTextDone: 'Pasted text shared',
165+
pasteFileTooLarge: 'Pasted files without a local path are limited to {size}. Please drag or choose the file instead.',
166+
unsupportedShareType: 'Folders or this content type are not supported',
157167
openLocationFailed: 'Cannot open location',
158168
openFailed: 'Open failed: {message}',
159169
sourceMissing: 'File missing'

src-tauri/Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fileshare"
3-
version = "1.0.8"
3+
version = "1.0.9"
44
description = "LAN file sharing desktop app"
55
authors = ["Trifolium Wang <trifolium.wang@gmail.com>"]
66
license = "MIT"
@@ -12,6 +12,7 @@ tauri-build = { version = "2", features = [] }
1212
[dependencies]
1313
async-stream = "0.3"
1414
axum = { version = "0.7", features = ["multipart"] }
15+
base64 = "0.22"
1516
chrono = { version = "0.4", features = ["serde"] }
1617
dirs = "5"
1718
futures-core = "0.3"

0 commit comments

Comments
 (0)