feat(web): connect quick share flow

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:37 +08:00
1 parent 53768a195d
commit e02e024605
138 files changed
+10614

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { parseChannelLink } from './channelLink';
const ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
describe('parseChannelLink', () => {
it('成员链接:带 k 即 enc', () => {
const r = parseChannelLink(`https://webdrop.example/c/${ID}#k=${KEY}`);
expect(r).toMatchObject({ ok: true, link: { id: ID, mode: 'enc', isOwner: false } });
});
it('所有者链接:带 a', () => {
const r = parseChannelLink(`https://webdrop.example/c/${ID}#k=${KEY}&a=${KEY}`);
expect(r).toMatchObject({ ok: true, link: { mode: 'enc', isOwner: true } });
});
it('不带 k 即 raw;带 w 表示可上传', () => {
const r = parseChannelLink(`https://webdrop.example/c/${ID}#w=${KEY}`);
expect(r).toMatchObject({ ok: true, link: { mode: 'raw', canWrite: true } });
});
it('忽略首尾空白', () => {
expect(parseChannelLink(` https://x.example/c/${ID} `).ok).toBe(true);
});
it.each([
['不是链接', 'hello', 'bad_url'],
['路径不对', `https://x.example/other/${ID}`, 'bad_url'],
['id 长度不对', 'https://x.example/c/abc', 'bad_id'],
['密钥长度不对', `https://x.example/c/${ID}#k=short`, 'bad_key'],
])('%s → %s', (_n, input, error) => {
expect(parseChannelLink(input)).toEqual({ ok: false, error });
});
});
+43
View File
@@ -0,0 +1,43 @@
/** protocol §2:频道链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
export interface ParsedChannelLink {
id: string;
mode: 'enc' | 'raw';
/** 含 a:所有者链接 */
isOwner: boolean;
/** raw 模式含 w:可上传 */
canWrite: boolean;
keys: { k?: string; a?: string; w?: string };
}
export type LinkError = 'bad_url' | 'bad_id' | 'bad_key';
export type LinkResult = { ok: true; link: ParsedChannelLink } | { ok: false; error: LinkError };
const ID_RE = /^[A-Za-z0-9_-]{22}$/; // 16 B → base64url 无填充
const KEY_RE = /^[A-Za-z0-9_-]{43}$/; // 32 B → base64url 无填充
export function parseChannelLink(input: string): LinkResult {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return { ok: false, error: 'bad_url' };
}
const m = /^\/c\/([^/]+)\/?$/.exec(url.pathname);
if (!m || !m[1] || !ID_RE.test(m[1])) return { ok: false, error: m ? 'bad_id' : 'bad_url' };
const params = new URLSearchParams(url.hash.replace(/^#/, ''));
const keys: ParsedChannelLink['keys'] = {};
for (const name of ['k', 'a', 'w'] as const) {
const v = params.get(name);
if (v === null) continue;
if (!KEY_RE.test(v)) return { ok: false, error: 'bad_key' };
keys[name] = v;
}
// 降级防御:带 k 就是 enc,不带就是 raw
return {
ok: true,
link: { id: m[1], mode: keys.k ? 'enc' : 'raw', isOwner: keys.a !== undefined, canWrite: keys.w !== undefined, keys },
};
}
+26
View File
@@ -0,0 +1,26 @@
export interface ClipboardPayload {
files: File[];
text: string;
}
/** 读取剪贴板(需要用户手势与权限)。失败或被拒绝返回 null */
export async function readClipboard(): Promise<ClipboardPayload | null> {
try {
if (!navigator.clipboard.read) return { files: [], text: await navigator.clipboard.readText() };
const files: File[] = [];
let text = '';
for (const entry of await navigator.clipboard.read()) {
for (const type of entry.types) {
const blob = await entry.getType(type);
if (type.startsWith('image/')) {
files.push(new File([blob], `粘贴-${Date.now()}.${type.split('/')[1] ?? 'png'}`, { type }));
} else if (type === 'text/plain' && !text) {
text = await blob.text();
}
}
}
return { files, text };
} catch {
return null;
}
}
+17
View File
@@ -0,0 +1,17 @@
export function saveBlob(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { isLongText, previewKindOf } from './fileKind';
describe('previewKindOf', () => {
it.each([
['image/png', 'a.png', 'image'],
['video/mp4', 'a.mp4', 'video'],
['application/zip', 'a.zip', 'archive'],
['application/octet-stream', 'a.tar.gz', 'archive'],
['', 'server.log', 'text'],
['application/json', 'a.json', 'text'],
['application/pdf', 'a.pdf', 'none'],
])('%s %s → %s', (mime, name, expected) => {
expect(previewKindOf(mime, name)).toBe(expected);
});
});
describe('isLongText', () => {
it('短文本不算长', () => expect(isLongText('hello')).toBe(false));
it('超过 8 行算长', () => expect(isLongText('a\n'.repeat(9))).toBe(true));
it('超过 400 字符算长', () => expect(isLongText('a'.repeat(401))).toBe(true));
});
+20
View File
@@ -0,0 +1,20 @@
import type { PreviewKind } from '@/core-bridge/types';
const ARCHIVE_EXT = /\.(zip|tar|gz|tgz|7z|rar|xz|bz2)$/i;
const TEXT_EXT = /\.(txt|md|log|json|csv|ya?ml|toml|ini|xml|js|ts|tsx|css|html|py|rs|go|c|cpp|h|sh)$/i;
export function previewKindOf(mime: string, name: string): PreviewKind {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (ARCHIVE_EXT.test(name) || /zip|x-tar|x-7z|rar|gzip/.test(mime)) return 'archive';
if (mime.startsWith('text/') || mime === 'application/json' || TEXT_EXT.test(name)) return 'text';
return 'none';
}
const LONG_TEXT_CHARS = 400;
const LONG_TEXT_LINES = 8;
export function isLongText(text: string): boolean {
return text.length > LONG_TEXT_CHARS || text.split('\n').length > LONG_TEXT_LINES;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import type { FileItem, TextItem } from '@/core-bridge/types';
import { isFiltering, matchesFilter, NO_FILTER } from './filter';
const base = { ts: 1, deviceId: 'self', state: { kind: 'complete' } as const };
const text: TextItem = { ...base, id: 't', kind: 'text', text: '明天 10 点开会' };
const zip: FileItem = { ...base, id: 'z', kind: 'file', name: 'Project.zip', mime: 'application/zip', size: 1, remaining: null, preview: 'archive' };
describe('matchesFilter', () => {
it('无筛选时全部通过', () => {
expect(matchesFilter(text, NO_FILTER)).toBe(true);
expect(isFiltering(NO_FILTER)).toBe(false);
});
it('按类型', () => {
expect(matchesFilter(zip, { query: '', kind: 'archive' })).toBe(true);
expect(matchesFilter(text, { query: '', kind: 'file' })).toBe(false);
expect(matchesFilter(zip, { query: '', kind: 'image' })).toBe(false);
});
it('按关键字,不区分大小写,文本看内容、文件看名称', () => {
expect(matchesFilter(zip, { query: 'project', kind: 'all' })).toBe(true);
expect(matchesFilter(text, { query: '开会', kind: 'all' })).toBe(true);
expect(matchesFilter(text, { query: 'zip', kind: 'all' })).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { Item } from '@/core-bridge/types';
export type KindFilter = 'all' | 'text' | 'file' | 'image' | 'video' | 'archive';
export interface ItemFilter {
query: string;
kind: KindFilter;
}
export const NO_FILTER: ItemFilter = { query: '', kind: 'all' };
export function isFiltering(f: ItemFilter): boolean {
return f.query.trim() !== '' || f.kind !== 'all';
}
function matchesKind(item: Item, kind: KindFilter): boolean {
switch (kind) {
case 'all':
return true;
case 'text':
return item.kind === 'text';
case 'file':
return item.kind === 'file';
case 'image':
case 'video':
case 'archive':
return item.kind === 'file' && item.preview === kind;
}
}
export function matchesFilter(item: Item, f: ItemFilter): boolean {
if (!matchesKind(item, f.kind)) return false;
const q = f.query.trim().toLowerCase();
if (q === '') return true;
const haystack = item.kind === 'text' ? item.text : item.name;
return haystack.toLowerCase().includes(q);
}
+33
View File
@@ -0,0 +1,33 @@
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
}
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
export function formatTime(ts: number): string {
return timeFmt.format(ts);
}
export function formatAgo(ts: number, now = Date.now()): string {
const m = Math.max(0, Math.round((now - ts) / 60000));
if (m < 1) return '刚刚';
if (m < 60) return `${m} 分钟前`;
return `${Math.round(m / 60)} 小时前`;
}
/** 距离过期还剩多久。到期后返回「已过期」 */
export function formatRemaining(expiresAt: number, now = Date.now()): string {
const m = Math.round((expiresAt - now) / 60000);
if (m <= 0) return '已过期';
if (m < 60) return `剩 ${m} 分钟`;
if (m < 60 * 48) return `剩 ${Math.round(m / 60)} 小时`;
return `剩 ${Math.round(m / 1440)} 天`;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { textView } from './inline';
describe('textView', () => {
it('普通文本原样显示', () => {
expect(textView('明天开会')).toEqual({ json: false, body: '明天开会', long: false });
});
it('JSON 对象被格式化', () => {
const v = textView('{"a":1,"b":[1,2]}');
expect(v.json).toBe(true);
expect(v.body).toBe('{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}');
expect(v.long).toBe(false);
});
it('看起来像 JSON 但不合法,按普通文本', () => {
expect(textView('{not json').json).toBe(false);
});
it('JSON 很长时标记为 long', () => {
const big = JSON.stringify(Array.from({ length: 40 }, (_, i) => i));
expect(textView(big)).toMatchObject({ json: true, long: true });
});
it('文本很长时标记为 long', () => {
expect(textView('x'.repeat(500)).long).toBe(true);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { isLongText } from './fileKind';
/** 不大的视频才直接放进列表播放 */
export const VIDEO_INLINE_MAX = 25 * 1024 * 1024;
const JSON_MAX_LINES = 14;
const JSON_MAX_CHARS = 1200;
const JSON_PARSE_MAX_CHARS = 100_000;
export interface TextView {
json: boolean;
/** 要显示的内容:JSON 已格式化 */
body: string;
/** 太长,列表里只显示前几行 */
long: boolean;
}
/** 文本与 JSON 的内联显示:能解析成对象或数组的当作 JSON 格式化,否则原样显示 */
export function textView(text: string): TextView {
const t = text.trim();
if ((t.startsWith('{') || t.startsWith('[')) && t.length <= JSON_PARSE_MAX_CHARS) {
try {
const body = JSON.stringify(JSON.parse(t) as unknown, null, 2);
return { json: true, body, long: body.split('\n').length > JSON_MAX_LINES || body.length > JSON_MAX_CHARS };
} catch {
/* 不是合法 JSON,按普通文本显示 */
}
}
return { json: false, body: text, long: isLongText(text) };
}
+141
View File
@@ -0,0 +1,141 @@
/** 由 Python qrcode 库生成的参考矩阵,用来逐模块验证 lib/qr.ts(纠错 M、字节模式、固定掩码) */
export const QR_FIXTURES = {
"short": {
"text": "hi",
"mask": 2,
"version": 1,
"rows": [
"111111100111101111111",
"100000100110101000001",
"101110101101101011101",
"101110101100101011101",
"101110101001101011101",
"100000101100101000001",
"111111101010101111111",
"000000001011100000000",
"101111100000101111100",
"011101010010100100001",
"001100110101010011110",
"111010000100000110100",
"111010100001010010101",
"000000001001111001001",
"111111100010101100010",
"100000101111111001001",
"101110101000100100100",
"101110101110100100100",
"101110101001010011100",
"100000100110000110100",
"111111101011010011110"
]
},
"member": {
"text": "https://webdrop.example/c/k3Fq9xWm2LpA7sTdQ8vZ1n#k=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF",
"mask": 5,
"version": 6,
"rows": [
"11111110011101001111001001010000101111111",
"10000010101011100010111001100000001000001",
"10111010100011111000100010011001101011101",
"10111010101101010011000100100000101011101",
"10111010000011011101010001011010101011101",
"10000010001110001100100011101100001000001",
"11111110101010101010101010101010101111111",
"00000000101011100001001100110001000000000",
"10000010110010100101001011110100111001110",
"10001001000000110111111100010111010110011",
"00001010000101011000100010110000101000000",
"11010000010100101000100010010011110111000",
"10001111000000010101101000011000111000010",
"00011100011011100000111011001001111110011",
"00011010001001000101011001011100110111100",
"00010000101100000011000010100010110001111",
"00011111011010001111011001101101010100011",
"00000001011001110101100001010010111111011",
"11110011111100110001011110011001010111011",
"00111100011010001110001101011010100011011",
"11110011000010011001010000111101100001011",
"01001101000010010011010100110001111111100",
"01100111111001001110111010011000111000100",
"01110101010100000001001010011000110000001",
"10111111011001010011100000110000101110000",
"01011001100111001111101100011011010111010",
"10000110001011001110010000110010111110000",
"01010100001110011010000000100011011101110",
"11010011111101001101101010110001100101110",
"10100100110101011010000001111010111011011",
"11110010101100111001000011010011110011011",
"10000001110000001010000100100001101011010",
"10111110000110111100101101001000111111101",
"00000000100111111010010100000100100010000",
"11111110010001011010101000010011101011000",
"10000010001100001000010010100001100011011",
"10111010010100001110110001100101111111000",
"10111010010101110001000111110001000101010",
"10111010010101100100010001111110111110000",
"10000010010010001101001010101010011011101",
"11111110100100101101001011110101001111000"
]
},
"owner": {
"text": "https://webdrop.example/c/k3Fq9xWm2LpA7sTdQ8vZ1n#k=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF&a=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF",
"mask": 0,
"version": 8,
"rows": [
"1111111001010001110111101011111001100000101111111",
"1000001011001000001100011001001001111011101000001",
"1011101001001011010110011010110111011001101011101",
"1011101000000010011000010010010001100001001011101",
"1011101010010011000010111111111111111100001011101",
"1000001001101101010000100010111011111110001000001",
"1111111010101010101010101010101010101010101111111",
"0000000000001111111100100010011001101010000000000",
"1010101000001010110111111111111111101001000010010",
"1000000001010111100000000001000000000101000110101",
"0111111011101111011010001010000001010000010111111",
"0110010110001110111001010000111111111110011000001",
"1000001000101010000011011000100111101111000110111",
"0100010001010000110010100100100100010001010101101",
"1111001000111110011000010100000010001101100001111",
"1111000011111001011010101001011100100000111010010",
"0010101111110010100100101001011111111000001011000",
"1001010110000111100100000001100001010000000000001",
"0011101110000101100001010101011101011001110010101",
"1110000010100100001000001011100010001101000010000",
"1010111011011110001100011001111011111000000011010",
"0101010101000101101000000010010000011000010011011",
"0000111110001001110001111110000011011001111111011",
"0101100011100000000001100010001010011100100010000",
"1001101011110101011100101011111110011101101011100",
"0001100010111011110000100010100110011001100010001",
"0110111110110000101010111110101111100000111110001",
"1010010011000001000011111101101010101001101101000",
"1000111011110011111111001111111111111000000011001",
"1010010000000010010101001110110000001001100000011",
"0110001110000010000111000111010100110111111110011",
"0011100010011101001101001011011111101100100010010",
"0110011000110111100110100110111111101100110111110",
"1001110110111111101001100101100000011000001000011",
"0101011110011011111010111111100001011111001100111",
"1011010101110111100110111000001111110000011100011",
"1011011011101111011010000001110110111010111111000",
"0101010101111011001001100001100110000000011000000",
"0100011010101000110111100111100110010101011010111",
"0111000110010110000010111001100101100110100000000",
"1110001111101011100010111111100111111100111110000",
"0000000010011011100001100011110000000100100010010",
"1111111000111011110001101011110100010101101010011",
"1000001001011001000101100011111110011110100010010",
"1011101011000111100011111110111110011111111110001",
"1011101001000010011011101100100001011001100011110",
"1011101011100110000000101110110010001001101111100",
"1000001000111010001010100101001000000110000100010",
"1111111010010000110111101111111110011010011010011"
]
}
} as const;
/** 同一段内容(所有者链接)在 8 种掩码下的参考矩阵:自动选掩码的结果必须等于其中之一 */
export const QR_ALL_MASKS = {
text: "https://webdrop.example/c/k3Fq9xWm2LpA7sTdQ8vZ1n#k=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF&a=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF",
rows: [["1111111001010001110111101011111001100000101111111", "1000001011001000001100011001001001111011101000001", "1011101001001011010110011010110111011001101011101", "1011101000000010011000010010010001100001001011101", "1011101010010011000010111111111111111100001011101", "1000001001101101010000100010111011111110001000001", "1111111010101010101010101010101010101010101111111", "0000000000001111111100100010011001101010000000000", "1010101000001010110111111111111111101001000010010", "1000000001010111100000000001000000000101000110101", "0111111011101111011010001010000001010000010111111", "0110010110001110111001010000111111111110011000001", "1000001000101010000011011000100111101111000110111", "0100010001010000110010100100100100010001010101101", "1111001000111110011000010100000010001101100001111", "1111000011111001011010101001011100100000111010010", "0010101111110010100100101001011111111000001011000", "1001010110000111100100000001100001010000000000001", "0011101110000101100001010101011101011001110010101", "1110000010100100001000001011100010001101000010000", "1010111011011110001100011001111011111000000011010", "0101010101000101101000000010010000011000010011011", "0000111110001001110001111110000011011001111111011", "0101100011100000000001100010001010011100100010000", "1001101011110101011100101011111110011101101011100", "0001100010111011110000100010100110011001100010001", "0110111110110000101010111110101111100000111110001", "1010010011000001000011111101101010101001101101000", "1000111011110011111111001111111111111000000011001", "1010010000000010010101001110110000001001100000011", "0110001110000010000111000111010100110111111110011", "0011100010011101001101001011011111101100100010010", "0110011000110111100110100110111111101100110111110", "1001110110111111101001100101100000011000001000011", "0101011110011011111010111111100001011111001100111", "1011010101110111100110111000001111110000011100011", "1011011011101111011010000001110110111010111111000", "0101010101111011001001100001100110000000011000000", "0100011010101000110111100111100110010101011010111", "0111000110010110000010111001100101100110100000000", "1110001111101011100010111111100111111100111110000", "0000000010011011100001100011110000000100100010010", "1111111000111011110001101011110100010101101010011", "1000001001011001000101100011111110011110100010010", "1011101011000111100011111110111110011111111110001", "1011101001000010011011101100100001011001100011110", "1011101011100110000000101110110010001001101111100", "1000001000111010001010100101001000000110000100010", "1111111010010000110111101111111110011010011010011"], ["1111111010000100100010111110101100110100101111111", "1000001000011101011001001100011100101111101000001", "1011101010011110000011001111100010001101101011101", "1011101001010111001101000111000100110101001011101", "1011101001000110010111111110101010101000001011101", "1000001010111000000101100011101110101010001000001", "1111111010101010101010101010101010101010101111111", "0000000001011010101001100011001100111111000000000", "1010001101011111100010111110101010111100000100101", "1101010100000010110101010100010101010000010011111", "0010101110111010001111011111010100000101000010101", "0011000011011011101100000101101010101011001101011", "1101011101111111010110001101110010111010010011101", "0001000100000101100111110001110001000100000000111", "1010011101101011001101000001010111011000110100101", "1010010110101100001111111100001001110101101111000", "0111111010100111110001111100001010101101011110010", "1100000011010010110001010100110100000101010101011", "0110111011010000110100000000001000001100100111111", "1011010111110001011101011110110111011000010111010", "1111101110001011011001001100101110101101010110000", "0000000000010000111101010111000101001101000110001", "0101111111011100100100111111010110001100111110001", "0000100010110101010100100011011111001001100011010", "1100101010100000001001101010101011001000101010110", "0100100011101110100101100011110011001100100011011", "0011111111100101111111111111111010110101111111011", "1111000110010100010110101000111111111100111000010", "1101101110100110101010011010101010101101010110011", "1111000101010111000000011011100101011100110101001", "0011011011010111010010010010000001100010101011001", "0110110111001000011000011110001010111001110111000", "0011001101100010110011110011101010111001100010100", "1100100011101010111100110000110101001101011101001", "0000001011001110101111101010110100001010011001101", "1110000000100010110011101101011010100101001001001", "1110001110111010001111010100100011101111101010010", "0000000000101110011100110100110011010101001101010", "0100011111111101100010110010110011000000001111101", "0111000011000011010111101100110000110011110101010", "1110001010111110110111111110110010101001111111010", "0000000011001110110100100010100101010001100011000", "1111111011101110100100101010100001000000101011001", "1000001000001100010000100010101011001011100011000", "1011101000010010110110111111101011001010111111011", "1011Line truncated
} as const;
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { encodeQr } from './qr';
import { QR_ALL_MASKS, QR_FIXTURES } from './qr.fixtures';
const toRows = (grid: boolean[][]) => grid.map((r) => r.map((c) => (c ? '1' : '0')).join(''));
describe('encodeQr', () => {
// 参考矩阵来自 Python qrcode(纠错 M、字节模式)。逐模块一致,说明版本选择、
// Reed-Solomon、交错、功能图形、格式位与版本位(v7+)都正确
it.each(Object.entries(QR_FIXTURES))('与参考实现逐模块一致:%s', (_name, fx) => {
expect(toRows(encodeQr(fx.text, fx.mask))).toEqual(fx.rows);
});
it('自动选掩码:结果必须等于参考实现 8 种掩码之一(验证掩码应用与还原)', () => {
const got = toRows(encodeQr(QR_ALL_MASKS.text)).join('|');
expect(QR_ALL_MASKS.rows.map((r) => r.join('|'))).toContain(got);
});
it('自动选择掩码时,尺寸与版本一致', () => {
const fx = QR_FIXTURES.member;
expect(encodeQr(fx.text)).toHaveLength(fx.rows.length);
});
it('内容太长时抛错', () => {
expect(() => encodeQr('x'.repeat(300))).toThrow(RangeError);
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* 极简二维码编码器:字节模式、纠错等级 M、版本 1–10(最多 213 字节)。
* 足够容纳频道链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
* 为什么自写:避免为一个 100 行量级的功能引入依赖(frontend.md 依赖策略)。
*/
const ECC_PER_BLOCK_M = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26] as const;
const NUM_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5] as const;
const MAX_VERSION = 10;
const FORMAT_M = 0; // 纠错等级 M 的格式位值
type Grid = boolean[][];
function rawModules(ver: number): number {
let n = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const align = Math.floor(ver / 7) + 2;
n -= (25 * align - 10) * align - 55;
if (ver >= 7) n -= 36;
}
return n;
}
const rawCodewords = (ver: number) => Math.floor(rawModules(ver) / 8);
const dataCodewords = (ver: number) => rawCodewords(ver) - ECC_PER_BLOCK_M[ver]! * NUM_BLOCKS_M[ver]!;
// ---- GF(256) 与 Reed-Solomon ----
function gfMul(x: number, y: number): number {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
function rsDivisor(degree: number): number[] {
const result = new Array<number>(degree).fill(0);
result[degree - 1] = 1;
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < degree; j++) {
result[j] = gfMul(result[j]!, root);
if (j + 1 < degree) result[j]! ^= result[j + 1]!;
}
root = gfMul(root, 2);
}
return result;
}
function rsRemainder(data: number[], divisor: number[]): number[] {
const result = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ result.shift()!;
result.push(0);
divisor.forEach((coef, i) => {
result[i]! ^= gfMul(coef, factor);
});
}
return result;
}
// ---- 数据编码 ----
function encodeData(bytes: Uint8Array): { ver: number; codewords: number[] } {
let ver = 1;
const bitsFor = (v: number) => 4 + (v < 10 ? 8 : 16) + bytes.length * 8;
while (ver <= MAX_VERSION && bitsFor(ver) > dataCodewords(ver) * 8) ver++;
if (ver > MAX_VERSION) throw new RangeError('内容太长,无法编码为二维码');
const bits: number[] = [];
const push = (value: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((value >>> i) & 1);
};
push(0b0100, 4);
push(bytes.length, ver < 10 ? 8 : 16);
for (const b of bytes) push(b, 8);
const capacity = dataCodewords(ver) * 8;
push(0, Math.min(4, capacity - bits.length));
push(0, (8 - (bits.length % 8)) % 8);
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) push(pad, 8);
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
}
return { ver, codewords: addEccAndInterleave(data, ver) };
}
function addEccAndInterleave(data: number[], ver: number): number[] {
const blocks = NUM_BLOCKS_M[ver]!;
const eccLen = ECC_PER_BLOCK_M[ver]!;
const raw = rawCodewords(ver);
const shortLen = Math.floor(raw / blocks);
const numShort = blocks - (raw % blocks);
const divisor = rsDivisor(eccLen);
const parts: number[][] = [];
for (let i = 0, k = 0; i < blocks; i++) {
const len = shortLen - eccLen + (i < numShort ? 0 : 1);
const dat = data.slice(k, k + len);
k += len;
const ecc = rsRemainder(dat, divisor);
if (i < numShort) dat.push(0);
parts.push(dat.concat(ecc));
}
const out: number[] = [];
for (let i = 0; i < parts[0]!.length; i++) {
parts.forEach((block, j) => {
if (i !== shortLen - eccLen || j >= numShort) out.push(block[i]!);
});
}
return out;
}
// ---- 矩阵 ----
class Matrix {
readonly size: number;
readonly modules: Grid;
readonly isFn: Grid;
constructor(readonly ver: number) {
this.size = ver * 4 + 17;
this.modules = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.isFn = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.drawFunctionPatterns();
}
private setFn(x: number, y: number, dark: boolean) {
this.modules[y]![x] = dark;
this.isFn[y]![x] = true;
}
private drawFunctionPatterns() {
const { size, ver } = this;
for (let i = 0; i < size; i++) {
this.setFn(6, i, i % 2 === 0);
this.setFn(i, 6, i % 2 === 0);
}
this.finder(3, 3);
this.finder(size - 4, 3);
this.finder(3, size - 4);
const pos = alignPositions(ver);
pos.forEach((cy, i) =>
pos.forEach((cx, j) => {
const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0);
if (!corner) this.align(cx, cy);
}),
);
this.drawFormat(0);
this.drawVersion();
}
private finder(cx: number, cy: number) {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const x = cx + dx;
const y = cy + dy;
if (x >= 0 && x < this.size && y >= 0 && y < this.size) this.setFn(x, y, dist !== 2 && dist !== 4);
}
}
}
private align(cx: number, cy: number) {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) this.setFn(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
drawFormat(mask: number) {
const data = (FORMAT_M << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
const bit = (i: number) => ((bits >>> i) & 1) !== 0;
const { size } = this;
for (let i = 0; i <= 5; i++) this.setFn(8, i, bit(i));
this.setFn(8, 7, bit(6));
this.setFn(8, 8, bit(7));
this.setFn(7, 8, bit(8));
for (let i = 9; i < 15; i++) this.setFn(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) this.setFn(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) this.setFn(8, size - 15 + i, bit(i));
this.setFn(8, size - 8, true);
}
private drawVersion() {
if (this.ver < 7) return;
let rem = this.ver;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (this.ver << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = this.size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.setFn(a, b, dark);
this.setFn(b, a, dark);
}
}
drawCodewords(data: number[]) {
const { size } = this;
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (!this.isFn[y]![x] && i < data.length * 8) {
this.modules[y]![x] = ((data[i >>> 3]! >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
}
}
applyMask(mask: number) {
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
if (!this.isFn[y]![x] && maskCondition(mask, x, y)) this.modules[y]![x] = !this.modules[y]![x];
}
}
}
/** 简化的惩罚分:同色连续、同色 2×2 块、深浅失衡。只影响掩码选择,不影响可读性 */
penalty(): number {
const { size, modules } = this;
let score = 0;
for (let y = 0; y < size; y++) {
for (const horizontal of [true, false]) {
let run = 1;
for (let i = 1; i < size; i++) {
const cur = horizontal ? modules[y]![i] : modules[i]![y];
const prev = horizontal ? modules[y]![i - 1] : modules[i - 1]![y];
run = cur === prev ? run + 1 : 1;
if (run === 5) score += 3;
else if (run > 5) score += 1;
}
}
}
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const c = modules[y]![x];
if (c === modules[y]![x + 1] && c === modules[y + 1]![x] && c === modules[y + 1]![x + 1]) score += 3;
}
}
const dark = modules.reduce((n, row) => n + row.filter(Boolean).length, 0);
return score + Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10;
}
}
function alignPositions(ver: number): number[] {
if (ver === 1) return [];
const n = Math.floor(ver / 7) + 2;
const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
const result = [6];
for (let pos = ver * 4 + 10; result.length < n; pos -= step) result.splice(1, 0, pos);
return result;
}
function maskCondition(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0: return (x + y) % 2 === 0;
case 1: return y % 2 === 0;
case 2: return x % 3 === 0;
case 3: return (x + y) % 3 === 0;
case 4: return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
case 5: return ((x * y) % 2) + ((x * y) % 3) === 0;
case 6: return ((((x * y) % 2) + ((x * y) % 3)) % 2) === 0;
default: return ((((x + y) % 2) + ((x * y) % 3)) % 2) === 0;
}
}
/** 编码为深浅矩阵(true = 深色)。mask 仅供测试固定掩码,默认自动选择 */
export function encodeQr(text: string, mask?: number): Grid {
const { ver, codewords } = encodeData(new TextEncoder().encode(text));
const m = new Matrix(ver);
m.drawCodewords(codewords);
let best = mask ?? 0;
if (mask === undefined) {
let bestScore = Infinity;
for (let candidate = 0; candidate < 8; candidate++) {
m.applyMask(candidate);
m.drawFormat(candidate);
const score = m.penalty();
if (score < bestScore) {
bestScore = score;
best = candidate;
}
m.applyMask(candidate); // 还原
}
}
m.applyMask(best);
m.drawFormat(best);
return m.modules;
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseShareLink } from './shareLink';
const ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
describe('parseShareLink', () => {
it('解析访客链接', () => {
expect(parseShareLink(`https://webdrop.example/s/${ID}#k=${KEY}`)).toEqual({ ok: true, link: { id: ID, key: KEY } });
});
it('忽略首尾空白', () => {
expect(parseShareLink(` https://x.example/s/${ID}#k=${KEY} `).ok).toBe(true);
});
it.each([
['不是链接', 'hello', 'bad_url'],
['频道链接不是分享链接', `https://x.example/c/${ID}#k=${KEY}`, 'bad_url'],
['id 长度不对', `https://x.example/s/abc#k=${KEY}`, 'bad_id'],
['缺少密钥', `https://x.example/s/${ID}`, 'bad_key'],
['密钥长度不对', `https://x.example/s/${ID}#k=short`, 'bad_key'],
])('%s → %s', (_n, input, error) => {
expect(parseShareLink(input)).toEqual({ ok: false, error });
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { LinkError } from './channelLink';
/** 便捷分享的访客链接:`/s/{id}#k={K}`。分享始终是加密模式,没有所有者链接(完成时已丢弃) */
export interface ParsedShareLink {
id: string;
/** 解密密钥 K,只放内存 */
key: string;
}
export type ShareLinkResult = { ok: true; link: ParsedShareLink } | { ok: false; error: LinkError };
const ID_RE = /^[A-Za-z0-9_-]{22}$/;
const KEY_RE = /^[A-Za-z0-9_-]{43}$/;
export function parseShareLink(input: string): ShareLinkResult {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return { ok: false, error: 'bad_url' };
}
const m = /^\/s\/([^/]+)\/?$/.exec(url.pathname);
if (!m?.[1]) return { ok: false, error: 'bad_url' };
if (!ID_RE.test(m[1])) return { ok: false, error: 'bad_id' };
const key = new URLSearchParams(url.hash.replace(/^#/, '')).get('k');
if (key === null || !KEY_RE.test(key)) return { ok: false, error: 'bad_key' };
return { ok: true, link: { id: m[1], key } };
}
+59
View File
@@ -0,0 +1,59 @@
import { useSyncExternalStore } from 'react';
export type ThemePref = 'system' | 'light' | 'dark';
const KEY = 'wd.theme';
const listeners = new Set<() => void>();
function read(): ThemePref {
try {
const v = localStorage.getItem(KEY);
return v === 'light' || v === 'dark' ? v : 'system';
} catch {
return 'system';
}
}
let current: ThemePref = read();
/** 让手机浏览器的状态栏/地址栏颜色跟随当前页面底色 */
function syncThemeColor(): void {
const color = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
if (color) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', color);
}
function apply(pref: ThemePref): void {
const root = document.documentElement;
if (pref === 'system') delete root.dataset.theme;
else root.dataset.theme = pref;
syncThemeColor();
}
/** 在渲染前调用,避免首屏闪烁 */
export function initTheme(): void {
apply(current);
// 跟随系统时,系统切换亮暗也要更新状态栏颜色
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncThemeColor);
}
export function setTheme(pref: ThemePref): void {
current = pref;
apply(pref);
try {
if (pref === 'system') localStorage.removeItem(KEY);
else localStorage.setItem(KEY, pref);
} catch {
/* 无存储时只在本次会话生效 */
}
for (const l of listeners) l();
}
export function useTheme(): ThemePref {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
},
() => current,
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
const MARK = 'wdSheet';
/** 当前历史记录是不是弹层压入的那条 */
export function isSheetEntry(): boolean {
return (history.state as Record<string, unknown> | null)?.[MARK] === true;
}
/**
* 弹层打开时压入一条历史记录,让 Android 返回键/iOS 边缘滑动关闭弹层,而不是离开页面。
* 这是弹层专用的唯一一处直接操作 history(见 AGENTS.md)。
* 压入放到 setTimeout 里,避免 StrictMode 在开发环境下的「挂载-卸载-挂载」把它立刻弹掉。
*/
export function useBackToClose(onClose: () => void): void {
const close = useRef(onClose);
useEffect(() => {
close.current = onClose;
}, [onClose]);
useEffect(() => {
let pushed = false;
const timer = window.setTimeout(() => {
history.pushState({ [MARK]: true }, '');
pushed = true;
}, 0);
const onPop = () => close.current();
window.addEventListener('popstate', onPop);
return () => {
clearTimeout(timer);
window.removeEventListener('popstate', onPop);
// 用按钮等方式关闭时,把压入的那条记录弹掉
if (pushed && isSheetEntry()) history.back();
};
}, []);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import type { ClipboardPayload } from './clipboard';
function isEditable(t: EventTarget | null): boolean {
return t instanceof HTMLElement && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA');
}
/** 窗口任意位置 Ctrl+V 直接发送(在输入框内粘贴时不拦截) */
export function useGlobalPaste(onPayload: (p: ClipboardPayload) => void): void {
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
if (isEditable(e.target) || !e.clipboardData) return;
const files = [...e.clipboardData.files];
const text = e.clipboardData.getData('text/plain').trim();
if (files.length === 0 && !text) return;
e.preventDefault();
onPayload({ files, text: files.length > 0 ? '' : text });
};
document.addEventListener('paste', onPaste);
return () => document.removeEventListener('paste', onPaste);
}, [onPayload]);
}
+12
View File
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from 'react';
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(cb) => {
const m = matchMedia(query);
m.addEventListener('change', cb);
return () => m.removeEventListener('change', cb);
},
() => matchMedia(query).matches,
);
}