Add plain text clipboard transfers and refine input feedback
This commit is contained in:
36
packages/shared/src/clipboard.js
Normal file
36
packages/shared/src/clipboard.js
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
|
||||
Reference in New Issue
Block a user