dsh-pocket 2.7.0 → 2.7.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/client/client.js CHANGED
@@ -337,84 +337,99 @@ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t
337
337
  ));
338
338
  }
339
339
 
340
- // client/mobile/fileCopy.ts
341
- var BUTTON_ATTR = "data-mobile-nav";
342
- var BUTTON_VALUE = "copy-file";
343
- var MARKER = "data-mobile-nav-copy";
344
- async function copyText(text) {
345
- try {
346
- if (navigator.clipboard && window.isSecureContext) {
347
- await navigator.clipboard.writeText(text);
348
- return true;
349
- }
350
- } catch {
351
- }
352
- try {
353
- const ta = document.createElement("textarea");
354
- ta.value = text;
355
- ta.setAttribute("readonly", "");
356
- ta.style.position = "fixed";
357
- ta.style.top = "0";
358
- ta.style.left = "0";
359
- ta.style.opacity = "0";
360
- document.body.appendChild(ta);
361
- ta.select();
362
- const ok = document.execCommand("copy");
363
- ta.remove();
364
- return ok;
365
- } catch {
366
- return false;
367
- }
340
+ // client/mobile/fileGuard.ts
341
+ var GUARD_MSG = "\u624B\u673A\u4E0A\u65E0\u6CD5\u76F4\u63A5\u6253\u5F00\u7535\u8111\u4E0A\u7684\u6587\u4EF6";
342
+ var WS_LABELS = ["\u6DFB\u52A0\u5DE5\u4F5C\u533A", "\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026", "Add workspace", "Add workspace\u2026"];
343
+ function looksLikeFilePath(text) {
344
+ const t = (text ?? "").trim();
345
+ if (t.length < 3 || t.length > 320) return false;
346
+ if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true;
347
+ if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true;
348
+ if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true;
349
+ return false;
368
350
  }
369
- function createCopyButton() {
370
- const btn = document.createElement("button");
371
- btn.type = "button";
372
- btn.setAttribute(BUTTON_ATTR, BUTTON_VALUE);
373
- btn.textContent = "\u590D\u5236";
374
- btn.setAttribute("aria-label", "\u590D\u5236\u6587\u4EF6\u5185\u5BB9");
375
- btn.addEventListener("click", async (event) => {
376
- event.preventDefault();
377
- event.stopPropagation();
378
- const card = btn.parentElement;
379
- const block = card?.querySelector("pre") ?? null;
380
- const text = block?.textContent ?? "";
381
- const ok = await copyText(text);
382
- const prev = btn.textContent;
383
- btn.textContent = ok ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u5931\u8D25";
384
- btn.setAttribute("data-copied", ok ? "1" : "0");
385
- window.setTimeout(() => {
386
- btn.textContent = prev;
387
- btn.removeAttribute("data-copied");
388
- }, 1500);
389
- });
390
- return btn;
351
+ function isInsidePocket(el) {
352
+ return el !== null && el.closest('[data-mobile-nav="frame"]') !== null;
391
353
  }
392
- function injectInto(card) {
393
- if (card.hasAttribute(MARKER)) return;
394
- card.setAttribute(MARKER, "1");
395
- if (getComputedStyle(card).position === "static") card.style.position = "relative";
396
- card.appendChild(createCopyButton());
397
- }
398
- function collectCodeBlocks(root) {
399
- const out = [];
400
- for (const pre of Array.from(root.querySelectorAll("pre"))) {
401
- const card = pre.parentElement;
402
- if (card === null) continue;
403
- if (card.hasAttribute(MARKER)) continue;
404
- if ((pre.textContent ?? "").trim().length < 1) continue;
405
- out.push(card);
406
- }
407
- return out;
408
- }
409
- function startFileCopyInjection() {
410
- const scan = () => {
411
- for (const card of collectCodeBlocks(document)) injectInto(card);
354
+ function startFileGuard() {
355
+ let toastEl = null;
356
+ let toastTimer = null;
357
+ const showToast = (text) => {
358
+ if (toastEl === null) {
359
+ toastEl = document.createElement("div");
360
+ toastEl.setAttribute("data-mobile-nav", "file-guard-toast");
361
+ Object.assign(toastEl.style, {
362
+ position: "fixed",
363
+ left: "50%",
364
+ bottom: "64px",
365
+ transform: "translateX(-50%)",
366
+ maxWidth: "84vw",
367
+ zIndex: "9999",
368
+ padding: "10px 14px",
369
+ borderRadius: "10px",
370
+ background: "rgba(20,22,28,.92)",
371
+ color: "#fff",
372
+ fontSize: "13px",
373
+ lineHeight: "1.4",
374
+ textAlign: "center",
375
+ fontFamily: "inherit",
376
+ boxShadow: "0 4px 16px rgba(0,0,0,.28)",
377
+ pointerEvents: "none",
378
+ opacity: "0",
379
+ transition: "opacity .18s ease"
380
+ });
381
+ document.body.appendChild(toastEl);
382
+ }
383
+ toastEl.textContent = text;
384
+ requestAnimationFrame(() => {
385
+ if (toastEl !== null) toastEl.style.opacity = "1";
386
+ });
387
+ if (toastTimer !== null) window.clearTimeout(toastTimer);
388
+ toastTimer = window.setTimeout(() => {
389
+ if (toastEl !== null) toastEl.style.opacity = "0";
390
+ }, 2600);
391
+ };
392
+ const onClick = (event) => {
393
+ const target = event.target;
394
+ if (target === null || isInsidePocket(target)) return;
395
+ const el = target.closest("button, a");
396
+ if (el === null) return;
397
+ if (!looksLikeFilePath(el.textContent)) return;
398
+ event.preventDefault();
399
+ event.stopImmediatePropagation();
400
+ showToast(GUARD_MSG);
401
+ };
402
+ document.addEventListener("click", onClick, true);
403
+ const hideWsEntries = () => {
404
+ const checkOne = (node) => {
405
+ if (node.nodeType !== 1) return;
406
+ const el = node;
407
+ const txt = (el.getAttribute("aria-label") ?? el.textContent ?? "").trim();
408
+ if (WS_LABELS.includes(txt)) {
409
+ el.style.display = "none";
410
+ el.setAttribute("data-mobile-nav-hide", "add-workspace");
411
+ }
412
+ };
413
+ const sel = '[role="menuitem"],[role="option"],li,button,a';
414
+ document.querySelectorAll(sel).forEach(checkOne);
415
+ const observer = new MutationObserver((mutations) => {
416
+ for (const m of mutations) {
417
+ m.addedNodes.forEach((n) => {
418
+ if (n.nodeType !== 1) return;
419
+ checkOne(n);
420
+ n.querySelectorAll?.(sel).forEach(checkOne);
421
+ });
422
+ }
423
+ });
424
+ observer.observe(document.body, { childList: true, subtree: true });
425
+ return () => observer.disconnect();
412
426
  };
413
- const observer = new MutationObserver(scan);
414
- observer.observe(document.body, { childList: true, subtree: true });
415
- scan();
427
+ const disconnectWs = hideWsEntries();
416
428
  return () => {
417
- observer.disconnect();
429
+ document.removeEventListener("click", onClick, true);
430
+ disconnectWs();
431
+ if (toastTimer !== null) window.clearTimeout(toastTimer);
432
+ toastEl?.remove();
418
433
  };
419
434
  }
420
435
 
@@ -1279,40 +1294,15 @@ var MOBILE_CSS = `
1279
1294
  gap: 0 !important;
1280
1295
  }
1281
1296
 
1282
- /* ---------- \u590D\u5236\u6587\u4EF6\u5185\u5BB9\u6309\u94AE\uFF08issue #17\uFF09 ----------
1283
- \u6302\u5728\u4EE3\u7801/\u6587\u4EF6\u5757\u5BB9\u5668\u53F3\u4E0A\u89D2\uFF08position:relative \u7531 JS \u5728\u6CE8\u5165\u65F6\u8865\u4E0A\uFF09\u3002
1284
- \u53EA\u5C4F\u5185\u53EF\u89C1\uFF1A\u684C\u9762\u7AEF DSH \u81EA\u5E26\u590D\u5236\uFF0C\u4E14\u672C effect \u53EA\u5728 narrow \u4E0B\u6302\u8F7D\uFF0C\u6309\u94AE
1285
- \u6839\u672C\u4E0D\u4F1A\u6CE8\u5165\uFF1B\u8FD9\u91CC\u518D\u515C\u5E95\u4E00\u5C42\uFF0C\u907F\u514D\u4EFB\u4F55\u9057\u6F0F\u3002 */
1286
- [data-mobile-nav="copy-file"] {
1287
- position: absolute !important;
1288
- top: 6px !important;
1289
- right: 6px !important;
1290
- z-index: 6 !important;
1291
- display: inline-flex !important;
1292
- align-items: center !important;
1293
- justify-content: center !important;
1294
- height: 26px !important;
1295
- padding: 0 10px !important;
1296
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)) !important;
1297
- border-radius: 8px !important;
1298
- background: var(--dsw-alias-bg-base, #ffffff) !important;
1299
- color: var(--dsw-alias-label-primary, inherit) !important;
1300
- font-family: inherit !important;
1301
- font-size: 12px !important;
1302
- line-height: 1 !important;
1303
- cursor: pointer !important;
1304
- -webkit-tap-highlight-color: transparent !important;
1305
- box-shadow: 0 1px 4px rgba(0, 0, 0, .14) !important;
1306
- }
1307
- [data-mobile-nav="copy-file"]:active {
1308
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
1309
- }
1310
- [data-mobile-nav="copy-file"][data-copied="1"] {
1311
- color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
1312
- border-color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
1313
- }
1314
- [data-mobile-nav="copy-file"][data-copied="0"] {
1315
- color: var(--dsw-alias-state-danger-primary, #e5484d) !important;
1297
+ /* ---------- \u9690\u85CF\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u300D\u5165\u53E3\uFF08\u624B\u673A\u4E0A\u914D\u5DE5\u4F5C\u533A\u65E0\u610F\u4E49\uFF0Cissue #17 \u4FEE\u6B63\uFF09 ----------
1298
+ \u56FE\u6807\u6309\u94AE\u7684 aria-label \u968F\u8BED\u8A00\u53D8\u5316\uFF08zh\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u300D/ en\u300CAdd workspace\u300D\uFF09\uFF0C
1299
+ \u4E24\u79CD\u90FD\u8986\u76D6\uFF1B\u4E0B\u62C9\u83DC\u5355\u91CC\u7684\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026\u300D\u9879\u7531 fileGuard.ts \u7684 MutationObserver
1300
+ \u6309\u6587\u6848\u515C\u5E95\u9690\u85CF\uFF08CSS \u9009\u4E0D\u5230\u7EAF\u6587\u672C\u8282\u70B9\uFF09\u3002\u53EA\u5728\u7A84\u5C4F\u751F\u6548\u2014\u2014\u684C\u9762\u7AEF\u7167\u5E38\u4FDD\u7559\u3002 */
1301
+ button[aria-label="\u6DFB\u52A0\u5DE5\u4F5C\u533A"],
1302
+ button[aria-label="\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026"],
1303
+ button[aria-label="Add workspace"],
1304
+ button[aria-label="Add workspace\u2026"] {
1305
+ display: none !important;
1316
1306
  }
1317
1307
  }
1318
1308
 
@@ -1548,8 +1538,8 @@ function mobileApply(ctx) {
1548
1538
  ctx.effect(() => {
1549
1539
  if (!narrow.matches) return () => {
1550
1540
  };
1551
- return startFileCopyInjection();
1552
- }, "dsh-mobile-nav: file copy button (issue #17)");
1541
+ return startFileGuard();
1542
+ }, "dsh-mobile-nav: file open guard + hide add-workspace (issue #17)");
1553
1543
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1554
1544
  name: "conversation.session.header.actions",
1555
1545
  id: "mobile-nav-toggle",
@@ -0,0 +1,118 @@
1
+ // 移动端文件守卫(issue #17 修正):dsh-web 在手机上点「文件链接」会触发桌面端
2
+ // workspaces.openPath(open <path>),既打不开(文件在电脑上),又会抛
3
+ // "path open failed: ...". 这里在捕获阶段拦截这类激活(点击 / 键盘),改为弹一个
4
+ // 提示;并隐藏「添加工作区」入口(手机上配工作区无意义)。
5
+ // 移植自 dsh-web-mobile(MIT)。
6
+ //
7
+ // 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
8
+ // <button>/<a>」——文件链接按钮的文案就是路径(如 lib/proxy.mjs / /Users/.../x.ts)。
9
+
10
+ /** 手机上点击文件时弹出的提示。 */
11
+ const GUARD_MSG = '手机上无法直接打开电脑上的文件'
12
+ /** 「添加工作区」入口的文案(随语言变化),两种都覆盖。 */
13
+ const WS_LABELS = ['添加工作区', '添加工作区…', 'Add workspace', 'Add workspace…']
14
+
15
+ /** 文本是否像文件路径:绝对路径 / 相对路径 / 带扩展名的目录路径。 */
16
+ function looksLikeFilePath(text: string | null): boolean {
17
+ const t = (text ?? '').trim()
18
+ if (t.length < 3 || t.length > 320) return false
19
+ if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true
20
+ if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true
21
+ if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true
22
+ return false
23
+ }
24
+
25
+ /** 节点是否落在 dsh-pocket 自身面板内(不拦截面板内的交互)。 */
26
+ function isInsidePocket(el: Element | null): boolean {
27
+ return el !== null && el.closest('[data-mobile-nav="frame"]') !== null
28
+ }
29
+
30
+ export function startFileGuard(): () => void {
31
+ // 轻量 toast:自包含,不依赖 dsh-pocket 面板的 React 状态。
32
+ let toastEl: HTMLElement | null = null
33
+ let toastTimer: number | null = null
34
+ const showToast = (text: string): void => {
35
+ if (toastEl === null) {
36
+ toastEl = document.createElement('div')
37
+ toastEl.setAttribute('data-mobile-nav', 'file-guard-toast')
38
+ Object.assign(toastEl.style, {
39
+ position: 'fixed',
40
+ left: '50%',
41
+ bottom: '64px',
42
+ transform: 'translateX(-50%)',
43
+ maxWidth: '84vw',
44
+ zIndex: '9999',
45
+ padding: '10px 14px',
46
+ borderRadius: '10px',
47
+ background: 'rgba(20,22,28,.92)',
48
+ color: '#fff',
49
+ fontSize: '13px',
50
+ lineHeight: '1.4',
51
+ textAlign: 'center',
52
+ fontFamily: 'inherit',
53
+ boxShadow: '0 4px 16px rgba(0,0,0,.28)',
54
+ pointerEvents: 'none',
55
+ opacity: '0',
56
+ transition: 'opacity .18s ease',
57
+ } as CSSStyleDeclaration)
58
+ document.body.appendChild(toastEl)
59
+ }
60
+ toastEl.textContent = text
61
+ requestAnimationFrame(() => {
62
+ if (toastEl !== null) toastEl.style.opacity = '1'
63
+ })
64
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
65
+ toastTimer = window.setTimeout(() => {
66
+ if (toastEl !== null) toastEl.style.opacity = '0'
67
+ }, 2600)
68
+ }
69
+
70
+ // 捕获阶段拦截文件链接的激活。按钮的键盘激活(Enter/Space)会派发 click,
71
+ // 因此只拦 click 即可同时覆盖鼠标与键盘,避免重复处理。
72
+ const onClick = (event: MouseEvent): void => {
73
+ const target = event.target as HTMLElement | null
74
+ if (target === null || isInsidePocket(target)) return
75
+ const el = target.closest('button, a') as HTMLElement | null
76
+ if (el === null) return
77
+ if (!looksLikeFilePath(el.textContent)) return
78
+ event.preventDefault()
79
+ event.stopImmediatePropagation()
80
+ showToast(GUARD_MSG)
81
+ }
82
+ document.addEventListener('click', onClick, true)
83
+
84
+ // 隐藏「添加工作区」入口:图标按钮由 mobile.css.ts 按 aria-label 隐藏;
85
+ // 下拉菜单里的文本项 CSS 选不到,这里按文案兜底(只在新增节点时检查,省开销)。
86
+ const hideWsEntries = (): (() => void) => {
87
+ const checkOne = (node: Node): void => {
88
+ if (node.nodeType !== 1) return
89
+ const el = node as HTMLElement
90
+ const txt = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
91
+ if (WS_LABELS.includes(txt)) {
92
+ el.style.display = 'none'
93
+ el.setAttribute('data-mobile-nav-hide', 'add-workspace')
94
+ }
95
+ }
96
+ const sel = '[role="menuitem"],[role="option"],li,button,a'
97
+ document.querySelectorAll(sel).forEach(checkOne)
98
+ const observer = new MutationObserver((mutations) => {
99
+ for (const m of mutations) {
100
+ m.addedNodes.forEach((n) => {
101
+ if (n.nodeType !== 1) return
102
+ checkOne(n)
103
+ ;(n as HTMLElement).querySelectorAll?.(sel).forEach(checkOne)
104
+ })
105
+ }
106
+ })
107
+ observer.observe(document.body, { childList: true, subtree: true })
108
+ return () => observer.disconnect()
109
+ }
110
+ const disconnectWs = hideWsEntries()
111
+
112
+ return () => {
113
+ document.removeEventListener('click', onClick, true)
114
+ disconnectWs()
115
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
116
+ toastEl?.remove()
117
+ }
118
+ }
@@ -3,7 +3,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
3
3
  import { MobileNavToggle } from './MobileNavToggle.tsx'
4
4
  import { MobileNavOverlay } from './MobileNavOverlay.tsx'
5
5
  import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
6
- import { startFileCopyInjection } from './fileCopy.ts'
6
+ import { startFileGuard } from './fileGuard.ts'
7
7
  import { MOBILE_CSS } from './mobile.css.ts'
8
8
  import { NS, en, zh } from './locales.ts'
9
9
  import type { MobileNavKey } from './locales.ts'
@@ -263,13 +263,14 @@ export function mobileApply(ctx): void {
263
263
  }
264
264
  }, 'dsh-mobile-nav: sheet rise animation replay')
265
265
 
266
- // 复制文件内容按钮(issue #17):dsh-web 在手机上没有可用的下载入口,于是在
267
- // 会话里的代码/文件块右上角注入一个「复制」按钮,把块内文本(文件内容)写
268
- // 入剪贴板。只挂窄屏——桌面端 DSH 自带复制,不需要。
266
+ // 移动端文件守卫(issue #17 修正):手机上点 dsh-web 渲染的文件链接会触发桌面
267
+ // 端 workspaces.openPath(open ...) —— 既打不开(路径在电脑上),又会抛
268
+ // "path open failed"。这里在捕获阶段拦截这类点击 / 键盘激活,改为弹一个提示,
269
+ // 并隐藏「添加工作区」入口(手机上配工作区无意义)。只挂窄屏。
269
270
  ctx.effect(() => {
270
271
  if (!narrow.matches) return () => {}
271
- return startFileCopyInjection()
272
- }, 'dsh-mobile-nav: file copy button (issue #17)')
272
+ return startFileGuard()
273
+ }, 'dsh-mobile-nav: file open guard + hide add-workspace (issue #17)')
273
274
 
274
275
  ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
275
276
  name: 'conversation.session.header.actions',
@@ -873,40 +873,15 @@ export const MOBILE_CSS = `
873
873
  gap: 0 !important;
874
874
  }
875
875
 
876
- /* ---------- 复制文件内容按钮(issue #17 ----------
877
- 挂在代码/文件块容器右上角(position:relative JS 在注入时补上)。
878
- 只屏内可见:桌面端 DSH 自带复制,且本 effect 只在 narrow 下挂载,按钮
879
- 根本不会注入;这里再兜底一层,避免任何遗漏。 */
880
- [data-mobile-nav="copy-file"] {
881
- position: absolute !important;
882
- top: 6px !important;
883
- right: 6px !important;
884
- z-index: 6 !important;
885
- display: inline-flex !important;
886
- align-items: center !important;
887
- justify-content: center !important;
888
- height: 26px !important;
889
- padding: 0 10px !important;
890
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)) !important;
891
- border-radius: 8px !important;
892
- background: var(--dsw-alias-bg-base, #ffffff) !important;
893
- color: var(--dsw-alias-label-primary, inherit) !important;
894
- font-family: inherit !important;
895
- font-size: 12px !important;
896
- line-height: 1 !important;
897
- cursor: pointer !important;
898
- -webkit-tap-highlight-color: transparent !important;
899
- box-shadow: 0 1px 4px rgba(0, 0, 0, .14) !important;
900
- }
901
- [data-mobile-nav="copy-file"]:active {
902
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
903
- }
904
- [data-mobile-nav="copy-file"][data-copied="1"] {
905
- color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
906
- border-color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
907
- }
908
- [data-mobile-nav="copy-file"][data-copied="0"] {
909
- color: var(--dsw-alias-state-danger-primary, #e5484d) !important;
876
+ /* ---------- 隐藏「添加工作区」入口(手机上配工作区无意义,issue #17 修正) ----------
877
+ 图标按钮的 aria-label 随语言变化(zh「添加工作区」/ en「Add workspace」),
878
+ 两种都覆盖;下拉菜单里的「添加工作区…」项由 fileGuard.ts MutationObserver
879
+ 按文案兜底隐藏(CSS 选不到纯文本节点)。只在窄屏生效——桌面端照常保留。 */
880
+ button[aria-label="添加工作区"],
881
+ button[aria-label="添加工作区…"],
882
+ button[aria-label="Add workspace"],
883
+ button[aria-label="Add workspace…"] {
884
+ display: none !important;
910
885
  }
911
886
  }
912
887
 
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.7.0"
83
+ "version": "2.7.1"
84
84
  }
@@ -1,115 +0,0 @@
1
- // dsh-web-mobile 移植(MIT,见 LICENSE.dsh-web-mobile):移动端「复制文件内容」按钮。
2
- //
3
- // issue #17:用户希望手机上能拿到 DSH 在会话窗口里生成的文件。但当前 dsh-web
4
- // 的 web UI 没有任何下载能力(全部插件 bundle 里搜不到 download / blob: /
5
- // saveAs / createObjectURL),桌面端的下载按钮在手机经隧道/局域网访问时也拿
6
- // 不到文件内容。退而求其次——在「文件内容块」(对话里渲染为 <pre> 代码块)的
7
- // 右上角注入一个复制按钮,把块内文本(即文件内容)写入剪贴板。
8
- //
9
- // 关键:只依赖稳定结构 `<pre>`(fenced code 渲染为 `<pre><code>`),不依赖任何
10
- // hash 类名。dsh-web 每次构建都会换类名,按类名挂会随版本失效。
11
-
12
- /** 我们注入的按钮标记(复用 mobile-nav 的 data 属性命名空间,避免与插件自身控件冲突)。 */
13
- const BUTTON_ATTR = 'data-mobile-nav';
14
- const BUTTON_VALUE = 'copy-file';
15
- /** 打在代码块容器上:已注入过按钮就不再重复注入(React 重渲染时自愈)。 */
16
- const MARKER = 'data-mobile-nav-copy';
17
-
18
- /**
19
- * 把文本写入剪贴板。优先用 async Clipboard API(隧道/https 下可用);非安全
20
- * 上下文(局域网 http)下 Clipboard API 会被浏览器拒绝,回退到隐藏 textarea +
21
- * execCommand('copy')。两者都失败返回 false。
22
- */
23
- export async function copyText(text: string): Promise<boolean> {
24
- try {
25
- if (navigator.clipboard && window.isSecureContext) {
26
- await navigator.clipboard.writeText(text);
27
- return true;
28
- }
29
- } catch {
30
- /* 落到下面的回退路径 */
31
- }
32
- try {
33
- const ta = document.createElement('textarea');
34
- ta.value = text;
35
- ta.setAttribute('readonly', '');
36
- ta.style.position = 'fixed';
37
- ta.style.top = '0';
38
- ta.style.left = '0';
39
- ta.style.opacity = '0';
40
- document.body.appendChild(ta);
41
- ta.select();
42
- const ok = document.execCommand('copy');
43
- ta.remove();
44
- return ok;
45
- } catch {
46
- return false;
47
- }
48
- }
49
-
50
- /** 造一个复制按钮。点击时复制其所在代码卡里的 <pre> 文本。 */
51
- function createCopyButton(): HTMLButtonElement {
52
- const btn = document.createElement('button');
53
- btn.type = 'button';
54
- btn.setAttribute(BUTTON_ATTR, BUTTON_VALUE);
55
- btn.textContent = '复制';
56
- btn.setAttribute('aria-label', '复制文件内容');
57
- btn.addEventListener('click', async (event) => {
58
- event.preventDefault();
59
- event.stopPropagation();
60
- const card = btn.parentElement;
61
- const block = card?.querySelector('pre') ?? null;
62
- const text = block?.textContent ?? '';
63
- const ok = await copyText(text);
64
- const prev = btn.textContent;
65
- btn.textContent = ok ? '已复制' : '复制失败';
66
- btn.setAttribute('data-copied', ok ? '1' : '0');
67
- window.setTimeout(() => {
68
- btn.textContent = prev;
69
- btn.removeAttribute('data-copied');
70
- }, 1500);
71
- });
72
- return btn;
73
- }
74
-
75
- /** 给一个代码块容器注入复制按钮(幂等)。 */
76
- function injectInto(card: HTMLElement): void {
77
- if (card.hasAttribute(MARKER)) return;
78
- card.setAttribute(MARKER, '1');
79
- if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
80
- card.appendChild(createCopyButton());
81
- }
82
-
83
- /**
84
- * 收集对话里需要挂复制按钮的代码块容器。候选 = 任何 `<pre>`(fenced code 与
85
- * 工具结果/错误块都渲染为 <pre>),跳过已标记 / 空块。返回的是其「容器」
86
- * (pre 的父元素),按钮挂在容器上、定位在块右上角。
87
- */
88
- export function collectCodeBlocks(root: ParentNode): HTMLElement[] {
89
- const out: HTMLElement[] = [];
90
- for (const pre of Array.from(root.querySelectorAll('pre'))) {
91
- const card = pre.parentElement;
92
- if (card === null) continue;
93
- if (card.hasAttribute(MARKER)) continue;
94
- if ((pre.textContent ?? '').trim().length < 1) continue;
95
- out.push(card);
96
- }
97
- return out;
98
- }
99
-
100
- /**
101
- * 启动注入:MutationObserver 监听全文 DOM,新出现的代码块立即挂按钮;React
102
- * 重渲染替换了容器时旧按钮随容器消失、新容器会在下次回调里补上(自愈)。
103
- * @returns 清理函数(断开 observer)。
104
- */
105
- export function startFileCopyInjection(): () => void {
106
- const scan = (): void => {
107
- for (const card of collectCodeBlocks(document)) injectInto(card);
108
- };
109
- const observer = new MutationObserver(scan);
110
- observer.observe(document.body, { childList: true, subtree: true });
111
- scan();
112
- return () => {
113
- observer.disconnect();
114
- };
115
- }