@yyyu-star/arcade-kit 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +15 -4
- package/dist/qr.d.ts +15 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +264 -0
- package/dist/qr.js.map +1 -0
- package/dist/share.d.ts +43 -14
- package/dist/share.d.ts.map +1 -1
- package/dist/share.js +131 -22
- package/dist/share.js.map +1 -1
- package/package.json +2 -1
- package/src/qr.ts +259 -0
- package/src/share.ts +131 -24
package/src/share.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* - 游戏只交数据(`ShareCard`),画法统一在这里,几个游戏风格一致;kit 不认识任何游戏,名字、图标、主题色都由游戏给。
|
|
6
6
|
* - 这个文件只在调用时碰 DOM,import 时不碰(房间、测试里 import 没事);排版的算法是纯函数,测试在 test/share.test.ts。
|
|
7
7
|
* - 图上只有游戏给的数据:别人的隐藏信息(没摊的底牌、手牌)游戏自己别放进来。
|
|
8
|
+
* - 右下角的二维码(0.15.0):朋友圈里的图不能点,扫码是唯一入口。内容是运行时的 location.origin 加上 opts.url 的路径,
|
|
9
|
+
* kit 和游戏都不写域名;编码器是 qr.ts(自己写的最小子集,不引依赖)。
|
|
10
|
+
* - 微信内置浏览器(0.15.0):没有 navigator.share,下载链接也常常不生效,预览图改用 data: URL(blob: 的图长按经常存不下来),提示改成长按。
|
|
8
11
|
*/
|
|
12
|
+
import { qrEncode } from './qr.js';
|
|
9
13
|
|
|
10
14
|
/** 卡的尺寸:竖版 4:5,社交软件里不被裁 */
|
|
11
15
|
export const CARD_W = 1080;
|
|
@@ -161,6 +165,53 @@ export function artRect(
|
|
|
161
165
|
return [sx, sy, sw, sh, box.x + (box.w - dw) / 2, box.y + (box.h - dh) / 2, dw, dh];
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
/**
|
|
169
|
+
* 码里和分享出去的链接(0.15.0):只认运行时的 origin,游戏给的 url 只取路径、查询和 # 后面的部分。
|
|
170
|
+
* 这样 kit 和游戏里都不写域名,单独部署的游戏(workers.dev)扫出来也是它自己
|
|
171
|
+
*/
|
|
172
|
+
export function shareLink(url: string, origin: string): string {
|
|
173
|
+
const u = new URL(url, `${origin}/`);
|
|
174
|
+
return `${origin}${u.pathname}${u.search}${u.hash}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 系统分享的文字末尾带上链接(0.15.0):有的 App 只取 text、不取 url */
|
|
178
|
+
export function shareText(text: string, link: string): string {
|
|
179
|
+
return text.includes(link) ? text : `${text} ${link}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 微信内置浏览器(UA 带 MicroMessenger,含企业微信) */
|
|
183
|
+
export function isWeChat(ua: string): boolean {
|
|
184
|
+
return /MicroMessenger/i.test(ua);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 调不调系统分享(0.15.1):只给手机、平板。桌面(Windows、Mac、Linux,含触屏笔记本)的系统共享面板发不出图:
|
|
189
|
+
* Windows Chrome 的 canShare 对文件回 true,弹出系统共享,选微信后微信 PC 版是空白窗口、再报"共享失败"。桌面一律直接出预览,复制图片去粘贴。
|
|
190
|
+
* iPadOS 的 Safari 默认报桌面版 UA(Macintosh),靠多点触控认出来;微信里照旧不调(isWeChat)
|
|
191
|
+
*/
|
|
192
|
+
export function useSystemShare(ua: string, maxTouchPoints: number): boolean {
|
|
193
|
+
if (isWeChat(ua)) return false;
|
|
194
|
+
if (/Android|iPhone|iPad|iPod|HarmonyOS|Mobile/i.test(ua)) return true;
|
|
195
|
+
return /Macintosh/.test(ua) && maxTouchPoints > 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 预览层怎么出(0.15.0):微信里用 data: URL、提示长按、不给"保存图片";
|
|
200
|
+
* 桌面(0.15.1)多一个"复制图片"(浏览器支持才真的摆出来),提示去微信里粘贴;手机照旧
|
|
201
|
+
*/
|
|
202
|
+
export function previewMode(ua: string, maxTouchPoints = 0): { dataUrl: boolean; tip: string; save: boolean; copyImage: boolean } {
|
|
203
|
+
if (isWeChat(ua)) return { dataUrl: true, tip: '长按图片 → 发送给朋友,或保存后发朋友圈', save: false, copyImage: false };
|
|
204
|
+
if (!useSystemShare(ua, maxTouchPoints)) return { dataUrl: false, tip: '复制图片,到微信或 QQ 的聊天框里粘贴就能发', save: true, copyImage: true };
|
|
205
|
+
return { dataUrl: false, tip: '手机上也可以长按图片保存', save: true, copyImage: false };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 二维码在卡上的框:白底留白 ≥ 4 格(扫码的规范要求),每格整数像素、对齐像素,截图后也清楚 */
|
|
209
|
+
export const QR_BOX = 248;
|
|
210
|
+
export function qrLayout(size: number, box = QR_BOX): { cell: number; pad: number } {
|
|
211
|
+
const cell = Math.max(1, Math.floor(box / (size + 8)));
|
|
212
|
+
return { cell, pad: Math.floor((box - cell * size) / 2) };
|
|
213
|
+
}
|
|
214
|
+
|
|
164
215
|
// ───────────── 画(只在调用时碰 DOM) ─────────────
|
|
165
216
|
|
|
166
217
|
const FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif';
|
|
@@ -211,9 +262,16 @@ function fitFont(ctx: CanvasRenderingContext2D, text: string, weight: string, si
|
|
|
211
262
|
return s;
|
|
212
263
|
}
|
|
213
264
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
265
|
+
/**
|
|
266
|
+
* 画一张卡。canvas 不给就新建一个 1080×1350 的。link 给了(0.15.0,prepareShare 用 shareLink 算好的)就在右下角画二维码,
|
|
267
|
+
* 太长编不了码就不画
|
|
268
|
+
*/
|
|
269
|
+
export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement, link?: string): Promise<HTMLCanvasElement> {
|
|
216
270
|
const c = normalizeCard(input);
|
|
271
|
+
const qr = link ? qrEncode(link) : null;
|
|
272
|
+
// 二维码的白框:右下角,压在底栏上;名场面和日期往左让
|
|
273
|
+
const qx = CARD_W - 72 - QR_BOX;
|
|
274
|
+
const qy = CARD_H - 40 - QR_BOX - 44;
|
|
217
275
|
const cv = canvas ?? document.createElement('canvas');
|
|
218
276
|
cv.width = CARD_W;
|
|
219
277
|
cv.height = CARD_H;
|
|
@@ -302,24 +360,28 @@ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement
|
|
|
302
360
|
if (c.line) {
|
|
303
361
|
ctx.fillStyle = '#ffffff';
|
|
304
362
|
ctx.font = `700 50px ${FONT}`;
|
|
305
|
-
const
|
|
306
|
-
lines.
|
|
363
|
+
const lw = qr ? qx - 32 - M : W - 2 * M - 40;
|
|
364
|
+
const lines = wrapLines(`“${c.line}”`, lw, (s) => ctx.measureText(s).width, 2);
|
|
365
|
+
lines.forEach((l, i) => ctx.fillText(l, qr ? M + lw / 2 : W / 2, 1065 + i * 66));
|
|
307
366
|
}
|
|
308
367
|
|
|
309
368
|
// 底:昵称、称号、日期
|
|
310
369
|
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
|
311
370
|
ctx.fillRect(0, CARD_H - 150, W, 150);
|
|
371
|
+
ctx.font = `600 38px ${FONT}`;
|
|
372
|
+
const dateRight = qr ? qx - 28 : W - M;
|
|
373
|
+
const dateLeft = dateRight - ctx.measureText(cardDate(c.date)).width;
|
|
312
374
|
ctx.textAlign = 'left';
|
|
313
375
|
ctx.fillStyle = '#ffffff';
|
|
314
376
|
if (c.player) {
|
|
315
|
-
fitFont(ctx, c.player.name, '800', 50, W * 0.5, 32);
|
|
377
|
+
fitFont(ctx, c.player.name, '800', 50, Math.min(W * 0.5, dateLeft - 24 - M), 32);
|
|
316
378
|
ctx.fillText(c.player.name, M, CARD_H - 62);
|
|
317
379
|
if (c.player.badge) {
|
|
318
380
|
const nw = ctx.measureText(c.player.name).width;
|
|
319
381
|
ctx.font = `700 34px ${FONT}`;
|
|
320
382
|
const bw = ctx.measureText(c.player.badge).width + 36;
|
|
321
383
|
const bx = M + nw + 24;
|
|
322
|
-
if (bx + bw < W * 0.72) {
|
|
384
|
+
if (bx + bw < Math.min(W * 0.72, dateLeft - 16)) {
|
|
323
385
|
round(ctx, bx, CARD_H - 104, bw, 54, 27);
|
|
324
386
|
ctx.fillStyle = shade(c.accent, 0.75);
|
|
325
387
|
ctx.fill();
|
|
@@ -331,20 +393,39 @@ export async function drawShareCard(input: ShareCard, canvas?: HTMLCanvasElement
|
|
|
331
393
|
ctx.textAlign = 'right';
|
|
332
394
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
333
395
|
ctx.font = `600 38px ${FONT}`;
|
|
334
|
-
ctx.fillText(cardDate(c.date),
|
|
396
|
+
ctx.fillText(cardDate(c.date), dateRight, CARD_H - 62);
|
|
397
|
+
|
|
398
|
+
if (qr) {
|
|
399
|
+
// 纯白底、近黑的格,不叠阴影和渐变:对比度够,手机截图压缩后照样扫得出
|
|
400
|
+
ctx.fillStyle = '#ffffff';
|
|
401
|
+
round(ctx, qx, qy, QR_BOX, QR_BOX + 44, 24);
|
|
402
|
+
ctx.fill();
|
|
403
|
+
const { cell, pad } = qrLayout(qr.size);
|
|
404
|
+
ctx.fillStyle = '#111111';
|
|
405
|
+
for (let y = 0; y < qr.size; y++) {
|
|
406
|
+
for (let x = 0; x < qr.size; x++) if (qr.dark[y]![x]) ctx.fillRect(qx + pad + x * cell, qy + pad + y * cell, cell, cell);
|
|
407
|
+
}
|
|
408
|
+
ctx.fillStyle = '#444444';
|
|
409
|
+
ctx.textAlign = 'center';
|
|
410
|
+
ctx.font = `700 28px ${FONT}`;
|
|
411
|
+
ctx.fillText('扫码来玩', qx + QR_BOX / 2, qy + QR_BOX + 22);
|
|
412
|
+
}
|
|
335
413
|
return cv;
|
|
336
414
|
}
|
|
337
415
|
|
|
338
|
-
/** 画成 PNG */
|
|
339
|
-
export async function shareCardBlob(card: ShareCard): Promise<Blob> {
|
|
340
|
-
const cv = await drawShareCard(card);
|
|
416
|
+
/** 画成 PNG;link 见 drawShareCard */
|
|
417
|
+
export async function shareCardBlob(card: ShareCard, link?: string): Promise<Blob> {
|
|
418
|
+
const cv = await drawShareCard(card, undefined, link);
|
|
341
419
|
return new Promise((resolve, reject) => cv.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'));
|
|
342
420
|
}
|
|
343
421
|
|
|
344
422
|
export interface ShareOptions {
|
|
345
|
-
/**
|
|
423
|
+
/**
|
|
424
|
+
* 分享出去的链接:游戏地址,好友房结算带房间码。只用它的路径、查询和 # 后面的部分,前面一律换成运行时的 location.origin(0.15.0),
|
|
425
|
+
* 写相对路径(`./?room=123456`)或绝对地址都行;卡上的二维码、系统分享、"复制链接"用的都是换过的这一个
|
|
426
|
+
*/
|
|
346
427
|
url: string;
|
|
347
|
-
/**
|
|
428
|
+
/** 系统分享里的一句话,默认用卡的游戏名和大数字拼;末尾自动带上链接(0.15.0) */
|
|
348
429
|
text?: string;
|
|
349
430
|
/** 存图的文件名,默认 share.png */
|
|
350
431
|
fileName?: string;
|
|
@@ -375,10 +456,13 @@ export interface PreparedShare {
|
|
|
375
456
|
*/
|
|
376
457
|
export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare {
|
|
377
458
|
const name = opts.fileName ?? 'share.png';
|
|
378
|
-
|
|
459
|
+
// 没有 DOM(房间、测试里)时 location 也没有:链接照原样,画卡那一步本来就会失败
|
|
460
|
+
const link = typeof location === 'undefined' ? opts.url : shareLink(opts.url, location.origin);
|
|
461
|
+
const text = shareText(opts.text ?? `${card.game} · ${card.big.label} ${card.big.value}`, link);
|
|
462
|
+
const o: ShareOptions = { ...opts, url: link };
|
|
379
463
|
let blob: Blob | null = null;
|
|
380
464
|
let failed: unknown = null;
|
|
381
|
-
const ready = shareCardBlob(card).then(
|
|
465
|
+
const ready = shareCardBlob(card, link).then(
|
|
382
466
|
(b) => {
|
|
383
467
|
blob = b;
|
|
384
468
|
},
|
|
@@ -388,8 +472,9 @@ export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare
|
|
|
388
472
|
);
|
|
389
473
|
const attempt = (b: Blob): Promise<ShareResult> => {
|
|
390
474
|
const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };
|
|
391
|
-
|
|
392
|
-
|
|
475
|
+
// 只有手机、平板试系统分享;桌面和微信直接出预览(useSystemShare)
|
|
476
|
+
if (useSystemShare(nav.userAgent, nav.maxTouchPoints ?? 0) && typeof File === 'function' && typeof nav.share === 'function') {
|
|
477
|
+
const data: ShareData = { files: [new File([b], name, { type: 'image/png' })], title: card.game, text, url: link };
|
|
393
478
|
if (nav.canShare?.(data)) {
|
|
394
479
|
// 同步调(这一行之前没有 await):点击的激活还在
|
|
395
480
|
return nav.share(data).then(
|
|
@@ -400,13 +485,13 @@ export function prepareShare(card: ShareCard, opts: ShareOptions): PreparedShare
|
|
|
400
485
|
(e: { name?: string }) => {
|
|
401
486
|
if (e?.name === 'AbortError') return 'cancelled' as const;
|
|
402
487
|
// 别的错(激活过期、权限、不支持的类型):退回预览
|
|
403
|
-
showPreview(b, name,
|
|
488
|
+
showPreview(b, name, o);
|
|
404
489
|
return 'preview' as const;
|
|
405
490
|
},
|
|
406
491
|
);
|
|
407
492
|
}
|
|
408
493
|
}
|
|
409
|
-
showPreview(b, name,
|
|
494
|
+
showPreview(b, name, o);
|
|
410
495
|
return Promise.resolve('preview');
|
|
411
496
|
};
|
|
412
497
|
return {
|
|
@@ -431,27 +516,36 @@ export async function shareCard(card: ShareCard, opts: ShareOptions): Promise<Sh
|
|
|
431
516
|
return p.share();
|
|
432
517
|
}
|
|
433
518
|
|
|
434
|
-
/** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"
|
|
519
|
+
/** 不支持系统分享时:预览图 + "保存图片""复制链接";点空白处或"关闭"收起。微信里见 previewMode */
|
|
435
520
|
function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
|
|
436
521
|
document.getElementById(PREVIEW_ID)?.remove();
|
|
437
|
-
const
|
|
522
|
+
const mode = previewMode(navigator.userAgent, navigator.maxTouchPoints ?? 0);
|
|
523
|
+
// 微信里不用 blob:(长按存不下来),也就不建对象 URL
|
|
524
|
+
const url = mode.dataUrl ? '' : URL.createObjectURL(blob);
|
|
438
525
|
const root = document.createElement('div');
|
|
439
526
|
root.id = PREVIEW_ID;
|
|
440
527
|
root.setAttribute('role', 'dialog');
|
|
441
528
|
root.style.cssText = `position:fixed;inset:0;z-index:${opts.zIndex ?? 11000};display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:16px;box-sizing:border-box;background:rgba(0,0,0,.72);font:600 16px ${FONT};color:#fff`;
|
|
442
529
|
const close = (): void => {
|
|
443
530
|
root.remove();
|
|
444
|
-
URL.revokeObjectURL(url);
|
|
531
|
+
if (url) URL.revokeObjectURL(url);
|
|
445
532
|
};
|
|
446
533
|
root.addEventListener('click', (e) => {
|
|
447
534
|
if (e.target === root) close();
|
|
448
535
|
});
|
|
449
536
|
const img = document.createElement('img');
|
|
450
|
-
img.src = url;
|
|
537
|
+
if (url) img.src = url;
|
|
538
|
+
else {
|
|
539
|
+
const reader = new FileReader();
|
|
540
|
+
reader.onload = () => {
|
|
541
|
+
if (typeof reader.result === 'string') img.src = reader.result;
|
|
542
|
+
};
|
|
543
|
+
reader.readAsDataURL(blob);
|
|
544
|
+
}
|
|
451
545
|
img.alt = '战绩图';
|
|
452
546
|
img.style.cssText = 'max-width:min(92vw,540px);max-height:calc(100% - 140px);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.5)';
|
|
453
547
|
const tip = document.createElement('div');
|
|
454
|
-
tip.textContent =
|
|
548
|
+
tip.textContent = mode.tip;
|
|
455
549
|
tip.style.cssText = 'font-size:13px;opacity:.75';
|
|
456
550
|
const row = document.createElement('div');
|
|
457
551
|
row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center';
|
|
@@ -467,6 +561,9 @@ function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
|
|
|
467
561
|
});
|
|
468
562
|
return b;
|
|
469
563
|
};
|
|
564
|
+
// 不支持 ClipboardItem 的浏览器不摆"复制图片","保存图片"当主按钮
|
|
565
|
+
const canCopyImage = mode.copyImage && typeof ClipboardItem === 'function' && typeof navigator.clipboard?.write === 'function';
|
|
566
|
+
if (mode.copyImage && !canCopyImage) tip.textContent = '保存图片后,到微信或 QQ 的聊天框里发';
|
|
470
567
|
const save = btn('保存图片', 'share:save', () => {
|
|
471
568
|
const a = document.createElement('a');
|
|
472
569
|
a.href = url;
|
|
@@ -475,6 +572,15 @@ function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
|
|
|
475
572
|
a.click();
|
|
476
573
|
a.remove();
|
|
477
574
|
opts.log?.('分享 保存');
|
|
575
|
+
}, !canCopyImage);
|
|
576
|
+
// 复制图片(0.15.1,桌面):在点击里直接写剪贴板(用户激活还在),图已经画好了
|
|
577
|
+
const copyImg = btn('复制图片', 'share:copy-image', () => {
|
|
578
|
+
const done = (ok: boolean): void => {
|
|
579
|
+
copyImg.textContent = ok ? '已复制' : '复制不了';
|
|
580
|
+
tip.textContent = ok ? '已复制,去微信里粘贴' : '复制不了图片,点"保存图片"存下来再发';
|
|
581
|
+
opts.log?.(ok ? '分享 复制图片' : '分享 复制图片失败');
|
|
582
|
+
};
|
|
583
|
+
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => done(true), () => done(false));
|
|
478
584
|
}, true);
|
|
479
585
|
const copy = btn('复制链接', 'share:copy', () => {
|
|
480
586
|
const done = (ok: boolean): void => {
|
|
@@ -486,7 +592,8 @@ function showPreview(blob: Blob, name: string, opts: ShareOptions): void {
|
|
|
486
592
|
else done(false);
|
|
487
593
|
});
|
|
488
594
|
const shut = btn('关闭', 'share:close', close);
|
|
489
|
-
row.appendChild(
|
|
595
|
+
if (canCopyImage) row.appendChild(copyImg);
|
|
596
|
+
if (mode.save) row.appendChild(save);
|
|
490
597
|
row.appendChild(copy);
|
|
491
598
|
row.appendChild(shut);
|
|
492
599
|
root.appendChild(img);
|