mirror of
https://github.com/imputnet/cobalt.git
synced 2025-06-28 09:28:29 +00:00
文件传输11-修复加入会话不稳定的问题
This commit is contained in:
parent
481f8d0e9c
commit
7777566e7a
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
|
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
|
||||||
import ActionButton from '$components/buttons/ActionButton.svelte';
|
import ActionButton from '$components/buttons/ActionButton.svelte';
|
||||||
|
|
||||||
@ -9,6 +9,72 @@
|
|||||||
export let receivedText: string;
|
export let receivedText: string;
|
||||||
export let peerConnected: boolean;
|
export let peerConnected: boolean;
|
||||||
|
|
||||||
|
let previousReceivedText = '';
|
||||||
|
let showNewMessageNotification = false;
|
||||||
|
let isNewMessage = false;
|
||||||
|
let receivedTextElement: HTMLElement;
|
||||||
|
|
||||||
|
// 监听接收文本的变化
|
||||||
|
$: if (receivedText !== previousReceivedText && receivedText && previousReceivedText !== '') {
|
||||||
|
handleNewTextReceived();
|
||||||
|
previousReceivedText = receivedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时记录当前文本
|
||||||
|
onMount(() => {
|
||||||
|
previousReceivedText = receivedText;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleNewTextReceived() {
|
||||||
|
// 显示新消息通知
|
||||||
|
showNewMessageNotification = true;
|
||||||
|
isNewMessage = true;
|
||||||
|
|
||||||
|
// 滚动到接收文本区域
|
||||||
|
if (receivedTextElement) {
|
||||||
|
receivedTextElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3秒后隐藏通知
|
||||||
|
setTimeout(() => {
|
||||||
|
showNewMessageNotification = false;
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// 5秒后移除新消息高亮
|
||||||
|
setTimeout(() => {
|
||||||
|
isNewMessage = false;
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// 可选:播放提示音
|
||||||
|
playNotificationSound();
|
||||||
|
}
|
||||||
|
function playNotificationSound() {
|
||||||
|
try {
|
||||||
|
// 创建一个短暂的提示音
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||||
|
oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime);
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.2);
|
||||||
|
} catch (error) {
|
||||||
|
// 如果音频播放失败,忽略错误
|
||||||
|
console.log('Audio notification not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sendText(): void {
|
function sendText(): void {
|
||||||
if (textContent.trim()) {
|
if (textContent.trim()) {
|
||||||
dispatch('sendText', { text: textContent });
|
dispatch('sendText', { text: textContent });
|
||||||
@ -16,37 +82,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearReceivedText(): void {
|
function clearReceivedText(): void {
|
||||||
|
isNewMessage = false;
|
||||||
|
showNewMessageNotification = false;
|
||||||
dispatch('clearText');
|
dispatch('clearText');
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyReceivedText(): void {
|
function copyReceivedText(): void {
|
||||||
if (receivedText && navigator.clipboard) {
|
if (receivedText && navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(receivedText);
|
navigator.clipboard.writeText(receivedText);
|
||||||
|
isNewMessage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissNotification() {
|
||||||
|
showNewMessageNotification = false;
|
||||||
|
isNewMessage = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- 新消息通知 -->
|
||||||
|
{#if showNewMessageNotification}
|
||||||
|
<div class="new-message-notification" on:click={dismissNotification}>
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="notification-icon">📩</div>
|
||||||
|
<div class="notification-text">
|
||||||
|
<strong>收到新消息</strong>
|
||||||
|
<span>点击此处查看</span>
|
||||||
|
</div>
|
||||||
|
<button class="notification-close" on:click|stopPropagation={dismissNotification}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<SettingsCategory title="文本分享" sectionId="text-sharing">
|
<SettingsCategory title="文本分享" sectionId="text-sharing">
|
||||||
<div class="text-sharing-section">
|
<div class="text-sharing-section">
|
||||||
<div class="send-text">
|
<!-- 接收文本区域 - 移到上方 -->
|
||||||
<h4>发送文本</h4>
|
<div class="received-text" class:new-message={isNewMessage} bind:this={receivedTextElement}>
|
||||||
<textarea
|
<h4>已接收文本 {#if isNewMessage}<span class="new-badge">新</span>{/if}</h4>
|
||||||
class="text-input"
|
|
||||||
bind:value={textContent}
|
|
||||||
placeholder="输入要发送的文本..."
|
|
||||||
rows="4"
|
|
||||||
disabled={!peerConnected}
|
|
||||||
></textarea> <ActionButton
|
|
||||||
id="send-text"
|
|
||||||
disabled={!peerConnected || !textContent.trim()}
|
|
||||||
click={sendText}
|
|
||||||
>
|
|
||||||
发送文本
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="received-text">
|
|
||||||
<h4>已接收文本</h4>
|
|
||||||
{#if receivedText}
|
{#if receivedText}
|
||||||
<div class="text-display">
|
<div class="text-display">
|
||||||
<div class="text-content">{receivedText}</div>
|
<div class="text-content">{receivedText}</div>
|
||||||
@ -71,10 +143,128 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 发送文本区域 - 移到下方 -->
|
||||||
|
<div class="send-text">
|
||||||
|
<h4>发送文本</h4>
|
||||||
|
<textarea
|
||||||
|
class="text-input"
|
||||||
|
bind:value={textContent}
|
||||||
|
placeholder="输入要发送的文本..."
|
||||||
|
rows="4"
|
||||||
|
disabled={!peerConnected}
|
||||||
|
></textarea>
|
||||||
|
<ActionButton
|
||||||
|
id="send-text"
|
||||||
|
disabled={!peerConnected || !textContent.trim()}
|
||||||
|
click={sendText}
|
||||||
|
>
|
||||||
|
发送文本
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCategory>
|
</SettingsCategory>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* 新消息通知样式 */
|
||||||
|
.new-message-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 320px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
color: white;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新消息徽章 */
|
||||||
|
.new-badge {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.text-sharing-section {
|
.text-sharing-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -94,12 +284,35 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 新消息状态的特殊样式 */
|
||||||
|
.received-text.new-message {
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||||
|
border-color: rgba(102, 126, 234, 0.3);
|
||||||
|
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||||
|
animation: glow 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
from {
|
||||||
|
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
box-shadow: 0 0 30px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.send-text:hover, .received-text:hover {
|
.send-text:hover, .received-text:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.received-text.new-message:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 35px rgba(102, 126, 234, 0.3);
|
||||||
|
border-color: rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.send-text h4, .received-text h4 {
|
.send-text h4, .received-text h4 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@ -108,6 +321,8 @@
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
@ -124,7 +339,9 @@
|
|||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
} .text-input:focus {
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(102, 126, 234, 0.5);
|
border-color: rgba(102, 126, 234, 0.5);
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1), 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1), 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
@ -152,17 +369,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-content {
|
.text-content {
|
||||||
|
padding: 1.5rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
padding: 1.5rem;
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
max-height: 250px;
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.005) 100%);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-content::-webkit-scrollbar {
|
.text-content::-webkit-scrollbar {
|
||||||
@ -170,86 +387,168 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-content::-webkit-scrollbar-track {
|
.text-content::-webkit-scrollbar-track {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-content::-webkit-scrollbar-thumb {
|
.text-content::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-content::-webkit-scrollbar-thumb:hover {
|
.text-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-actions {
|
.text-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1.25rem;
|
padding: 1rem 1.5rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.01);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn, .clear-btn {
|
.copy-btn, .clear-btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.25rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn:hover {
|
.copy-btn:hover {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||||
border-color: rgba(102, 126, 234, 0.3);
|
border-color: rgba(102, 126, 234, 0.3);
|
||||||
color: #667eea;
|
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn:hover {
|
.clear-btn:hover {
|
||||||
background: linear-gradient(135deg, rgba(244, 67, 54, 0.2) 0%, rgba(233, 30, 99, 0.2) 100%);
|
background: linear-gradient(135deg, rgba(244, 67, 54, 0.1) 0%, rgba(229, 57, 53, 0.1) 100%);
|
||||||
border-color: rgba(244, 67, 54, 0.3);
|
border-color: rgba(244, 67, 54, 0.3);
|
||||||
color: #f44336;
|
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
|
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:active, .clear-btn:active {
|
||||||
|
transform: translateY(0) scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
padding: 3rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
font-style: italic;
|
background: rgba(255, 255, 255, 0.01);
|
||||||
padding: 3rem 2rem;
|
|
||||||
border: 2px dashed rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.005) 100%);
|
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||||
transition: all 0.3s ease;
|
font-style: italic;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state:hover {
|
.empty-state::before {
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
content: '📭';
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
|
display: block;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
|
||||||
|
animation: shimmer 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端响应式 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.new-message-notification {
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
max-width: none;
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.text-sharing-section {
|
.text-sharing-section {
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-text, .received-text {
|
||||||
|
padding: 1.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
min-height: 100px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-actions {
|
.text-actions {
|
||||||
flex-direction: column;
|
padding: 0.75rem 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn, .clear-btn {
|
.copy-btn, .clear-btn {
|
||||||
width: 100%;
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state::before {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.notification-content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text span {
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -41,7 +41,10 @@ export const clipboardState = writable({
|
|||||||
receivingFiles: false,
|
receivingFiles: false,
|
||||||
transferProgress: 0,
|
transferProgress: 0,
|
||||||
dataChannel: null as RTCDataChannel | null,
|
dataChannel: null as RTCDataChannel | null,
|
||||||
peerConnection: null as RTCPeerConnection | null
|
peerConnection: null as RTCPeerConnection | null,
|
||||||
|
errorMessage: '' as string,
|
||||||
|
showError: false as boolean,
|
||||||
|
waitingForCreator: false as boolean
|
||||||
});
|
});
|
||||||
|
|
||||||
export class ClipboardManager {
|
export class ClipboardManager {
|
||||||
@ -92,19 +95,31 @@ export class ClipboardManager {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('clipboard_session');
|
localStorage.removeItem('clipboard_session');
|
||||||
}
|
}
|
||||||
}
|
} private startStatusCheck(): void {
|
||||||
|
// 在移动端使用更频繁的状态检查以确保UI及时更新
|
||||||
private startStatusCheck(): void {
|
const isMobile = typeof window !== 'undefined' &&
|
||||||
|
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||||
|
const checkInterval = isMobile ? 300 : 1000; // 移动端300ms,桌面端1秒
|
||||||
|
|
||||||
this.statusInterval = setInterval(() => {
|
this.statusInterval = setInterval(() => {
|
||||||
const wsConnected = this.ws?.readyState === WebSocket.OPEN;
|
const wsConnected = this.ws?.readyState === WebSocket.OPEN;
|
||||||
const peerConnected = this.dataChannel?.readyState === 'open';
|
const peerConnected = this.dataChannel?.readyState === 'open';
|
||||||
|
|
||||||
clipboardState.update(state => ({
|
// 获取当前状态避免不必要的更新
|
||||||
...state,
|
let currentState: any = {};
|
||||||
isConnected: wsConnected,
|
const unsubscribe = clipboardState.subscribe(s => currentState = s);
|
||||||
peerConnected: peerConnected
|
unsubscribe();
|
||||||
}));
|
|
||||||
}, 1000);
|
// 只在状态真正变化时更新
|
||||||
|
if (currentState.isConnected !== wsConnected || currentState.peerConnected !== peerConnected) {
|
||||||
|
console.log('📱 Status update:', { wsConnected, peerConnected, isMobile });
|
||||||
|
clipboardState.update(state => ({
|
||||||
|
...state,
|
||||||
|
isConnected: wsConnected,
|
||||||
|
peerConnected: peerConnected
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, checkInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket management
|
// WebSocket management
|
||||||
@ -353,9 +368,7 @@ export class ClipboardManager {
|
|||||||
const url = `${origin}/clipboard?session=${sessionId}`;
|
const url = `${origin}/clipboard?session=${sessionId}`;
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
}
|
}
|
||||||
}
|
} cleanup(): void {
|
||||||
|
|
||||||
cleanup(): void {
|
|
||||||
if (this.dataChannel) {
|
if (this.dataChannel) {
|
||||||
this.dataChannel.close();
|
this.dataChannel.close();
|
||||||
this.dataChannel = null;
|
this.dataChannel = null;
|
||||||
@ -377,7 +390,10 @@ export class ClipboardManager {
|
|||||||
sessionId: '',
|
sessionId: '',
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
peerConnected: false,
|
peerConnected: false,
|
||||||
qrCodeUrl: ''
|
qrCodeUrl: '',
|
||||||
|
errorMessage: '',
|
||||||
|
showError: false,
|
||||||
|
waitingForCreator: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.sharedKey = null;
|
this.sharedKey = null;
|
||||||
@ -385,7 +401,15 @@ export class ClipboardManager {
|
|||||||
this.clearStoredSession();
|
this.clearStoredSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket message handler
|
// 清除错误消息
|
||||||
|
clearError(): void {
|
||||||
|
clipboardState.update(state => ({
|
||||||
|
...state,
|
||||||
|
errorMessage: '',
|
||||||
|
showError: false,
|
||||||
|
waitingForCreator: false
|
||||||
|
}));
|
||||||
|
}// WebSocket message handler
|
||||||
private async handleWebSocketMessage(message: any): Promise<void> {
|
private async handleWebSocketMessage(message: any): Promise<void> {
|
||||||
console.log('Handling WebSocket message:', message.type);
|
console.log('Handling WebSocket message:', message.type);
|
||||||
|
|
||||||
@ -395,7 +419,9 @@ export class ClipboardManager {
|
|||||||
...state,
|
...state,
|
||||||
sessionId: message.sessionId,
|
sessionId: message.sessionId,
|
||||||
isCreating: false,
|
isCreating: false,
|
||||||
isCreator: true
|
isCreator: true,
|
||||||
|
errorMessage: '',
|
||||||
|
showError: false
|
||||||
}));
|
}));
|
||||||
await this.generateQRCode(message.sessionId);
|
await this.generateQRCode(message.sessionId);
|
||||||
this.saveSession(message.sessionId, true);
|
this.saveSession(message.sessionId, true);
|
||||||
@ -405,7 +431,13 @@ export class ClipboardManager {
|
|||||||
console.log('Session joined successfully, setting up WebRTC...');
|
console.log('Session joined successfully, setting up WebRTC...');
|
||||||
await this.importRemotePublicKey(message.publicKey);
|
await this.importRemotePublicKey(message.publicKey);
|
||||||
await this.deriveSharedKey();
|
await this.deriveSharedKey();
|
||||||
clipboardState.update(state => ({ ...state, isJoining: false }));
|
clipboardState.update(state => ({
|
||||||
|
...state,
|
||||||
|
isJoining: false,
|
||||||
|
waitingForCreator: false,
|
||||||
|
errorMessage: '',
|
||||||
|
showError: false
|
||||||
|
}));
|
||||||
await this.setupWebRTC(false);
|
await this.setupWebRTC(false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -426,6 +458,36 @@ export class ClipboardManager {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'waiting_for_creator':
|
||||||
|
console.log('Waiting for creator to reconnect...');
|
||||||
|
clipboardState.update(state => ({
|
||||||
|
...state,
|
||||||
|
isJoining: false,
|
||||||
|
waitingForCreator: true,
|
||||||
|
errorMessage: message.message || '等待创建者重新连接',
|
||||||
|
showError: true
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'peer_disconnected':
|
||||||
|
console.log('Peer disconnected');
|
||||||
|
clipboardState.update(state => ({
|
||||||
|
...state,
|
||||||
|
peerConnected: false,
|
||||||
|
errorMessage: '对端已断开连接',
|
||||||
|
showError: true
|
||||||
|
}));
|
||||||
|
// 清理 WebRTC 连接但保持 WebSocket 连接
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = null;
|
||||||
|
}
|
||||||
|
if (this.dataChannel) {
|
||||||
|
this.dataChannel.close();
|
||||||
|
this.dataChannel = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'offer':
|
case 'offer':
|
||||||
await this.handleOffer(message.offer);
|
await this.handleOffer(message.offer);
|
||||||
break;
|
break;
|
||||||
@ -440,20 +502,48 @@ export class ClipboardManager {
|
|||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
console.error('Server error:', message);
|
console.error('Server error:', message);
|
||||||
|
let errorMessage = '连接错误';
|
||||||
|
|
||||||
|
// 处理特定的错误消息
|
||||||
|
if (message.message) {
|
||||||
|
switch (message.message) {
|
||||||
|
case '会话不存在或已过期':
|
||||||
|
errorMessage = '会话已过期或不存在,请创建新会话';
|
||||||
|
this.clearStoredSession(); // 清理本地存储的过期会话
|
||||||
|
break;
|
||||||
|
case '会话已满':
|
||||||
|
errorMessage = '会话已满,无法加入';
|
||||||
|
break;
|
||||||
|
case '未知消息类型':
|
||||||
|
errorMessage = '通信协议错误';
|
||||||
|
break;
|
||||||
|
case '消息格式错误':
|
||||||
|
errorMessage = '数据格式错误';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = message.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clipboardState.update(state => ({
|
clipboardState.update(state => ({
|
||||||
...state,
|
...state,
|
||||||
isCreating: false,
|
isCreating: false,
|
||||||
isJoining: false
|
isJoining: false,
|
||||||
|
waitingForCreator: false,
|
||||||
|
errorMessage,
|
||||||
|
showError: true
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
} // WebRTC setup and handlers
|
||||||
|
|
||||||
// WebRTC setup and handlers
|
|
||||||
private async setupWebRTC(isInitiator: boolean): Promise<void> {
|
private async setupWebRTC(isInitiator: boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.peerConnection = new RTCPeerConnection({
|
this.peerConnection = new RTCPeerConnection({
|
||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
iceServers: [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
{ urls: 'stun:stun1.l.google.com:19302' } // 添加备用STUN服务器
|
||||||
|
],
|
||||||
|
iceCandidatePoolSize: 10 // 增加ICE候选池大小
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update state with peer connection
|
// Update state with peer connection
|
||||||
@ -462,37 +552,84 @@ export class ClipboardManager {
|
|||||||
peerConnection: this.peerConnection
|
peerConnection: this.peerConnection
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 添加ICE连接状态监听
|
||||||
|
this.peerConnection.oniceconnectionstatechange = () => {
|
||||||
|
const iceState = this.peerConnection?.iceConnectionState;
|
||||||
|
console.log('🧊 ICE connection state changed:', iceState);
|
||||||
|
|
||||||
|
if (iceState === 'failed') {
|
||||||
|
console.warn('❌ ICE connection failed, attempting restart...');
|
||||||
|
this.restartIce();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.peerConnection.onicecandidate = (event) => {
|
this.peerConnection.onicecandidate = (event) => {
|
||||||
if (event.candidate && this.ws) {
|
if (event.candidate && this.ws) {
|
||||||
|
console.log('📡 Sending ICE candidate:', event.candidate.type);
|
||||||
this.ws.send(JSON.stringify({
|
this.ws.send(JSON.stringify({
|
||||||
type: 'ice_candidate',
|
type: 'ice_candidate',
|
||||||
candidate: event.candidate
|
candidate: event.candidate
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
}; this.peerConnection.onconnectionstatechange = () => {
|
||||||
|
|
||||||
this.peerConnection.onconnectionstatechange = () => {
|
|
||||||
const state = this.peerConnection?.connectionState;
|
const state = this.peerConnection?.connectionState;
|
||||||
console.log('🔗 Peer connection state changed:', state);
|
console.log('🔗 Peer connection state changed:', state);
|
||||||
|
|
||||||
|
// 检测移动设备
|
||||||
|
const isMobile = typeof window !== 'undefined' &&
|
||||||
|
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||||
|
|
||||||
if (state === 'connected') {
|
if (state === 'connected') {
|
||||||
console.log('🎉 Peer connected!');
|
console.log('🎉 Peer connected!');
|
||||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||||
} else if (state === 'failed' || state === 'disconnected') {
|
|
||||||
console.warn('❌ Peer connection failed/disconnected');
|
// 移动端额外确认连接状态
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('📱 Mobile peer connection confirmation');
|
||||||
|
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
} else if (state === 'failed') {
|
||||||
|
console.warn('❌ Peer connection failed, retrying...');
|
||||||
clipboardState.update(state => ({ ...state, peerConnected: false }));
|
clipboardState.update(state => ({ ...state, peerConnected: false }));
|
||||||
|
|
||||||
|
// 移动端使用更短的重试间隔
|
||||||
|
const retryDelay = isMobile ? 1000 : 2000;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.peerConnection?.connectionState === 'failed') {
|
||||||
|
console.log(`🔄 Attempting to restart peer connection (${isMobile ? 'mobile' : 'desktop'})...`);
|
||||||
|
this.restartWebRTC();
|
||||||
|
}
|
||||||
|
}, retryDelay);
|
||||||
|
} else if (state === 'disconnected') {
|
||||||
|
console.warn('⚠️ Peer connection disconnected');
|
||||||
|
clipboardState.update(state => ({ ...state, peerConnected: false }));
|
||||||
|
|
||||||
|
// 移动端快速恢复尝试
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.peerConnection?.connectionState === 'disconnected') {
|
||||||
|
console.log('📱 Mobile reconnection attempt...');
|
||||||
|
this.restartWebRTC();
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};if (isInitiator) {
|
||||||
|
|
||||||
if (isInitiator) {
|
|
||||||
this.dataChannel = this.peerConnection.createDataChannel('files', {
|
this.dataChannel = this.peerConnection.createDataChannel('files', {
|
||||||
ordered: true
|
ordered: true,
|
||||||
|
maxRetransmits: 3 // 增加重传次数
|
||||||
});
|
});
|
||||||
this.setupDataChannel();
|
this.setupDataChannel();
|
||||||
|
|
||||||
|
// 为移动端增加延迟,确保ICE candidates收集完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
const offer = await this.peerConnection.createOffer();
|
const offer = await this.peerConnection.createOffer();
|
||||||
await this.peerConnection.setLocalDescription(offer);
|
await this.peerConnection.setLocalDescription(offer);
|
||||||
|
|
||||||
|
console.log('📤 Sending offer to peer...');
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.send(JSON.stringify({
|
this.ws.send(JSON.stringify({
|
||||||
type: 'offer',
|
type: 'offer',
|
||||||
@ -501,16 +638,59 @@ export class ClipboardManager {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.peerConnection.ondatachannel = (event) => {
|
this.peerConnection.ondatachannel = (event) => {
|
||||||
|
console.log('📥 Data channel received');
|
||||||
this.dataChannel = event.channel;
|
this.dataChannel = event.channel;
|
||||||
this.setupDataChannel();
|
this.setupDataChannel();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting up WebRTC:', error);
|
console.error('Error setting up WebRTC:', error);
|
||||||
|
// 添加错误恢复
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('🔄 Retrying WebRTC setup...');
|
||||||
|
this.setupWebRTC(isInitiator);
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupDataChannel(): void {
|
// 添加ICE重启方法
|
||||||
|
private async restartIce(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.peerConnection && this.peerConnection.connectionState !== 'closed') {
|
||||||
|
console.log('🔄 Restarting ICE...');
|
||||||
|
await this.peerConnection.restartIce();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restarting ICE:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加WebRTC重启方法
|
||||||
|
private async restartWebRTC(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Restarting WebRTC connection...');
|
||||||
|
|
||||||
|
// 清理现有连接
|
||||||
|
if (this.dataChannel) {
|
||||||
|
this.dataChannel.close();
|
||||||
|
this.dataChannel = null;
|
||||||
|
}
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前状态以确定是否为发起者
|
||||||
|
let currentState: any = {};
|
||||||
|
const unsubscribe = clipboardState.subscribe(s => currentState = s);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
// 重新建立连接
|
||||||
|
await this.setupWebRTC(currentState.isCreator);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restarting WebRTC:', error);
|
||||||
|
}
|
||||||
|
} private setupDataChannel(): void {
|
||||||
if (!this.dataChannel) return;
|
if (!this.dataChannel) return;
|
||||||
|
|
||||||
// Update state with data channel
|
// Update state with data channel
|
||||||
@ -521,7 +701,18 @@ export class ClipboardManager {
|
|||||||
|
|
||||||
this.dataChannel.onopen = () => {
|
this.dataChannel.onopen = () => {
|
||||||
console.log('🎉 Data channel opened!');
|
console.log('🎉 Data channel opened!');
|
||||||
|
// 立即更新状态,不等待状态检查间隔
|
||||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||||
|
|
||||||
|
// 移动端额外的状态确认延迟,确保UI有足够时间响应
|
||||||
|
const isMobile = typeof window !== 'undefined' &&
|
||||||
|
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('📱 Mobile connection confirmation');
|
||||||
|
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.dataChannel.onclose = () => {
|
this.dataChannel.onclose = () => {
|
||||||
@ -531,6 +722,17 @@ export class ClipboardManager {
|
|||||||
|
|
||||||
this.dataChannel.onerror = (error) => {
|
this.dataChannel.onerror = (error) => {
|
||||||
console.error('Data channel error:', error);
|
console.error('Data channel error:', error);
|
||||||
|
// 移动端错误恢复机制
|
||||||
|
const isMobile = typeof window !== 'undefined' &&
|
||||||
|
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||||
|
if (isMobile) {
|
||||||
|
console.log('📱 Mobile data channel error, attempting recovery...');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.dataChannel?.readyState === 'open') {
|
||||||
|
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
}; this.dataChannel.onmessage = async (event) => {
|
}; this.dataChannel.onmessage = async (event) => {
|
||||||
console.log('📨 Data channel message received:', event.data);
|
console.log('📨 Data channel message received:', event.data);
|
||||||
try {
|
try {
|
||||||
|
@ -48,6 +48,11 @@
|
|||||||
let dataChannel: RTCDataChannel | null = null;
|
let dataChannel: RTCDataChannel | null = null;
|
||||||
let peerConnection: RTCPeerConnection | null = null;
|
let peerConnection: RTCPeerConnection | null = null;
|
||||||
|
|
||||||
|
// Error handling state
|
||||||
|
let errorMessage = '';
|
||||||
|
let showError = false;
|
||||||
|
let waitingForCreator = false;
|
||||||
|
|
||||||
// Clipboard manager instance
|
// Clipboard manager instance
|
||||||
let clipboardManager: ClipboardManager; // Subscribe to clipboard state
|
let clipboardManager: ClipboardManager; // Subscribe to clipboard state
|
||||||
$: if (clipboardManager) {
|
$: if (clipboardManager) {
|
||||||
@ -71,6 +76,9 @@
|
|||||||
transferProgress = state.transferProgress;
|
transferProgress = state.transferProgress;
|
||||||
dataChannel = state.dataChannel;
|
dataChannel = state.dataChannel;
|
||||||
peerConnection = state.peerConnection;
|
peerConnection = state.peerConnection;
|
||||||
|
errorMessage = state.errorMessage;
|
||||||
|
showError = state.showError;
|
||||||
|
waitingForCreator = state.waitingForCreator;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,10 +151,15 @@
|
|||||||
clipboardManager?.sendText(text);
|
clipboardManager?.sendText(text);
|
||||||
} else {
|
} else {
|
||||||
console.log('No text to send');
|
console.log('No text to send');
|
||||||
}
|
} } function handleClearText() {
|
||||||
} function handleClearText() {
|
|
||||||
clipboardState.update(state => ({ ...state, receivedText: '' }));
|
clipboardState.update(state => ({ ...state, receivedText: '' }));
|
||||||
}// Lifecycle functions
|
}
|
||||||
|
|
||||||
|
function handleClearError() {
|
||||||
|
clipboardManager?.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle functions
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
clipboardManager = new ClipboardManager();
|
clipboardManager = new ClipboardManager();
|
||||||
|
|
||||||
@ -171,27 +184,37 @@
|
|||||||
<meta property="description" content={$t("clipboard.description")} />
|
<meta property="description" content={$t("clipboard.description")} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="clipboard-container"> <div class="clipboard-header">
|
<div class="clipboard-container">
|
||||||
|
<div class="clipboard-header">
|
||||||
<h1>{$t("clipboard.title")}</h1>
|
<h1>{$t("clipboard.title")}</h1>
|
||||||
<div class="description-container">
|
<div class="description-container">
|
||||||
<p class="description-main">{$t("clipboard.description")}</p>
|
<p class="description-main">{$t("clipboard.description")}</p>
|
||||||
<p class="description-subtitle">{$t("clipboard.description_subtitle")}</p>
|
<p class="description-subtitle">{$t("clipboard.description_subtitle")}</p>
|
||||||
|
</div> </div>
|
||||||
|
|
||||||
|
<!-- Error Message Display -->
|
||||||
|
{#if showError && errorMessage}
|
||||||
|
<div class="error-notification" class:waiting={waitingForCreator}>
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-icon">
|
||||||
|
{#if waitingForCreator}
|
||||||
|
<div class="spinner"></div>
|
||||||
|
{:else}
|
||||||
|
⚠️
|
||||||
|
{/if}
|
||||||
|
</div> <div class="error-text">
|
||||||
|
<strong>{waitingForCreator ? '等待中' : '提示'}</strong>
|
||||||
|
<p>{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<button class="error-close" on:click={handleClearError} aria-label="关闭">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div><!-- Session Management Component -->
|
{/if}
|
||||||
<SessionManager
|
|
||||||
{sessionId}
|
{#if isConnected && peerConnected}
|
||||||
{isConnected}
|
<!-- Connection Status Indicator -->
|
||||||
{isCreating}
|
|
||||||
{isJoining}
|
|
||||||
{isCreator}
|
|
||||||
{peerConnected}
|
|
||||||
{qrCodeUrl}
|
|
||||||
on:createSession={handleCreateSession}
|
|
||||||
on:joinSession={handleJoinSession}
|
|
||||||
on:shareSession={handleShareSession}
|
|
||||||
on:cleanup={handleCleanup}
|
|
||||||
bind:joinCode
|
|
||||||
/> {#if isConnected && peerConnected} <!-- Connection Status Indicator -->
|
|
||||||
<div class="session-status">
|
<div class="session-status">
|
||||||
<div class="status-badge">
|
<div class="status-badge">
|
||||||
<div class="status-dot connected"></div>
|
<div class="status-dot connected"></div>
|
||||||
@ -199,13 +222,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Navigation Component -->
|
<!-- Tab Navigation Component - Moved to top -->
|
||||||
<TabNavigation
|
<TabNavigation
|
||||||
{activeTab}
|
{activeTab}
|
||||||
on:tabChange={handleTabChange}
|
on:tabChange={handleTabChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area - Moved to top -->
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- File Transfer Component -->
|
<!-- File Transfer Component -->
|
||||||
{#if activeTab === 'files'}
|
{#if activeTab === 'files'}
|
||||||
@ -233,8 +256,41 @@
|
|||||||
on:sendText={handleSendText}
|
on:sendText={handleSendText}
|
||||||
on:clearText={handleClearText}
|
on:clearText={handleClearText}
|
||||||
bind:textContent
|
bind:textContent
|
||||||
/> {/if}
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Management Section - Moved to bottom -->
|
||||||
|
<div class="session-management-section">
|
||||||
|
<div class="session-info">
|
||||||
|
<h3>会话管理</h3>
|
||||||
|
<p>会话ID: <code>{sessionId}</code></p>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="btn-secondary" on:click={handleShareSession}>
|
||||||
|
分享会话
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary danger" on:click={handleCleanup}>
|
||||||
|
断开连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Session Management Component - Show when not connected -->
|
||||||
|
<SessionManager
|
||||||
|
{sessionId}
|
||||||
|
{isConnected}
|
||||||
|
{isCreating}
|
||||||
|
{isJoining}
|
||||||
|
{isCreator}
|
||||||
|
{peerConnected}
|
||||||
|
{qrCodeUrl}
|
||||||
|
on:createSession={handleCreateSession}
|
||||||
|
on:joinSession={handleJoinSession}
|
||||||
|
on:shareSession={handleShareSession}
|
||||||
|
on:cleanup={handleCleanup}
|
||||||
|
bind:joinCode
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -302,9 +358,106 @@
|
|||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
opacity: 0.7 !important;
|
opacity: 0.7 !important;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 600px;
|
max-width: 600px; line-height: 1.4;
|
||||||
line-height: 1.4;
|
}
|
||||||
} /* Enhanced session status */
|
|
||||||
|
/* Error notification styles */
|
||||||
|
.error-notification {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-notification.waiting {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(34, 197, 94, 0.3);
|
||||||
|
border-top: 2px solid #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text strong {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-notification.waiting .error-text strong {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text p {
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced session status */
|
||||||
.session-status {
|
.session-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -357,11 +510,86 @@
|
|||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible; /* 允许通知显示在容器外 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content:hover {
|
.tab-content:hover {
|
||||||
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.08);
|
||||||
border-color: rgba(255, 255, 255, 0.12); }
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session management section styling */
|
||||||
|
.session-management-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 15px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info h3 {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info p {
|
||||||
|
color: var(--subtext);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.danger {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Enhanced card-like sections */
|
/* Enhanced card-like sections */
|
||||||
:global(.card) {
|
:global(.card) {
|
||||||
|
Loading…
Reference in New Issue
Block a user