feat(web): connect quick share flow
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
53768a195d
commit
e02e024605
138 files changed
+10614
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)} 天`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user