Add plain text clipboard transfers and refine input feedback
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
clearAccountUploads,
|
||||
cancelTransfer,
|
||||
uploadProgress,
|
||||
clipboardTextLimitBytes,
|
||||
} from './client';
|
||||
const props = defineProps({ initialView: { type: String, default: 'vault' } });
|
||||
const emit = defineEmits(['devices', 'send-item']);
|
||||
@@ -248,7 +249,7 @@ async function save(values) {
|
||||
'/vault',
|
||||
'POST',
|
||||
{
|
||||
kind: values.files ? 'file' : 'url',
|
||||
kind: values.files ? 'file' : values.text !== undefined ? 'text' : 'url',
|
||||
...(values.files
|
||||
? {
|
||||
files: values.files.map((file) => ({
|
||||
@@ -257,7 +258,9 @@ async function save(values) {
|
||||
type: file.type || 'application/octet-stream',
|
||||
})),
|
||||
}
|
||||
: { urls: values.urls }),
|
||||
: values.text !== undefined
|
||||
? { text: values.text }
|
||||
: { urls: values.urls }),
|
||||
note: note.value,
|
||||
},
|
||||
crypto.randomUUID(),
|
||||
@@ -317,7 +320,7 @@ function recordLinkOpen(item) {
|
||||
}
|
||||
async function copy(item) {
|
||||
await act(async () => {
|
||||
await navigator.clipboard.writeText(item.url);
|
||||
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
|
||||
await read(item);
|
||||
message('복사했습니다.');
|
||||
});
|
||||
@@ -577,7 +580,7 @@ async function resendVerification() {
|
||||
<input
|
||||
type="search"
|
||||
v-model="search"
|
||||
placeholder="링크, 파일, 메모 검색"
|
||||
placeholder="링크, 텍스트, 파일, 메모 검색"
|
||||
aria-label="계정 자료 검색"
|
||||
/><button class="button small">검색</button>
|
||||
</form>
|
||||
@@ -592,8 +595,8 @@ async function resendVerification() {
|
||||
type="checkbox"
|
||||
v-model="selected"
|
||||
:value="item.accessId"
|
||||
:aria-label="`${item.file?.name ?? item.url} 선택`"
|
||||
/><span>{{ item.file ? '파일' : '링크' }}</span
|
||||
:aria-label="`${item.file?.name ?? item.url ?? item.text} 선택`"
|
||||
/><span>{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</span
|
||||
><span v-if="view === 'inbox'">{{ item.peerName }}</span
|
||||
><time>{{ date(item.createdAt) }}</time>
|
||||
</div>
|
||||
@@ -605,6 +608,9 @@ async function resendVerification() {
|
||||
><span>{{ size(item.file.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-else-if="item.kind === 'text'" class="item-body account-text-preview">{{
|
||||
item.text
|
||||
}}</pre>
|
||||
<p v-else class="item-body">{{ item.url }}</p>
|
||||
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
|
||||
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
|
||||
@@ -624,6 +630,7 @@ async function resendVerification() {
|
||||
><template v-else
|
||||
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
|
||||
><a
|
||||
v-if="item.url"
|
||||
class="button small"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
@@ -687,7 +694,8 @@ async function resendVerification() {
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="!saveKind" class="save-kind-choices">
|
||||
<button class="button" @click="saveKind = 'url'"><Link :size="22" />링크 저장</button
|
||||
<button class="button" @click="saveKind = 'clipboard'">
|
||||
<Copy :size="22" />클립보드 저장</button
|
||||
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -711,7 +719,8 @@ async function resendVerification() {
|
||||
.filter(([key]) => key.startsWith(saveTransfer?.id + ':'))
|
||||
.map(([, value]) => value)
|
||||
"
|
||||
@links="save({ urls: $event })"
|
||||
@clipboard="save($event)"
|
||||
:text-limit-bytes="clipboardTextLimitBytes"
|
||||
@files="save({ files: $event })"
|
||||
@cancel="cancelSave"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
ClipboardPaste,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
} from '@lucide/vue';
|
||||
import QRCode from 'qrcode';
|
||||
import { version as appVersion } from '../package.json';
|
||||
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
|
||||
import AccountPanel from './AccountPanel.vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import InstallButton from './InstallButton.vue';
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
connected,
|
||||
spaceInviteCodeLength,
|
||||
locationSettings,
|
||||
clipboardTextLimitBytes,
|
||||
publishLocation,
|
||||
clearLocation,
|
||||
reserveLocationAcceptance,
|
||||
@@ -144,6 +147,31 @@ createNearbySearch({
|
||||
});
|
||||
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
|
||||
const blockedLinks = receiver.blockedLinks;
|
||||
const receivedTexts = receiver.receivedTexts;
|
||||
const receivedLinks = receiver.receivedLinks;
|
||||
watch(
|
||||
() => receivedTexts.value[0]?.accessId,
|
||||
async (id) => {
|
||||
if (!id) return;
|
||||
await nextTick();
|
||||
document.querySelector('.received-text-dialog > .button')?.focus();
|
||||
},
|
||||
);
|
||||
function trapReceivedFocus(event) {
|
||||
const controls = [
|
||||
...event.currentTarget.querySelectorAll('button:not(:disabled), [tabindex="0"]'),
|
||||
];
|
||||
const first = controls[0],
|
||||
last = controls.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
watch([requests, items], ([currentRequests, currentItems]) => {
|
||||
receiver.reconcile(currentRequests, currentItems);
|
||||
});
|
||||
@@ -533,14 +561,34 @@ function selectPeer(peer) {
|
||||
selectionEpoch.value++;
|
||||
forwardedItem.value = null;
|
||||
}
|
||||
async function submitLinks(urls) {
|
||||
async function submitClipboard(content) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
await send(selected.value.id, 'url', { urls });
|
||||
await send(
|
||||
selected.value.id,
|
||||
content.kind,
|
||||
content.kind === 'text' ? { text: content.text } : { urls: content.urls },
|
||||
);
|
||||
});
|
||||
}
|
||||
async function copyReceived(item) {
|
||||
await act(async () => {
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) throw Error('unavailable');
|
||||
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
|
||||
} catch {
|
||||
throw Error('복사하지 못했어요. 내용을 선택해 직접 복사해 주세요.');
|
||||
}
|
||||
notify('클립보드에 복사했어요.');
|
||||
await markRead(item);
|
||||
});
|
||||
}
|
||||
async function closeReceivedText(item) {
|
||||
receivedTexts.value = receivedTexts.value.filter((value) => value.accessId !== item.accessId);
|
||||
await act(() => markRead(item));
|
||||
}
|
||||
async function submitFiles(files) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
@@ -736,8 +784,8 @@ async function spaceAction(space, action, memberId) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="receiver.opened(item)"
|
||||
>링크 열기 <ArrowUpRight :size="15"
|
||||
/></a>
|
||||
>링크 열기 <ArrowUpRight :size="15" /></a
|
||||
><button class="button small" @click="copyReceived(item)">주소 복사</button>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="안내 닫기"
|
||||
@@ -747,6 +795,53 @@ async function spaceAction(space, action, memberId) {
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<div
|
||||
v-if="receivedTexts.length"
|
||||
class="receive-backdrop text-receive-backdrop"
|
||||
@keydown.esc="closeReceivedText(receivedTexts[0])"
|
||||
>
|
||||
<section
|
||||
class="received-text-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="받은 텍스트"
|
||||
@keydown.tab="trapReceivedFocus"
|
||||
>
|
||||
<header>
|
||||
<h2>
|
||||
<ClipboardPaste :size="20" />{{ receivedTexts[0].peerName }}에서 텍스트를 보냈어요
|
||||
</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="받은 텍스트 닫기"
|
||||
@click="closeReceivedText(receivedTexts[0])"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<pre class="received-text-preview" tabindex="0" aria-label="받은 텍스트 내용">{{
|
||||
receivedTexts[0].text
|
||||
}}</pre>
|
||||
<button
|
||||
class="button primary"
|
||||
:disabled="busy"
|
||||
@click="copyReceived(receivedTexts[0])"
|
||||
>
|
||||
<Copy :size="18" />클립보드에 복사
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="receivedLinks.length" class="received-link-notice" role="status">
|
||||
<Link :size="18" /><span>링크를 열었어요.</span
|
||||
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
|
||||
><button
|
||||
class="icon-button"
|
||||
aria-label="링크 수신 안내 닫기"
|
||||
@click="receivedLinks = []"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="incoming.length" class="receive-backdrop">
|
||||
<article
|
||||
v-for="r in incoming.slice(0, 1)"
|
||||
@@ -763,8 +858,8 @@ async function spaceAction(space, action, memberId) {
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
{{ r.kind === 'file' ? '파일' : '링크' }} · {{ r.count }}개 ·
|
||||
{{ r.byteSize.toLocaleString() }}바이트
|
||||
{{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} ·
|
||||
{{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트
|
||||
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1011,7 +1106,8 @@ async function spaceAction(space, action, memberId) {
|
||||
:disabled="!connected"
|
||||
@cancel="cancelCurrentTransfer"
|
||||
@files="submitFiles"
|
||||
@links="submitLinks"
|
||||
@clipboard="submitClipboard"
|
||||
:text-limit-bytes="clipboardTextLimitBytes"
|
||||
:transfer-source="forwardedItem"
|
||||
:initial-item="itemToSend"
|
||||
:vault-enabled="Boolean(account)"
|
||||
@@ -1166,12 +1262,20 @@ async function spaceAction(space, action, memberId) {
|
||||
</template>
|
||||
<template v-else
|
||||
><form class="name-form device-settings-form" @submit.prevent="saveName">
|
||||
<label
|
||||
>기기 이름<input
|
||||
<label>
|
||||
<span class="name-field-heading"
|
||||
><span>기기 이름</span
|
||||
><small id="device-name-count" class="name-count"
|
||||
>{{ nameDraft.length }}/{{ DEVICE_NAME_MAX_LENGTH }}</small
|
||||
></span
|
||||
>
|
||||
<input
|
||||
v-model="nameDraft"
|
||||
aria-label="기기 이름"
|
||||
maxlength="40"
|
||||
required /></label
|
||||
aria-describedby="device-name-count"
|
||||
:maxlength="DEVICE_NAME_MAX_LENGTH"
|
||||
required
|
||||
/> </label
|
||||
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
|
||||
</form>
|
||||
<label class="settings-row discovery-setting">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import {
|
||||
FileUp,
|
||||
File as FileIcon,
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
} from '@lucide/vue';
|
||||
import { parseUrls } from '@hop/shared';
|
||||
import { classifyClipboard, clipboardCharacterCount } from '@hop/shared';
|
||||
import { mayReadClipboardAutomatically } from './clipboardAccess';
|
||||
const props = defineProps({
|
||||
targetName: String,
|
||||
textLimitBytes: { type: Number, default: 262144 },
|
||||
targetKey: String,
|
||||
clipboardKey: Number,
|
||||
busy: Boolean,
|
||||
@@ -30,20 +32,28 @@ const props = defineProps({
|
||||
initialItem: Object,
|
||||
vaultOwner: String,
|
||||
});
|
||||
const emit = defineEmits(['files', 'links', 'vault', 'vault-open', 'vault-more', 'cancel']);
|
||||
const source = ref('file'),
|
||||
const emit = defineEmits(['files', 'clipboard', 'vault', 'vault-open', 'vault-more', 'cancel']);
|
||||
const source = ref('url'),
|
||||
files = ref([]),
|
||||
urlInput = ref(''),
|
||||
clipboardManaged = ref(false),
|
||||
reading = ref(false),
|
||||
feedback = ref(''),
|
||||
selectedVault = ref(null),
|
||||
fileInput = ref(null);
|
||||
fileInput = ref(null),
|
||||
editor = ref(null),
|
||||
compositionKind = ref(null);
|
||||
let generation = 0;
|
||||
const parsed = computed(() => parseUrls(urlInput.value));
|
||||
const validUrl = computed(
|
||||
const clipboardOnly = computed(() => ['url', 'clipboard'].includes(props.contentMode));
|
||||
const classified = computed(() => classifyClipboard(urlInput.value));
|
||||
const editorKind = computed(() => compositionKind.value ?? classified.value.kind);
|
||||
const contentBytes = computed(() => new TextEncoder().encode(urlInput.value).length);
|
||||
const characters = computed(() => clipboardCharacterCount(urlInput.value));
|
||||
const validClipboard = computed(
|
||||
() =>
|
||||
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
|
||||
Boolean(urlInput.value.trim()) &&
|
||||
((classified.value.kind === 'url' && classified.value.url.length <= 8192) ||
|
||||
(classified.value.kind === 'text' && contentBytes.value <= props.textLimitBytes)),
|
||||
);
|
||||
const retryable = computed(
|
||||
() =>
|
||||
@@ -57,14 +67,15 @@ const mutable = computed(
|
||||
);
|
||||
const ready = computed(() =>
|
||||
source.value === 'url'
|
||||
? validUrl.value && !reading.value
|
||||
? validClipboard.value && !reading.value && !compositionKind.value
|
||||
: source.value === 'file'
|
||||
? files.value.length > 0
|
||||
: Boolean(selectedVault.value),
|
||||
);
|
||||
async function loadClipboard(force = false) {
|
||||
async function loadClipboard(force = false, explicit = false) {
|
||||
if (
|
||||
!props.targetKey ||
|
||||
(!explicit && reading.value) ||
|
||||
props.transfer ||
|
||||
!props.readClipboardOnSelection ||
|
||||
props.contentMode === 'file' ||
|
||||
@@ -81,23 +92,23 @@ async function loadClipboard(force = false) {
|
||||
reading.value = true;
|
||||
feedback.value = '';
|
||||
try {
|
||||
if (!explicit && !(await mayReadClipboardAutomatically())) return;
|
||||
if (current !== generation || !mutable.value || document.visibilityState === 'hidden') return;
|
||||
if (!navigator.clipboard?.readText) throw Error('Unavailable');
|
||||
const result = parseUrls(await navigator.clipboard.readText());
|
||||
if (current !== generation) return;
|
||||
if (result.urls.length && result.urls.length <= 20 && !result.invalid.length) {
|
||||
const result = await navigator.clipboard.readText();
|
||||
if (current !== generation || !mutable.value) return;
|
||||
if (result.trim()) {
|
||||
source.value = 'url';
|
||||
files.value = [];
|
||||
selectedVault.value = null;
|
||||
urlInput.value = result.urls.join('\n');
|
||||
urlInput.value = result;
|
||||
clipboardManaged.value = true;
|
||||
} else if (force || clipboardManaged.value) {
|
||||
urlInput.value = '';
|
||||
clipboardManaged.value = false;
|
||||
if (source.value === 'url') feedback.value = '클립보드에 링크가 없어요. 직접 입력해 주세요.';
|
||||
} else if (explicit) {
|
||||
feedback.value = '클립보드가 비어 있어요. 직접 입력하거나 붙여넣어 주세요.';
|
||||
}
|
||||
} catch {
|
||||
if (current === generation && source.value === 'url')
|
||||
feedback.value = '링크를 직접 붙여넣어 주세요.';
|
||||
feedback.value = '클립보드를 읽지 못했어요. 직접 입력하거나 붙여넣어 주세요.';
|
||||
} finally {
|
||||
if (current === generation) reading.value = false;
|
||||
}
|
||||
@@ -113,12 +124,13 @@ watch(
|
||||
selectedVault.value = null;
|
||||
clipboardManaged.value = false;
|
||||
feedback.value = '';
|
||||
source.value = props.contentMode === 'url' ? 'url' : 'file';
|
||||
source.value = props.contentMode === 'file' ? 'file' : 'url';
|
||||
}
|
||||
if (props.initialItem) {
|
||||
source.value = 'vault';
|
||||
selectedVault.value = props.initialItem;
|
||||
} else void loadClipboard(true);
|
||||
} else if (current[0] !== previous?.[0] || current[1] !== previous?.[1])
|
||||
void loadClipboard(true);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -175,23 +187,6 @@ watch(
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (!disabled) void loadClipboard();
|
||||
},
|
||||
);
|
||||
const focus = () => void loadClipboard();
|
||||
const visible = () => {
|
||||
if (document.visibilityState === 'visible') focus();
|
||||
};
|
||||
window.addEventListener('focus', focus);
|
||||
document.addEventListener('visibilitychange', visible);
|
||||
onUnmounted(() => {
|
||||
generation++;
|
||||
window.removeEventListener('focus', focus);
|
||||
document.removeEventListener('visibilitychange', visible);
|
||||
});
|
||||
function editUrl() {
|
||||
generation++;
|
||||
reading.value = false;
|
||||
@@ -199,7 +194,7 @@ function editUrl() {
|
||||
feedback.value = '';
|
||||
}
|
||||
function choose(list) {
|
||||
if (!mutable.value || props.contentMode === 'url' || !list?.length) return;
|
||||
if (!mutable.value || clipboardOnly.value || !list?.length) return;
|
||||
generation++;
|
||||
reading.value = false;
|
||||
files.value = Array.from(list);
|
||||
@@ -224,7 +219,7 @@ function toLink() {
|
||||
urlInput.value = '';
|
||||
source.value = 'url';
|
||||
clipboardManaged.value = false;
|
||||
void loadClipboard(true);
|
||||
void loadClipboard(true, true);
|
||||
}
|
||||
function toVault() {
|
||||
if (!mutable.value || !props.vaultEnabled) return;
|
||||
@@ -247,15 +242,48 @@ function pasted(event) {
|
||||
return;
|
||||
}
|
||||
if (props.contentMode === 'file' || props.transfer) return;
|
||||
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
|
||||
if (result.urls.length && !result.invalid.length) {
|
||||
event.preventDefault();
|
||||
editUrl();
|
||||
files.value = [];
|
||||
selectedVault.value = null;
|
||||
source.value = 'url';
|
||||
urlInput.value = result.urls.join('\n');
|
||||
}
|
||||
const value = event.clipboardData?.getData('text/plain');
|
||||
if (value === undefined) return;
|
||||
event.preventDefault();
|
||||
editUrl();
|
||||
files.value = [];
|
||||
selectedVault.value = null;
|
||||
source.value = 'url';
|
||||
if (event.target === editor.value) {
|
||||
const start = event.target.selectionStart ?? urlInput.value.length;
|
||||
const end = event.target.selectionEnd ?? start;
|
||||
urlInput.value = urlInput.value.slice(0, start) + value + urlInput.value.slice(end);
|
||||
void nextTick(() => {
|
||||
editor.value?.focus({ preventScroll: true });
|
||||
editor.value?.setSelectionRange(start + value.length, start + value.length);
|
||||
});
|
||||
} else urlInput.value = value;
|
||||
}
|
||||
function edited(event) {
|
||||
urlInput.value = event.target.value;
|
||||
const focused = document.activeElement === event.target;
|
||||
const start = event.target.selectionStart,
|
||||
end = event.target.selectionEnd;
|
||||
editUrl();
|
||||
if (focused)
|
||||
void nextTick(() => {
|
||||
if (editor.value && editor.value !== event.target) {
|
||||
editor.value.focus({ preventScroll: true });
|
||||
editor.value.setSelectionRange(start, end);
|
||||
}
|
||||
});
|
||||
}
|
||||
function newline(event) {
|
||||
if (event.target.tagName !== 'INPUT' || event.isComposing || compositionKind.value) return;
|
||||
event.preventDefault();
|
||||
const start = event.target.selectionStart ?? urlInput.value.length;
|
||||
const end = event.target.selectionEnd ?? start;
|
||||
urlInput.value = urlInput.value.slice(0, start) + '\n' + urlInput.value.slice(end);
|
||||
editUrl();
|
||||
void nextTick(() => {
|
||||
editor.value?.focus({ preventScroll: true });
|
||||
editor.value?.setSelectionRange(start + 1, start + 1);
|
||||
});
|
||||
}
|
||||
function submit() {
|
||||
if (props.transfer && !retryable.value) {
|
||||
@@ -263,7 +291,13 @@ function submit() {
|
||||
return;
|
||||
}
|
||||
if (!ready.value || !mutable.value) return;
|
||||
if (source.value === 'url') emit('links', parsed.value.urls);
|
||||
if (source.value === 'url')
|
||||
emit(
|
||||
'clipboard',
|
||||
classified.value.kind === 'url'
|
||||
? { kind: 'url', urls: [classified.value.url] }
|
||||
: { kind: 'text', text: urlInput.value },
|
||||
);
|
||||
else if (source.value === 'file') emit('files', [...files.value]);
|
||||
else emit('vault', selectedVault.value);
|
||||
}
|
||||
@@ -286,7 +320,7 @@ function size(value) {
|
||||
@drop.stop.prevent="choose($event.dataTransfer?.files)"
|
||||
>
|
||||
<input
|
||||
v-if="contentMode !== 'url'"
|
||||
v-if="!clipboardOnly"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
@@ -297,32 +331,63 @@ function size(value) {
|
||||
/>
|
||||
<div
|
||||
class="content-pane"
|
||||
:class="{ 'clipboard-pane': source === 'url' }"
|
||||
@dragover.prevent
|
||||
@drop.stop.prevent="choose($event.dataTransfer?.files)"
|
||||
>
|
||||
<template v-if="source === 'url'">
|
||||
<div class="content-heading">
|
||||
<strong><Link :size="18" />보낼 링크</strong
|
||||
<strong
|
||||
><Link v-if="editorKind === 'url'" :size="18" /><ClipboardPaste v-else :size="18" />{{
|
||||
editorKind === 'url' ? '보낼 링크' : '보낼 텍스트'
|
||||
}}</strong
|
||||
><button
|
||||
class="text-button clipboard-load"
|
||||
:disabled="!mutable || reading"
|
||||
@click="loadClipboard(true)"
|
||||
@click="loadClipboard(true, true)"
|
||||
>
|
||||
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '클립보드 가져오기' }}
|
||||
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '다시 가져오기' }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="urlInput"
|
||||
aria-label="보낼 URL"
|
||||
placeholder="https://example.com"
|
||||
rows="3"
|
||||
<component
|
||||
:is="editorKind === 'url' ? 'input' : 'textarea'"
|
||||
ref="editor"
|
||||
:value="urlInput"
|
||||
:aria-label="editorKind === 'url' ? '보낼 URL' : '보낼 텍스트'"
|
||||
:placeholder="
|
||||
classified.kind === 'url' ? 'https://example.com' : '내용을 입력하거나 붙여넣어 주세요.'
|
||||
"
|
||||
:rows="editorKind === 'text' ? 6 : undefined"
|
||||
:disabled="!mutable"
|
||||
@input="editUrl"
|
||||
class="current-url"
|
||||
@input="edited"
|
||||
@compositionstart="
|
||||
compositionKind = classified.kind;
|
||||
editUrl();
|
||||
"
|
||||
@compositionend="
|
||||
compositionKind = null;
|
||||
edited($event);
|
||||
"
|
||||
@keydown.enter="newline"
|
||||
:class="editorKind === 'url' ? 'current-url' : 'current-text'"
|
||||
/>
|
||||
<small v-if="classified.kind === 'text'" class="clipboard-count"
|
||||
>{{ characters.toLocaleString() }}자</small
|
||||
>
|
||||
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
|
||||
<p v-else-if="urlInput.trim() && !validUrl" class="content-feedback" role="status">
|
||||
HTTP 또는 HTTPS 링크를 입력해 주세요.
|
||||
<p
|
||||
v-if="classified.kind === 'url' && classified.url.length > 8192"
|
||||
class="content-feedback"
|
||||
role="status"
|
||||
>
|
||||
링크가 너무 길어요. 최대 8,192자까지 보낼 수 있어요.
|
||||
</p>
|
||||
<p
|
||||
v-if="classified.kind === 'text' && contentBytes > textLimitBytes"
|
||||
class="content-feedback"
|
||||
role="status"
|
||||
>
|
||||
텍스트는 최대 {{ Math.floor(textLimitBytes / 1024) }}KiB까지 보낼 수 있어요.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="source === 'file'">
|
||||
@@ -370,11 +435,17 @@ function size(value) {
|
||||
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
|
||||
</div>
|
||||
<div v-if="selectedVault" class="current-vault">
|
||||
<FileIcon v-if="selectedVault.file" :size="28" /><Link v-else :size="28" /><strong>{{
|
||||
selectedVault.note || selectedVault.file?.name || selectedVault.url
|
||||
<FileIcon v-if="selectedVault.file" :size="28" /><ClipboardPaste
|
||||
v-else-if="selectedVault.kind === 'text'"
|
||||
:size="28"
|
||||
/><Link v-else :size="28" /><strong>{{
|
||||
selectedVault.note ||
|
||||
selectedVault.file?.name ||
|
||||
selectedVault.url ||
|
||||
selectedVault.text
|
||||
}}</strong
|
||||
><small v-if="selectedVault.note">{{
|
||||
selectedVault.file?.name || selectedVault.url
|
||||
selectedVault.file?.name || selectedVault.url || selectedVault.text
|
||||
}}</small
|
||||
><button
|
||||
v-if="!transfer"
|
||||
@@ -395,11 +466,14 @@ function size(value) {
|
||||
:disabled="!mutable"
|
||||
@click="selectVault(item)"
|
||||
>
|
||||
<FileIcon v-if="item.file" :size="18" /><Link v-else :size="18" /><span
|
||||
><strong>{{ item.note || item.file?.name || item.url }}</strong
|
||||
<FileIcon v-if="item.file" :size="18" /><ClipboardPaste
|
||||
v-else-if="item.kind === 'text'"
|
||||
:size="18"
|
||||
/><Link v-else :size="18" /><span
|
||||
><strong>{{ item.note || item.file?.name || item.url || item.text }}</strong
|
||||
><small
|
||||
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
|
||||
{{ item.file ? '파일' : '링크' }}</small
|
||||
{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</small
|
||||
></span
|
||||
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
|
||||
</button>
|
||||
@@ -453,7 +527,7 @@ function size(value) {
|
||||
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
|
||||
<FileIcon :size="14" />대신 파일 선택하기</button
|
||||
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
|
||||
<Link :size="14" />대신 링크 붙여넣기</button
|
||||
<Link :size="14" />대신 클립보드에서 가져오기</button
|
||||
><button
|
||||
v-if="vaultEnabled && source !== 'vault'"
|
||||
class="text-button"
|
||||
|
||||
@@ -28,6 +28,7 @@ export const pendingPeers = ref([]);
|
||||
let pendingTimer;
|
||||
let deviceInfo = {};
|
||||
export const nextCursor = ref(null);
|
||||
export const clipboardTextLimitBytes = ref(262144);
|
||||
export const spaceInviteCodeLength = ref(4);
|
||||
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
|
||||
export async function publishLocation(position) {
|
||||
@@ -147,6 +148,7 @@ export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
device.value = snapshot.device;
|
||||
peers.value = snapshot.devices;
|
||||
clipboardTextLimitBytes.value = snapshot.clipboardTextLimitBytes ?? 262144;
|
||||
pendingPeers.value = pendingPeers.value.filter(
|
||||
(id) => !snapshot.devices.some((peer) => peer.id === id),
|
||||
);
|
||||
@@ -182,7 +184,7 @@ export const refresh = createRefreshQueue(async () => {
|
||||
}
|
||||
});
|
||||
export async function send(targetDeviceId, kind, values) {
|
||||
const content = values.urls.join('\n');
|
||||
const content = kind === 'text' ? values.text : values.urls.join('\n');
|
||||
const { requestId } = await api(
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
@@ -190,7 +192,7 @@ export async function send(targetDeviceId, kind, values) {
|
||||
targetDeviceId,
|
||||
kind,
|
||||
byteSize: new TextEncoder().encode(content).length,
|
||||
count: values.urls.length,
|
||||
count: kind === 'text' ? 1 : values.urls.length,
|
||||
},
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
@@ -204,7 +206,12 @@ async function deliver(id) {
|
||||
if (!draft) return;
|
||||
completing.add(id);
|
||||
try {
|
||||
await api(`/transfers/${id}/complete`, 'POST', { urls: draft.urls }, draft.key);
|
||||
await api(
|
||||
`/transfers/${id}/complete`,
|
||||
'POST',
|
||||
draft.text !== undefined ? { text: draft.text } : { urls: draft.urls },
|
||||
draft.key,
|
||||
);
|
||||
drafts.delete(id);
|
||||
await refresh();
|
||||
return true;
|
||||
|
||||
14
apps/web/src/clipboardAccess.js
Normal file
14
apps/web/src/clipboardAccess.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Safari/Firefox may require a native Paste confirmation. Do not trigger it on selection.
|
||||
export async function mayReadClipboardAutomatically(
|
||||
nav = navigator,
|
||||
touch = nav.maxTouchPoints > 0 ||
|
||||
(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches),
|
||||
) {
|
||||
try {
|
||||
if (!nav.permissions?.query) return false;
|
||||
const { state } = await nav.permissions.query({ name: 'clipboard-read' });
|
||||
return state === 'granted' || (state === 'prompt' && !touch);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { ref } from 'vue';
|
||||
export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
const pending = new Map();
|
||||
const blockedLinks = ref([]);
|
||||
const receivedTexts = ref([]);
|
||||
const receivedLinks = ref([]);
|
||||
function reserve(request) {
|
||||
if (pending.has(request.id)) return;
|
||||
const tabs = [];
|
||||
@@ -45,13 +47,16 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
for (const item of items.filter((i) => i.batchId === request.batchId)) {
|
||||
if (delivery.handled.has(item.accessId)) continue;
|
||||
delivery.handled.add(item.accessId);
|
||||
if (item.file) {
|
||||
if (item.kind === 'text' && typeof item.text === 'string') {
|
||||
receivedTexts.value.push(item);
|
||||
} else if (item.file) {
|
||||
void download(item).catch(onError);
|
||||
} else if (item.url) {
|
||||
const tab = delivery.tabs.shift();
|
||||
if (tab && !tab.closed) {
|
||||
try {
|
||||
tab.location.replace(item.url);
|
||||
receivedLinks.value = [item];
|
||||
void markRead(item).catch(onError);
|
||||
} catch {
|
||||
tab.close();
|
||||
@@ -70,12 +75,16 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
function dispose() {
|
||||
for (const id of pending.keys()) abort(id);
|
||||
blockedLinks.value = [];
|
||||
receivedTexts.value = [];
|
||||
receivedLinks.value = [];
|
||||
}
|
||||
return {
|
||||
reserve,
|
||||
abort,
|
||||
reconcile,
|
||||
blockedLinks,
|
||||
receivedTexts,
|
||||
receivedLinks,
|
||||
opened,
|
||||
dispose,
|
||||
hasPending: () => pending.size > 0,
|
||||
|
||||
@@ -3538,3 +3538,175 @@ a.button {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.initial-device-loading {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.initial-device-loading p {
|
||||
margin: 0;
|
||||
}
|
||||
.name-field-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.name-count {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.peer-card,
|
||||
.qr-connect-card {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.single-composer input.current-url {
|
||||
min-height: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.current-text {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
height: 190px;
|
||||
resize: none;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.clipboard-count {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.received-text-dialog {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100dvh - 48px);
|
||||
overflow: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 24px;
|
||||
}
|
||||
.received-text-dialog header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.received-text-dialog h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.received-text-dialog h2 svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.received-text-preview,
|
||||
.account-text-preview {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
tab-size: 4;
|
||||
font-family: inherit;
|
||||
line-height: 1.7;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
.received-text-preview {
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: var(--soft);
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.received-text-dialog > .button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.received-link-notice {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 32px #0004;
|
||||
font-size: 12px;
|
||||
}
|
||||
.received-link-notice .text-button {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.received-link-notice > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.received-text-dialog {
|
||||
padding: 20px;
|
||||
}
|
||||
.received-text-dialog h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.received-text-preview {
|
||||
max-height: 45dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.current-vault strong,
|
||||
.current-vault small {
|
||||
white-space: pre-wrap;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
.vault-choice strong {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.content-pane.clipboard-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.clipboard-pane .content-heading {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clipboard-pane .current-text {
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
height: 0;
|
||||
}
|
||||
.clipboard-pane .current-url,
|
||||
.clipboard-pane .clipboard-count,
|
||||
.clipboard-pane .content-feedback,
|
||||
.clipboard-pane .composer-transfer-state {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user