Add plain text clipboard transfers and refine input feedback

This commit is contained in:
2026-10-13 14:07:03 +09:00
parent af004a2942
commit 8a8df417aa
32 changed files with 1263 additions and 197 deletions

View File

@@ -0,0 +1,36 @@
/** @param {string} value @returns {{kind: 'url', url: string} | {kind: 'text', text: string}} */
export function classifyClipboard(value) {
const candidate = value.trim();
const text = () => ({ kind: /** @type {'text'} */ ('text'), text: value });
if (!candidate || /[\r\n]/.test(value) || /\s/.test(candidate)) return text();
// Recognize a bare domain without interpreting an ordinary sentence as a URL.
const bareDomain =
/^(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+[\p{L}]{2,}(?::\d{1,5})?(?:[/?#][^\s]*)?$/u.test(
candidate,
);
try {
if (!/^https?:\/\//i.test(candidate) && !bareDomain) return text();
const url = new URL(/^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`);
if (
!['http:', 'https:'].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password
)
return text();
return { kind: 'url', url: url.href };
} catch {
return text();
}
}
/** @param {string} value */
export function clipboardCharacterCount(value) {
if (typeof Intl.Segmenter === 'function') {
let count = 0;
for (const _ of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value))
count++;
return count;
}
return Array.from(value).length;
}

View File

@@ -1,11 +1,13 @@
import { z } from 'zod';
export { classifyClipboard, clipboardCharacterCount } from './clipboard.js';
export const DEVICE_NAME_MAX_LENGTH = 40;
export const devicePatch = z
.object({
name: z
.string()
.trim()
.min(1)
.max(40)
.max(DEVICE_NAME_MAX_LENGTH)
.refine((v) => !/[\x00-\x1f\x7f<>]/.test(v), '제어문자와 HTML 문자는 사용할 수 없습니다.')
.optional(),
discoverable: z.boolean().optional(),
@@ -25,7 +27,7 @@ export const fileMetadata = z
export const requestInput = z
.object({
targetDeviceId: z.uuid(),
kind: z.enum(['url', 'file']),
kind: z.enum(['url', 'text', 'file']),
byteSize: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
count: z.number().int().min(1).max(1000),
files: z.array(fileMetadata).max(1000).optional(),
@@ -36,13 +38,13 @@ export const requestInput = z
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
: !v.files && (v.kind !== 'text' || v.count === 1),
);
export const completeInput = z
.object({
urls: z.array(z.string().max(8192)).min(1).max(20),
})
.strict();
export const completeInput = z.union([
z.object({ urls: z.array(z.string().max(8192)).min(1).max(20) }).strict(),
z.object({ text: z.string().min(1).max(1048576) }).strict(),
]);
export type CompleteInput = z.infer<typeof completeInput>;
export const spaceInput = z.object({ approvalRequired: z.boolean().default(false) }).strict();
export const joinInput = z
.object({
@@ -73,7 +75,7 @@ export type TransferRequest = {
batchId: string;
senderDevice: Device | null;
recipientDevice: Device | null;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
@@ -94,8 +96,9 @@ export type Item = {
accessId: string;
id: string;
batchId: string;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
url: string | null;
text?: string | null;
file?: { id: string; name: string; size: number; type: string };
role: 'sender' | 'recipient' | 'self';
read: boolean;
@@ -149,7 +152,7 @@ export type AccountItem = Item & {
export type EmailRequest = {
id: string;
batchId: string;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
@@ -177,5 +180,5 @@ export const emailRequestInput = z
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
: !v.files && (v.kind !== 'text' || v.count === 1),
);