@heycys/gallery 0.2.3 → 0.2.4

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.
Files changed (220) hide show
  1. package/README.md +1 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/assets/preview/runtime.js +156 -0
  4. package/assets/preview/shell.html +107 -0
  5. package/assets/writing-preferences.md +1 -1
  6. package/browser/editor/components/divider/index.mjs +1 -1
  7. package/browser/editor/page/annotate/card.mjs +6 -0
  8. package/browser/editor/page/annotate/index.mjs +22 -2
  9. package/browser/editor/page/columns/index.mjs +1 -1
  10. package/browser/editor/page/conflicts.mjs +1 -1
  11. package/browser/editor/page/drag/index.mjs +1 -1
  12. package/browser/editor/page/editor.mjs +39 -3
  13. package/browser/editor/page/focus.mjs +104 -0
  14. package/browser/editor/page/handle/index.mjs +14 -2
  15. package/browser/editor/page/links/index.mjs +2 -2
  16. package/browser/editor/page/quiet.mjs +3 -3
  17. package/browser/editor/page/resize/index.mjs +1 -1
  18. package/browser/editor/page/session.mjs +4 -1
  19. package/browser/editor/page/ui.mjs +1 -1
  20. package/browser/shared/annotations.mjs +22 -3
  21. package/browser/shared/block-packs.mjs +13 -4
  22. package/browser/shared/document-look.mjs +14 -0
  23. package/browser/shared/drafts.mjs +1 -1
  24. package/browser/shared/extension-manifest.mjs +40 -12
  25. package/browser/shared/favorite-merge.mjs +17 -0
  26. package/browser/shared/favorites.mjs +77 -0
  27. package/browser/shared/i18n/en.mjs +123 -24
  28. package/browser/shared/outline-ops.mjs +109 -0
  29. package/browser/shared/outline.mjs +6 -0
  30. package/browser/shared/pages.mjs +20 -8
  31. package/browser/shared/paths.mjs +5 -2
  32. package/browser/shared/preference-documents.mjs +137 -0
  33. package/browser/shared/presence.mjs +94 -0
  34. package/browser/shared/preview-server.mjs +8 -5
  35. package/browser/shared/qa-levels.mjs +9 -1
  36. package/browser/shared/session.mjs +4 -0
  37. package/browser/shared/shipped-blocks.mjs +22 -4
  38. package/browser/shared/startup.mjs +101 -0
  39. package/browser/shared/workspace-columns.mjs +1 -1
  40. package/browser/shared/workspace-look.mjs +21 -8
  41. package/browser/shared/workspace-registry.mjs +6 -3
  42. package/browser/shared/workspace.mjs +37 -10
  43. package/browser/shared/writing-preferences.mjs +2 -42
  44. package/dist/chunk-25LYSMAE.mjs +2 -0
  45. package/dist/chunk-2I6ZVC5O.mjs +2 -0
  46. package/dist/{chunk-M4NRDYI5.mjs → chunk-2MYTOOP6.mjs} +1 -1
  47. package/dist/chunk-2VNENLER.mjs +2 -0
  48. package/dist/chunk-3FERXZF5.mjs +4 -0
  49. package/dist/chunk-3M6SBBGU.mjs +2 -0
  50. package/dist/chunk-3VCAAF34.mjs +2 -0
  51. package/dist/{chunk-WD4U6XXT.mjs → chunk-3ZTRLGSA.mjs} +1 -1
  52. package/dist/chunk-4FRA5AUE.mjs +2 -0
  53. package/dist/chunk-4LR2QEC2.mjs +4 -0
  54. package/dist/chunk-56SVJENP.mjs +966 -0
  55. package/dist/chunk-5CQISL2G.mjs +4 -0
  56. package/dist/{chunk-RRKJMHNX.mjs → chunk-5I5ZDDWC.mjs} +5 -5
  57. package/dist/chunk-5IHDLEB4.mjs +10 -0
  58. package/dist/chunk-5LRDMV7O.mjs +2 -0
  59. package/dist/chunk-5MVUYRKG.mjs +5 -0
  60. package/dist/chunk-5TLLIZT7.mjs +2 -0
  61. package/dist/chunk-6ILJWFYW.mjs +4 -0
  62. package/dist/chunk-6N25CJ6Y.mjs +3 -0
  63. package/dist/{chunk-SQ6Y2ZMA.mjs → chunk-7DTINW6U.mjs} +2 -2
  64. package/dist/chunk-7KWAGKIK.mjs +2 -0
  65. package/dist/chunk-7WNUOAFK.mjs +2 -0
  66. package/dist/chunk-7XCRW5RC.mjs +2 -0
  67. package/dist/{chunk-BYNPEUDS.mjs → chunk-7XSDTILF.mjs} +1 -1
  68. package/dist/chunk-ABKYFOVR.mjs +2 -0
  69. package/dist/chunk-AMUF7PUN.mjs +2 -0
  70. package/dist/{chunk-FJLM7YE7.mjs → chunk-B2EIMERT.mjs} +2 -2
  71. package/dist/{chunk-QNECWMGR.mjs → chunk-B45IAAU6.mjs} +1 -1
  72. package/dist/chunk-B6RAGF4R.mjs +2 -0
  73. package/dist/chunk-BSJKFE7I.mjs +4 -0
  74. package/dist/chunk-C72EEVLK.mjs +2 -0
  75. package/dist/chunk-CRAUKZ23.mjs +4 -0
  76. package/dist/chunk-F4CT35KL.mjs +2 -0
  77. package/dist/chunk-F63BNJLG.mjs +2 -0
  78. package/dist/chunk-FBKUPS44.mjs +2 -0
  79. package/dist/chunk-FL52ZL5Z.mjs +15 -0
  80. package/dist/chunk-FOZCU4VW.mjs +2 -0
  81. package/dist/chunk-G5HV7BC3.mjs +2 -0
  82. package/dist/{chunk-ERLAIBOA.mjs → chunk-GB5SOYQJ.mjs} +2 -2
  83. package/dist/chunk-GN3SG3IF.mjs +2 -0
  84. package/dist/{chunk-6QWQXHAV.mjs → chunk-HFTBVEZK.mjs} +1 -1
  85. package/dist/chunk-IOW5HLRK.mjs +2 -0
  86. package/dist/chunk-IQ3GJNTI.mjs +6 -0
  87. package/dist/chunk-K6SPFBUJ.mjs +2 -0
  88. package/dist/chunk-K7Y7SPTS.mjs +2 -0
  89. package/dist/chunk-L2IJCEUH.mjs +2 -0
  90. package/dist/{chunk-GPIWQ4X4.mjs → chunk-L3VT6LM4.mjs} +1 -1
  91. package/dist/chunk-L4WLQYFC.mjs +5 -0
  92. package/dist/{chunk-NLDF35P5.mjs → chunk-LAMCAGK6.mjs} +1 -1
  93. package/dist/chunk-LHGKPMTI.mjs +2 -0
  94. package/dist/chunk-LJZIYVNA.mjs +3 -0
  95. package/dist/chunk-LSVCTLS2.mjs +2 -0
  96. package/dist/chunk-LZY74XTP.mjs +2 -0
  97. package/dist/chunk-MC4LRSVH.mjs +2 -0
  98. package/dist/chunk-MCEXN4G5.mjs +2 -0
  99. package/dist/chunk-MTLAOCFF.mjs +181 -0
  100. package/dist/chunk-MZ66LN3B.mjs +2 -0
  101. package/dist/chunk-N5QWJRRI.mjs +2 -0
  102. package/dist/chunk-NF4MMOUB.mjs +2 -0
  103. package/dist/chunk-NHJYAJ73.mjs +3 -0
  104. package/dist/chunk-NRSD7SVY.mjs +2 -0
  105. package/dist/chunk-NT23I4XW.mjs +2 -0
  106. package/dist/chunk-NUALABIS.mjs +3 -0
  107. package/dist/chunk-NW6LAU22.mjs +2 -0
  108. package/dist/chunk-NWWHDGKF.mjs +5 -0
  109. package/dist/chunk-OFRCVMSJ.mjs +2 -0
  110. package/dist/chunk-OKVQQ3CX.mjs +4 -0
  111. package/dist/chunk-OM24O4KR.mjs +8 -0
  112. package/dist/{chunk-4B2TB5K2.mjs → chunk-PBMOC7KA.mjs} +2 -2
  113. package/dist/chunk-PVTCP43W.mjs +2 -0
  114. package/dist/chunk-Q62HZAJS.mjs +2 -0
  115. package/dist/chunk-QBJWIR3Q.mjs +2 -0
  116. package/dist/chunk-QDSEZX7Z.mjs +6 -0
  117. package/dist/chunk-QR4V4A2Z.mjs +15 -0
  118. package/dist/{chunk-QVGOPHOF.mjs → chunk-QRLVVZ3R.mjs} +1 -1
  119. package/dist/chunk-QW3QG67J.mjs +2 -0
  120. package/dist/chunk-R2T2EVWY.mjs +8 -0
  121. package/dist/{chunk-JJNHKGTD.mjs → chunk-R5RERE6G.mjs} +1 -1
  122. package/dist/chunk-RFHEWT3Y.mjs +5 -0
  123. package/dist/chunk-RS2Z2JQP.mjs +2 -0
  124. package/dist/chunk-S6YEC4GE.mjs +16 -0
  125. package/dist/chunk-SHZLMGGW.mjs +2 -0
  126. package/dist/chunk-TALJQM2O.mjs +2 -0
  127. package/dist/chunk-TLNJWEFY.mjs +22 -0
  128. package/dist/{chunk-ZLS2FRS6.mjs → chunk-TQZU7D7C.mjs} +1 -1
  129. package/dist/chunk-UTKNWYBA.mjs +2 -0
  130. package/dist/chunk-V3TM3TEN.mjs +2 -0
  131. package/dist/chunk-V3XROSP5.mjs +7 -0
  132. package/dist/{chunk-6QBHU4DF.mjs → chunk-V5JTW2LH.mjs} +1 -1
  133. package/dist/chunk-VAXLSISW.mjs +4 -0
  134. package/dist/chunk-VBHI3RG6.mjs +2 -0
  135. package/dist/{chunk-CRDG6EF2.mjs → chunk-VKZJRGDR.mjs} +1 -1
  136. package/dist/chunk-VZXZM7NS.mjs +2 -0
  137. package/dist/chunk-WDS6HEPE.mjs +2 -0
  138. package/dist/chunk-WHGANNEY.mjs +13 -0
  139. package/dist/chunk-WMJWC4QY.mjs +56 -0
  140. package/dist/{chunk-UTMAY3WK.mjs → chunk-WWONJJQS.mjs} +1 -1
  141. package/dist/chunk-X4WNLFIK.mjs +2 -0
  142. package/dist/chunk-XNNCQROX.mjs +11 -0
  143. package/dist/chunk-XRYECF23.mjs +170 -0
  144. package/dist/{chunk-225ALKYI.mjs → chunk-Z4NEXXFK.mjs} +1 -1
  145. package/dist/{chunk-PKVK6WFC.mjs → chunk-ZFVF2YPV.mjs} +2 -2
  146. package/dist/chunk-ZM2IX3R2.mjs +2 -0
  147. package/dist/chunk-ZNGGDUZI.mjs +2 -0
  148. package/dist/chunk-ZOEJQOKT.mjs +2 -0
  149. package/dist/cli.mjs +1 -1
  150. package/dist/server.mjs +1 -1
  151. package/dist/update-fetch.mjs +1 -1
  152. package/dist/worker.mjs +1 -1
  153. package/package.json +1 -1
  154. package/release.json +3 -3
  155. package/skill/SKILL.md +54 -148
  156. package/skill/references/building-blocks.md +4 -4
  157. package/skill/references/charts-and-figures.md +73 -0
  158. package/skill/references/extensions.md +47 -4
  159. package/skill/references/pages-and-data.md +22 -3
  160. package/skill/references/preference-files.md +7 -0
  161. package/skill/references/previews.md +17 -0
  162. package/skill/references/writing-html.md +21 -82
  163. package/web/assets/index-B4uUluLM.js +15 -0
  164. package/web/assets/index-C3vHT8sH.css +1 -0
  165. package/web/index.html +2 -2
  166. package/dist/chunk-2HS7ZBPU.mjs +0 -2
  167. package/dist/chunk-3F57LEN5.mjs +0 -2
  168. package/dist/chunk-4OZWXZE5.mjs +0 -5
  169. package/dist/chunk-5GK7HS62.mjs +0 -2
  170. package/dist/chunk-6GV3CBB2.mjs +0 -2
  171. package/dist/chunk-6NLXUKAY.mjs +0 -4
  172. package/dist/chunk-6XD6U3QA.mjs +0 -5
  173. package/dist/chunk-6Y3HYOBM.mjs +0 -2
  174. package/dist/chunk-7EFHNQ5B.mjs +0 -3
  175. package/dist/chunk-7M2BQQGZ.mjs +0 -2
  176. package/dist/chunk-7UZUQQ7Q.mjs +0 -554
  177. package/dist/chunk-ALZ6ZENZ.mjs +0 -3
  178. package/dist/chunk-BX5AKINW.mjs +0 -15
  179. package/dist/chunk-DR73Y2UJ.mjs +0 -3
  180. package/dist/chunk-EURLTOTZ.mjs +0 -4
  181. package/dist/chunk-F4ZA6JRZ.mjs +0 -3
  182. package/dist/chunk-FNCCVGKX.mjs +0 -2
  183. package/dist/chunk-FOJ3K5Z3.mjs +0 -6
  184. package/dist/chunk-FQ72LZHB.mjs +0 -2
  185. package/dist/chunk-G7N7YFVD.mjs +0 -2
  186. package/dist/chunk-GIRXJBGC.mjs +0 -2
  187. package/dist/chunk-GKOBB6DH.mjs +0 -2
  188. package/dist/chunk-GMJD7ID3.mjs +0 -2
  189. package/dist/chunk-GQJMVSSN.mjs +0 -2
  190. package/dist/chunk-HX4DLE3J.mjs +0 -10
  191. package/dist/chunk-IS7N3N6Q.mjs +0 -168
  192. package/dist/chunk-JEUK3ZO7.mjs +0 -2
  193. package/dist/chunk-JPG2LBQM.mjs +0 -11
  194. package/dist/chunk-JYK5WJPM.mjs +0 -2
  195. package/dist/chunk-KLQP46Y3.mjs +0 -4
  196. package/dist/chunk-L55NOXBE.mjs +0 -4
  197. package/dist/chunk-LCB6MARS.mjs +0 -5
  198. package/dist/chunk-LJLVCU6W.mjs +0 -16
  199. package/dist/chunk-LMUZQK5O.mjs +0 -2
  200. package/dist/chunk-NMF3IPYA.mjs +0 -13
  201. package/dist/chunk-ONKC4BCC.mjs +0 -2
  202. package/dist/chunk-OU6ZZVBX.mjs +0 -5
  203. package/dist/chunk-PUOQ65NC.mjs +0 -2
  204. package/dist/chunk-QJAVCTY7.mjs +0 -2
  205. package/dist/chunk-QP7BOHE4.mjs +0 -2
  206. package/dist/chunk-S3SX3COT.mjs +0 -20
  207. package/dist/chunk-SQ3FQCCN.mjs +0 -8
  208. package/dist/chunk-T4Y2ZSVX.mjs +0 -4
  209. package/dist/chunk-TANZRINB.mjs +0 -2
  210. package/dist/chunk-UGWMMEJ5.mjs +0 -15
  211. package/dist/chunk-UTF7GBQU.mjs +0 -4
  212. package/dist/chunk-V4ZHRALW.mjs +0 -170
  213. package/dist/chunk-XBNFXWAW.mjs +0 -6
  214. package/dist/chunk-XZV72SQ4.mjs +0 -2
  215. package/dist/chunk-YFJB534W.mjs +0 -2
  216. package/dist/chunk-Z6OKLRHC.mjs +0 -2
  217. package/dist/chunk-ZMHUDYLV.mjs +0 -2
  218. package/web/assets/index-D8P8uhDW.js +0 -15
  219. package/web/assets/index-FLb9jCfl.css +0 -1
  220. /package/dist/{chunk-PAREWQGB.mjs → chunk-NOGQX56R.mjs} +0 -0
package/README.md CHANGED
@@ -41,7 +41,7 @@ Explorer shows, OneDrive's when OneDrive backs it up) unless you name another (`
41
41
  `GALLERY_WORKSPACE` variable, or `gallery init <dir>`, which marks a folder as one). Each document is a folder
42
42
  (`<slug>/doc.html`, `doc.json`), the deliverables are in `exports/`, and Gallery keeps its own state in `.gallery/`.
43
43
 
44
- The server listens on 127.0.0.1 only (a port from 4317), one per workspace; `gallery stop` stops it. After
44
+ The server listens on 127.0.0.1 only, reusing its last port or trying 8080–8099, one per Gallery data directory; `gallery stop` stops it. After
45
45
  upgrading, run `gallery open` once (the server of the version before stops by itself and the new one starts) and
46
46
  `gallery skill install` again (the skill of the new version). From the older Gallery skill (the whole tools in the
47
47
  agent's skills folder), `gallery skill install` copies its documents into the workspace and moves it out of the
@@ -1,6 +1,6 @@
1
1
  # Third-party notices
2
2
 
3
- @heycys/gallery 0.2.3 carries the following code and data of others, bundled into dist/ and web/, built into kit/, or as files in vendor/ and browser/.
3
+ @heycys/gallery 0.2.4 carries the following code and data of others, bundled into dist/ and web/, built into kit/, or as files in vendor/ and browser/.
4
4
 
5
5
  ## @asamuzakjp/css-color 5.1.11
6
6
 
@@ -0,0 +1,156 @@
1
+ // Preview runtime: injected at the top of every preview page by cli/preview.mjs.
2
+ // Gives the page the toolbar state (theme, width, its own options) and Gallery's tooltip, toast, confirm and menu,
3
+ // drawn with Gallery's own classes, so a page only writes what is new.
4
+ (() => {
5
+ const listeners = new Set();
6
+ let state = (() => { try { return { ...parent.__pvState }; } catch { return {}; } })();
7
+
8
+ const ICON = {
9
+ close: '<svg viewBox="0 0 20 20"><path d="m5.5 5.5 9 9M14.5 5.5l-9 9"/></svg>',
10
+ plus: '<svg viewBox="0 0 16 16"><path d="M8 3.25v9.5M3.25 8h9.5"/></svg>',
11
+ more: '<svg viewBox="0 0 16 16" class="fill"><circle cx="3.25" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="12.75" cy="8" r="1.25"/></svg>',
12
+ rename: '<svg viewBox="0 0 20 20"><path d="M12.75 4.25 15.75 7.25 7.5 15.5l-3.75.75.75-3.75z"/></svg>',
13
+ duplicate: '<svg viewBox="0 0 20 20"><rect x="6.75" y="6.75" width="9.5" height="9.5" rx="1.75"/><path d="M13.25 6.75v-1.5a1.5 1.5 0 0 0-1.5-1.5h-6.5a1.5 1.5 0 0 0-1.5 1.5v6.5a1.5 1.5 0 0 0 1.5 1.5h1.5"/></svg>',
14
+ trash: '<svg viewBox="0 0 20 20"><path d="M3.75 5.75h12.5M8 5.75V4.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.25M5.25 5.75l.7 9.6a1.5 1.5 0 0 0 1.5 1.4h5.1a1.5 1.5 0 0 0 1.5-1.4l.7-9.6"/></svg>',
15
+ folder: '<svg viewBox="0 0 24 24" class="lu"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>',
16
+ copy: '<svg viewBox="0 0 24 24" class="lu"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
17
+ file: '<svg viewBox="0 0 24 24" class="lu"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>',
18
+ puzzle: '<svg viewBox="0 0 24 24" class="lu"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>',
19
+ column: '<svg viewBox="0 0 24 24" class="lu"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>',
20
+ window: '<svg viewBox="0 0 24 24" class="lu"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/></svg>',
21
+ server: '<svg viewBox="0 0 24 24" class="lu"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>',
22
+ bot: '<svg viewBox="0 0 24 24" class="lu"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>',
23
+ };
24
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
25
+
26
+ function apply(next, key) {
27
+ state = { ...state, ...next };
28
+ document.documentElement.dataset.theme = state.theme === 'dark' ? 'dark' : 'light';
29
+ document.documentElement.style.colorScheme = state.theme === 'dark' ? 'dark' : 'light';
30
+ for (const fn of listeners) fn(state, key);
31
+ }
32
+ addEventListener('message', e => { if (e.data?.pv === 'state') apply(e.data.state, e.data.key); });
33
+ if (!state.theme) try { parent.postMessage({ pv: 'hello' }, '*'); } catch {}
34
+
35
+ // Tooltip: Gallery shows one for any [data-tip] (data-tip-delay in ms, default 450).
36
+ let tipEl = null, tipTimer = 0;
37
+ const hideTip = () => { clearTimeout(tipTimer); tipEl?.classList.remove('show'); };
38
+ document.addEventListener('mouseover', e => {
39
+ const t = e.target.closest?.('[data-tip]');
40
+ hideTip();
41
+ if (!t || !t.dataset.tip || document.body.classList.contains('pv-dragging')) return;
42
+ tipTimer = setTimeout(() => {
43
+ tipEl ??= document.body.appendChild(Object.assign(document.createElement('div'), { className: 'tip' }));
44
+ tipEl.textContent = t.dataset.tip;
45
+ const r = t.getBoundingClientRect(), w = tipEl.offsetWidth, h = tipEl.offsetHeight;
46
+ const x = Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2));
47
+ const y = r.bottom + 6 + h > innerHeight - 8 ? r.top - h - 6 : r.bottom + 6;
48
+ tipEl.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
49
+ tipEl.classList.add('show');
50
+ }, Number(t.dataset.tipDelay) || 450);
51
+ });
52
+ document.addEventListener('pointerdown', hideTip, true);
53
+
54
+ let toastTimer = 0;
55
+ function toast(text, { action, onAction, tone } = {}) {
56
+ document.querySelector('.toast')?.remove();
57
+ const t = document.createElement('div');
58
+ t.className = 'toast' + (tone === 'error' ? ' error' : '');
59
+ t.setAttribute('role', 'status');
60
+ t.innerHTML = '<span>' + esc(text) + '</span>' + (action ? '<button type="button">' + esc(action) + '</button>' : '');
61
+ t.querySelector('button')?.addEventListener('click', () => { t.remove(); onAction?.(); });
62
+ document.body.append(t);
63
+ requestAnimationFrame(() => t.classList.add('show'));
64
+ clearTimeout(toastTimer);
65
+ toastTimer = setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 200); }, 4000);
66
+ }
67
+
68
+ let modal = null;
69
+ function confirm({ title, message, actions = [{ id: 'cancel', label: '取消' }, { id: 'ok', label: '确定', primary: true }] }) {
70
+ return new Promise(done => {
71
+ modal?.end('cancel');
72
+ const m = document.createElement('div');
73
+ m.className = 'modal dialog-overlay';
74
+ m.innerHTML = '<div class="dialog" role="alertdialog" aria-modal="true"><div class="dialog-title">' + esc(title) + '</div>' +
75
+ (message ? '<div class="dialog-message">' + esc(message) + '</div>' : '') + '<div class="dialog-actions">' +
76
+ actions.map(a => '<button type="button" class="dialog-btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '') + '" data-a="' + esc(a.id) + '">' + esc(a.label) + '</button>').join('') + '</div></div>';
77
+ const back = document.activeElement;
78
+ const end = id => { m.remove(); modal = null; back?.focus?.({ preventScroll: true }); done(id); };
79
+ m.addEventListener('pointerdown', e => { if (e.target === m) end('cancel'); });
80
+ m.addEventListener('click', e => { const b = e.target.closest('[data-a]'); if (b) end(b.dataset.a); });
81
+ document.body.append(m);
82
+ modal = { end };
83
+ (m.querySelector('.primary') || m.querySelector('button')).focus();
84
+ });
85
+ }
86
+
87
+ // items: { icon, label, danger, disabled, tip, run } or '-' for a line. Returns a function that closes it.
88
+ let menuEl = null, menuAnchor = null, menuDone = null;
89
+ function closeMenu() {
90
+ if (!menuEl) return;
91
+ menuEl.remove(); menuEl = null;
92
+ menuAnchor?.classList.remove('pv-menu-open'); menuAnchor?.setAttribute?.('aria-expanded', 'false');
93
+ const d = menuDone; menuAnchor = menuDone = null; d?.();
94
+ }
95
+ function menu(anchor, items, { align = 'start', onClose } = {}) {
96
+ closeMenu(); hideTip();
97
+ menuEl = document.createElement('div');
98
+ menuEl.className = 'menu';
99
+ menuEl.setAttribute('role', 'menu');
100
+ menuEl.style.zIndex = 1600;
101
+ menuEl.innerHTML = items.filter(Boolean).map((it, i) => it === '-' ? '<div class="menu-sep"></div>' :
102
+ '<div class="menu-item' + (it.danger ? ' danger' : '') + (it.disabled ? ' off' : '') + '" role="menuitem" data-i="' + i + '"' +
103
+ (it.disabled ? ' aria-disabled="true"' : '') + (it.tip ? ' data-tip="' + esc(it.tip) + '"' : '') + '>' +
104
+ (it.icon ? '<span class="mi-icon">' + (ICON[it.icon] || it.icon) + '</span>' : '') + '<span class="mi-label">' + esc(it.label) + '</span></div>').join('');
105
+ document.body.append(menuEl);
106
+ const r = anchor.getBoundingClientRect(), w = menuEl.offsetWidth;
107
+ menuEl.style.left = Math.max(8, Math.min(align === 'end' ? r.right - w : r.left, innerWidth - w - 8)) + 'px';
108
+ menuEl.style.top = (r.bottom + 4 + menuEl.offsetHeight > innerHeight - 8 ? Math.max(8, r.top - 4 - menuEl.offsetHeight) : r.bottom + 4) + 'px';
109
+ menuAnchor = anchor; menuDone = onClose;
110
+ anchor.classList.add('pv-menu-open'); anchor.setAttribute?.('aria-expanded', 'true');
111
+ const list = items.filter(Boolean);
112
+ menuEl.addEventListener('click', e => {
113
+ const el = e.target.closest('.menu-item');
114
+ if (!el || el.classList.contains('off')) return;
115
+ const it = list[el.dataset.i];
116
+ closeMenu();
117
+ it.run?.();
118
+ });
119
+ return closeMenu;
120
+ }
121
+ document.addEventListener('pointerdown', e => {
122
+ if (menuEl && !menuEl.contains(e.target) && !menuAnchor?.contains(e.target)) closeMenu();
123
+ }, true);
124
+ // Escape closes what is on top first: the confirm, then the menu; only then does the page see it.
125
+ document.addEventListener('keydown', e => {
126
+ if (e.key !== 'Escape') return;
127
+ if (modal) { e.stopImmediatePropagation(); e.preventDefault(); modal.end('cancel'); }
128
+ else if (menuEl) { e.stopImmediatePropagation(); e.preventDefault(); const a = menuAnchor; closeMenu(); a?.focus?.({ preventScroll: true }); }
129
+ }, true);
130
+
131
+ // A Gallery window behind the page's dialog: <body data-backdrop="gallery">.
132
+ document.addEventListener('DOMContentLoaded', () => {
133
+ if (document.body.dataset.backdrop !== 'gallery') return;
134
+ const b = document.createElement('div');
135
+ b.className = 'pv-backdrop';
136
+ b.setAttribute('aria-hidden', 'true');
137
+ b.innerHTML = '<div class="pv-backdrop-side"><i style="width:40%"></i><i style="width:70%"></i><i style="width:55%"></i><i style="width:80%"></i><i style="width:62%"></i></div>' +
138
+ '<div class="pv-backdrop-main"><i style="width:36%;height:22px"></i><i style="width:90%"></i><i style="width:84%"></i><i style="width:72%"></i></div>';
139
+ document.body.prepend(b);
140
+ });
141
+ const style = document.createElement('style');
142
+ style.textContent = 'body{margin:0;font-family:var(--font);font-size:14px;background:var(--bacPri);color:var(--texPri)}' +
143
+ '.pv-backdrop{position:fixed;inset:0;display:flex}.pv-backdrop-side{width:270px;flex:none;box-sizing:border-box;background:var(--sidBac);padding:56px 16px;display:flex;flex-direction:column;gap:16px}' +
144
+ '.pv-backdrop-main{flex:1;min-width:0;padding:72px 64px;display:flex;flex-direction:column;gap:18px}.pv-backdrop i{display:block;height:10px;border-radius:5px;background:var(--bacTerTra)}' +
145
+ '@media (max-width:700px){.pv-backdrop-side{display:none}}';
146
+ document.head.prepend(style);
147
+
148
+ window.preview = {
149
+ get config() { try { return JSON.parse(document.getElementById('preview-config')?.textContent || '{}'); } catch { return {}; } },
150
+ get state() { return state; },
151
+ on(fn) { listeners.add(fn); fn(state, null); return () => listeners.delete(fn); },
152
+ set(next) { try { parent.postMessage({ pv: 'set', state: next }, '*'); } catch {} },
153
+ toast, confirm, menu, closeMenu, hideTip, icon: ICON, esc,
154
+ };
155
+ apply({});
156
+ })();
@@ -0,0 +1,107 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>预览 · {{TITLE}}</title>
7
+ <style>
8
+ :root { --pv-bg: #f1f0ee; --pv-bar: #fbfbfa; --pv-line: #e3e2e0; --pv-text: #37352f; --pv-sub: #787774; --pv-hover: #37352f0f; --pv-on: #37352f17; --pv-frame: #ffffff; }
9
+ :root[data-theme="dark"] { --pv-bg: #151515; --pv-bar: #202020; --pv-line: #2f2f2f; --pv-text: #e6e6e5; --pv-sub: #9b9b9b; --pv-hover: #ffffff0e; --pv-on: #ffffff1a; --pv-frame: #191919; }
10
+ html, body { height: 100%; margin: 0; }
11
+ body { display: flex; flex-direction: column; background: var(--pv-bg); color: var(--pv-text); font: 13px/1.4 -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif; }
12
+ .pvs-bar { flex: none; display: flex; align-items: center; gap: 12px; height: 44px; padding: 0 12px; box-sizing: border-box; background: var(--pv-bar); border-bottom: 1px solid var(--pv-line); }
13
+ .pvs-title, .pvs-hint { flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
14
+ .pvs-title { font-weight: 600; }
15
+ .pvs-hint { color: var(--pv-sub); font-size: 12px; text-align: right; }
16
+ .pvs-tools { flex: none; display: flex; align-items: center; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
17
+ .pvs-group { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
18
+ .pvs-group + .pvs-group { margin-left: 6px; padding-left: 8px; border-left: 1px solid var(--pv-line); }
19
+ .pvs-label { padding: 0 4px; color: var(--pv-sub); font-size: 12px; }
20
+ .pvs-tools button { height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: none; color: var(--pv-sub); font: inherit; cursor: pointer; }
21
+ .pvs-tools button:hover { background: var(--pv-hover); }
22
+ .pvs-tools button[aria-pressed="true"] { background: var(--pv-on); color: var(--pv-text); font-weight: 500; }
23
+ .pvs-tools button:focus-visible { outline: 2px solid #2383e2; outline-offset: -2px; }
24
+ .pvs-stage { flex: 1; min-height: 0; display: flex; justify-content: center; }
25
+ .pvs-stage iframe { display: block; width: 100%; max-width: 100%; height: 100%; border: 0; background: var(--pv-frame); }
26
+ .pvs-stage[data-fixed] iframe { box-shadow: 0 0 0 1px var(--pv-line); }
27
+ @media (max-width: 900px) { .pvs-title, .pvs-hint { display: none; } .pvs-bar { justify-content: center; } }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <header class="pvs-bar">
32
+ <div class="pvs-title"></div>
33
+ <div class="pvs-tools" role="toolbar" aria-label="预览设置"></div>
34
+ <div class="pvs-hint"></div>
35
+ </header>
36
+ <main class="pvs-stage"><iframe title="预览"></iframe></main>
37
+ <script>
38
+ (() => {
39
+ const CONFIG = /*CONFIG*/;
40
+ const PAGE = /*PAGE*/;
41
+ const WIDTH_NAMES = { normal: '正常', wide: '宽', narrow: '窄', phone: '手机' };
42
+ const groups = [
43
+ ...(CONFIG.options || []).map(o => ({ key: o.key, label: o.label, values: Object.entries(o.values) })),
44
+ { key: 'theme', label: '主题', values: [['light', '浅色'], ['dark', '深色']] },
45
+ { key: 'width', label: '宽度', values: Object.keys(CONFIG.widths).map(k => [k, WIDTH_NAMES[k] || k]) },
46
+ ];
47
+ const fromHash = Object.fromEntries(new URLSearchParams(location.hash.slice(1)));
48
+ const state = {};
49
+ for (const g of groups) {
50
+ const fallback = g.key === 'theme' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : g.values[0][0];
51
+ state[g.key] = g.values.some(([v]) => v === fromHash[g.key]) ? fromHash[g.key] : fallback;
52
+ }
53
+ window.__pvState = state;
54
+
55
+ const $ = s => document.querySelector(s);
56
+ const esc = value => String(value).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
57
+ const frame = $('iframe'), stage = $('.pvs-stage');
58
+ $('.pvs-title').textContent = CONFIG.title || '';
59
+ $('.pvs-hint').textContent = CONFIG.hint || '';
60
+ $('.pvs-tools').innerHTML = groups.map(g => '<div class="pvs-group"><span class="pvs-label">' + esc(g.label) + '</span>' +
61
+ g.values.map(([v, label]) => '<button type="button" data-k="' + esc(g.key) + '" data-v="' + esc(v) + '">' + esc(label) + '</button>').join('') + '</div>').join('');
62
+
63
+ function show() {
64
+ document.documentElement.dataset.theme = state.theme;
65
+ document.querySelectorAll('.pvs-tools button').forEach(b => b.setAttribute('aria-pressed', String(state[b.dataset.k] === b.dataset.v)));
66
+ const px = CONFIG.widths[state.width];
67
+ frame.style.width = px ? px + 'px' : '100%';
68
+ stage.toggleAttribute('data-fixed', Boolean(px));
69
+ history.replaceState(null, '', '#' + new URLSearchParams(state));
70
+ }
71
+ function set(next, key) {
72
+ Object.assign(state, next);
73
+ show();
74
+ frame.contentWindow?.postMessage({ pv: 'state', state: { ...state }, key }, '*');
75
+ }
76
+ $('.pvs-tools').addEventListener('click', e => {
77
+ const b = e.target.closest('button');
78
+ if (b && state[b.dataset.k] !== b.dataset.v) set({ [b.dataset.k]: b.dataset.v }, b.dataset.k);
79
+ });
80
+ addEventListener('message', e => {
81
+ if (e.source !== frame.contentWindow) return;
82
+ if (e.data?.pv === 'set') set(e.data.state, Object.keys(e.data.state)[0]);
83
+ if (e.data?.pv === 'hello') frame.contentWindow.postMessage({ pv: 'state', state: { ...state }, key: 'init' }, '*');
84
+ });
85
+
86
+ let loaded;
87
+ const ready = new Promise(r => { loaded = r; });
88
+ frame.addEventListener('load', () => loaded(), { once: true });
89
+ show();
90
+ frame.srcdoc = PAGE;
91
+
92
+ // For scripts that drive the preview (screenshots, checks): run code inside the page, find where an element is.
93
+ window.pv = {
94
+ ready, state, set,
95
+ run: code => frame.contentWindow.eval('(async () => {\n' + code + '\n})()'),
96
+ rect(selector) {
97
+ const f = frame.getBoundingClientRect(), el = frame.contentDocument.querySelector(selector);
98
+ if (!el) return null;
99
+ const r = el.getBoundingClientRect();
100
+ return { x: f.left + r.left + r.width / 2, y: f.top + r.top + r.height / 2, left: f.left + r.left, top: f.top + r.top, width: r.width, height: r.height };
101
+ },
102
+ frameRect() { const r = frame.getBoundingClientRect(); return { x: r.left, y: r.top, width: r.width, height: r.height }; },
103
+ };
104
+ })();
105
+ </script>
106
+ </body>
107
+ </html>
@@ -1,4 +1,4 @@
1
- # 写作偏好
1
+ # 项目报告写作
2
2
 
3
3
  ## 写作流程
4
4
 
@@ -42,7 +42,7 @@ export default {
42
42
  },
43
43
  label: t("分割线"), icon: svg('<path d="M3 10h14"/><path d="M5.5 6h9M5.5 14h9" stroke-opacity=".35"/>'),
44
44
  hint: "---", markdown: [{ match: /^---$/, alone: true }], words: "分割线 分隔线 横线 fengexian fgx hr divider line rule",
45
- glance: { row: 5, name: "分割线", write: "`<hr>` first in a section", rule: "The page" },
45
+ glance: { row: 5, name: "分割线", write: "`<hr>` where a visual break is useful", rule: "The page" },
46
46
  },
47
47
  },
48
48
  };
@@ -61,6 +61,12 @@ export const STYLE = `
61
61
  .ge-note-delete { position: absolute; top: 7px; right: 7px; width: 24px; height: 24px; box-sizing: border-box; margin: 0; padding: 0; border: 0; border-radius: 6px; background: transparent; display: grid; place-items: center; color: var(--ge-ico3); cursor: pointer; }
62
62
  .ge-note-delete svg { width: 18px; height: 18px; pointer-events: none; }
63
63
  .ge-note-bar { width: 2px; border-radius: 1px; background: var(--ge-note-line); }
64
+ /* Acted on (gallery notes done): grey, as a resolved comment is quieter. */
65
+ .ge-note-card[data-done] .ge-note-face { opacity: 0.55; }
66
+ .ge-note-card[data-done] textarea { color: var(--ge-tx3); }
67
+ .ge-note-marker[data-done] { color: var(--ge-ico3); opacity: 0.6; }
68
+ .ge-note-bar[data-done] { opacity: 0.4; }
69
+ .ge-note-card[data-done][data-front] .ge-note-face { opacity: 1; }
64
70
  .ge-note-bar[data-hot] { background: var(--ge-note-hot-line); }
65
71
  @media print {
66
72
  .ge-note-layer { display: none; }
@@ -113,6 +113,7 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
113
113
  let items = new Map(); // id -> { id, created, updated, text, anchor } in page order, as the server last said, with the page's own changes
114
114
  let filed = new Map(); // id -> its text as the file last held it (the server's last answer): what 撤销 brings back of one emptied in its card
115
115
  let version = null; // the file's, as last read or written
116
+ let toldAt = null; // when the removal by a gallery command the workbench was last told of happened (gallery notes remove)
116
117
  const lost = new Map(); // id -> an annotation a check deleted this visit (its words gone): back when an undo brings them back
117
118
  const timers = new Map(); // id -> the timer of its save (typed, not saved yet)
118
119
  const sending = new Set(); // ids whose save is on its way
@@ -218,9 +219,16 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
218
219
  resolved = false;
219
220
  everything = true;
220
221
  remeasure = true;
222
+ marksChanged = true; // done ones are drawn quietly
221
223
  if (items.size > 0) followText();
222
224
  wake();
223
225
  changed();
226
+ // An Agent removed some at the reader's word (gallery notes remove): offered back as a card's delete button's is.
227
+ const gone = answer.commandRemoved;
228
+ if (gone?.items?.length > 0 && gone.at !== toldAt) {
229
+ toldAt = gone.at;
230
+ removed(gone.items);
231
+ }
224
232
  return true;
225
233
  }
226
234
 
@@ -506,6 +514,7 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
506
514
  function cardOf(id) {
507
515
  const card = cards.get(id) ?? makeCard(id);
508
516
  const text = items.get(id)?.text ?? "";
517
+ card.node.toggleAttribute("data-done", items.get(id)?.done === true);
509
518
  if (card.field.value !== text && document.activeElement !== card.field) {
510
519
  card.field.value = text;
511
520
  card.height = 0;
@@ -829,7 +838,7 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
829
838
  .sort((a, b) => a.at.compareBoundaryPoints(Range.START_TO_START, b.at));
830
839
  return listed.map(({ item, view }) => {
831
840
  const words = view.range || view.unknown ? view.excerpt?.quote ?? item.anchor?.quote : blockSelection(view.units)?.excerpt.quote;
832
- return { id: item.id, text: item.text, quote: tidy(words) };
841
+ return { id: item.id, text: item.text, quote: tidy(words), done: item.done === true };
833
842
  });
834
843
  }
835
844
 
@@ -1071,6 +1080,7 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
1071
1080
  const strong = [];
1072
1081
  for (const [id, view] of views) {
1073
1082
  if (!view.range || view.hidden || view.folded) continue;
1083
+ if (id !== hotId && items.get(id)?.done) continue; // acted on: the words are left unmarked
1074
1084
  (id === hotId ? strong : plain).push(view.range);
1075
1085
  }
1076
1086
  if (shown?.id && !views.has(shown.id) && made.get(shown.id)?.range) strong.push(made.get(shown.id).range);
@@ -1078,7 +1088,9 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
1078
1088
  CSS.highlights.set(HOT, new Highlight(...strong));
1079
1089
  for (const [id, view] of views) {
1080
1090
  view.marker?.toggleAttribute("data-hot", id === hotId);
1091
+ view.marker?.toggleAttribute("data-done", items.get(id)?.done === true);
1081
1092
  view.bar?.toggleAttribute("data-hot", id === shown?.id || id === hot);
1093
+ view.bar?.toggleAttribute("data-done", items.get(id)?.done === true);
1082
1094
  }
1083
1095
  }
1084
1096
 
@@ -1167,7 +1179,7 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
1167
1179
  // BAR.reach), so that each keeps the pointer it is for, and within the column's right room (margin.mjs).
1168
1180
  function markerAcross(right, units) {
1169
1181
  const section = units.map((unit) => sectionOps()?.sectionOf(unit)).find(Boolean);
1170
- return markerLeft(right, windowWidth(), section ? Math.min(section.getBoundingClientRect().right + BAR.reach, right + rightRoom() - RAIL.marker) : 0);
1182
+ return markerLeft(right, windowWidth(), section ? Math.min(section.getBoundingClientRect().right + BAR.gap + BAR.width + 2, right + rightRoom() - RAIL.marker) : 0);
1171
1183
  }
1172
1184
 
1173
1185
  // Where a marker of `unit` alone stands (window coordinates): the place of the button beside it
@@ -1572,6 +1584,14 @@ export function createAnnotate({ slug, token, isEditing, picked, unpick = () =>
1572
1584
  const answer = await fetch(`${ROUTE}?slug=${encodeURIComponent(slug)}&as=copy`, { headers: { "X-Gallery-Token": token }, cache: "no-store" }).then((response) => response.json()).catch(() => ({ ok: false, reason: "offline" }));
1573
1585
  return answer.ok ? { ok: true, data: { title: answer.title, path: answer.path, items: answer.items } } : { ok: false, reason: answer.reason ?? "error" };
1574
1586
  },
1587
+ // 删除已处理: those marked done (gallery notes done), what was typed saved first; 撤销 brings them back (the workbench's).
1588
+ async clearDone() {
1589
+ await flush();
1590
+ const answer = await send({ removeDone: true });
1591
+ if (!answer.ok) return { ok: false, reason: answer.reason ?? "error" };
1592
+ take(answer);
1593
+ return { ok: true, slug, removed: answer.removed ?? [] };
1594
+ },
1575
1595
  async clear() {
1576
1596
  if (focused()) document.activeElement.blur();
1577
1597
  close();
@@ -345,7 +345,7 @@ export function createColumnGaps({ active, struct, look, say, settle = () => {}
345
345
  }, true);
346
346
  document.addEventListener("pointerup", () => { if (drag) finish(true); }, true);
347
347
  endsGesture({ active: () => Boolean(drag), cancel: () => finish(false) });
348
- cancelOnEscape({ active: () => Boolean(drag), cancel: () => { finish(false); say(t("已取消调整。")); } });
348
+ cancelOnEscape({ active: () => Boolean(drag), cancel: () => { finish(false); say(t("已取消调整")); } });
349
349
  addEventListener("scroll", () => { if (hover && !drag) place(hover); }, { passive: true, capture: true });
350
350
 
351
351
  return {
@@ -151,7 +151,7 @@ export function createConflicts({ act, nodeOf, restorable, copyBlock, say }) {
151
151
  const node = nodeOf(entry);
152
152
  const parts = node?.parentElement ? [...node.parentElement.children].filter((other) => partOwner(other)?.owner === node) : [];
153
153
  const text = [node, ...parts].filter((part) => part?.isConnected).map(blocksText).filter(Boolean).join("\n") || mine;
154
- if (text) navigator.clipboard?.writeText(text).then(() => say(t("已复制。")));
154
+ if (text) navigator.clipboard?.writeText(text).then(() => say(t("已复制")));
155
155
  else say(t("没有可复制的文字。"));
156
156
  return;
157
157
  }
@@ -301,7 +301,7 @@ export function createDrag({ info, selection, struct, say, closeMenu, schedule,
301
301
  for (const details of opened) if (!(lands && details.contains(drop.container))) details.open = false;
302
302
  if (!lands) {
303
303
  selection.select(targets.filter(alive), { quiet: true });
304
- say(!commit ? t("已取消移动。") : t("位置没有变化。"));
304
+ say(!commit ? t("已取消移动") : t("位置没有变化"));
305
305
  return;
306
306
  }
307
307
  if (drop.aside) {
@@ -51,6 +51,7 @@ import { createSelectAll, topStep } from "./select-all/index.mjs";
51
51
  import { revealText } from "./find/index.mjs";
52
52
  import { createOutline } from "./outline/index.mjs";
53
53
  import { createAnnotate } from "./annotate/index.mjs";
54
+ import { watchFocus } from "./focus.mjs";
54
55
  import { createChanges as createAgentChanges } from "./changes/index.mjs";
55
56
  import { BLOCK_TYPES, HOSTS, PLAIN_TEXT, TYPE_INFO, accepts, blankSaysOf, boxOwner, texOnly, textFilters } from "../components/registry.mjs";
56
57
  import { createPages } from "../components/pages.mjs";
@@ -519,7 +520,7 @@ function committed({ conflicts: held, merged, outdated, forced = [], rekeyed = [
519
520
  }
520
521
  // What the server says besides (service.mjs retitle, reicon: "标题已更新", what did not follow it),
521
522
  // each note in the UI's language, as one sentence.
522
- else if (receipt.notes?.length) say(t("{0}。", receipt.notes.map((note) => t(note)).join(t(";"))), "note", receipt.notes.length > 1 ? 6000 : undefined);
523
+ else if (receipt.notes?.length) say(receipt.notes.length === 1 ? t(receipt.notes[0]) : t("{0}。", receipt.notes.map((note) => t(note)).join(t(";"))), "note", receipt.notes.length > 1 ? 6000 : undefined);
523
524
  }
524
525
 
525
526
  // ---------- conflicts ----------
@@ -1148,7 +1149,7 @@ async function saveNow() {
1148
1149
  const outcome = await autosave?.flush();
1149
1150
  const { held, drafts } = view();
1150
1151
  const unfitHeld = unfitCount();
1151
- if (outcome === "saved" || outcome === "nothing") say(unfitHeld === 0 && held > 0 ? t("其余修改已保存;红框处的冲突还没有保存。") : held > unfitHeld ? t("其余修改已保存;红框处的内容还没有保存。") : held > 0 ? t("其余修改已保存;红框处的内容文档存不下,没有保存。") : drafts > 0 ? t("其余修改已保存;还没写完的块(红框或空标题)没有保存。") : t("已保存。"));
1152
+ if (outcome === "saved" || outcome === "nothing") say(unfitHeld === 0 && held > 0 ? t("其余修改已保存;红框处的冲突还没有保存。") : held > unfitHeld ? t("其余修改已保存;红框处的内容还没有保存。") : held > 0 ? t("其余修改已保存;红框处的内容文档存不下,没有保存。") : drafts > 0 ? t("其余修改已保存;还没写完的块(红框或空标题)没有保存。") : t("已保存"));
1152
1153
  else if (outcome === "offline") say(t("{0}:修改暂存在浏览器里,连上后自动保存。", OFFLINE), "error");
1153
1154
  return outcome;
1154
1155
  }
@@ -1177,6 +1178,32 @@ function post(route, body) {
1177
1178
  return postAnswer(route, MAP.token, body);
1178
1179
  }
1179
1180
 
1181
+ // A write of the file the reader asked for elsewhere (历史版本's 恢复 and its 撤销: POST /__gallery/edit/history,
1182
+ // `body` its action), as 撤回 of the Agent's changes goes (changes/): what waits is saved first, and nothing may
1183
+ // wait for the reader (a text not saved, a conflict, offline: `pending`, nothing done); then the server writes it
1184
+ // and the page is shown again as after a write of its own (autosave showOwn: no question of what that write put
1185
+ // back of the reader's, who asked for it). Answers the server's answer (`undo` for a restore) as soon as it
1186
+ // comes, before the page goes (the workbench takes a page's reload for its end, and a reply after it for none).
1187
+ async function writeOwn(body) {
1188
+ if (!editing || !autosave) return { ok: false, reason: "editing" };
1189
+ checkAll();
1190
+ await annotate?.flush();
1191
+ await autosave.flush();
1192
+ const state = autosave.state;
1193
+ if (state.waiting > 0 || state.syncing || state.held > 0 || state.offline) return { ok: false, reason: "pending" };
1194
+ return new Promise((resolve) => {
1195
+ let answered = false;
1196
+ autosave.showOwn(async () => {
1197
+ const answer = await post("/__gallery/edit/history", { slug: MAP.slug, ...body });
1198
+ answered = true;
1199
+ const { id: _, ...said } = answer; // the reply's own id is the command's
1200
+ resolve(said);
1201
+ await new Promise((sent) => setTimeout(sent, 0)); // the reply posted first
1202
+ return answer.ok && !answer.same ? answer.version || true : false;
1203
+ }).then(() => { if (!answered) resolve({ ok: false, reason: "pending" }); }, () => { if (!answered) resolve({ ok: false, reason: "pending" }); });
1204
+ });
1205
+ }
1206
+
1180
1207
  let workbench = null; // the session with a workbench embedding the page (session.mjs), if any
1181
1208
  let released = false; // the workbench closes the page: what does not get saved in time stays in the crash buffer
1182
1209
 
@@ -1232,6 +1259,13 @@ const COMMANDS = {
1232
1259
  }
1233
1260
  return exportNow();
1234
1261
  },
1262
+ // 历史版本's 恢复 of a version and the 撤销 of one (the workbench's: features/history/), the reader's own writes.
1263
+ async "restore-version"({ version }) {
1264
+ return writeOwn({ action: "restore", version });
1265
+ },
1266
+ async "undo-version"({ undo }) {
1267
+ return writeOwn({ action: "undo", undo });
1268
+ },
1235
1269
  // 搜索文档 opened the report at a hit: its words scrolled into view and marked for a moment (find/).
1236
1270
  async reveal(message) {
1237
1271
  return revealText(message, (node) => {
@@ -1638,7 +1672,7 @@ function showLost() {
1638
1672
  panelItem(
1639
1673
  element("p", { text: LOST[item.why] ?? LOST.refused }),
1640
1674
  element("div", { class: "ge-version", text }),
1641
- element("div", { class: "ge-actions" }, textButton(t("复制文字"), () => navigator.clipboard?.writeText(text).then(() => say(t("已复制。"))))),
1675
+ element("div", { class: "ge-actions" }, textButton(t("复制文字"), () => navigator.clipboard?.writeText(text).then(() => say(t("已复制"))))),
1642
1676
  );
1643
1677
  }
1644
1678
  panelItem(element("div", { class: "ge-actions" }, textButton(t("都处理好了,不再保留"), () => {
@@ -1703,6 +1737,8 @@ function boot() {
1703
1737
  changed: () => workbench?.update(),
1704
1738
  removed: (items) => workbench?.annotationsRemoved(items),
1705
1739
  });
1740
+ // Where the reader is (the caret's block, the words and blocks selected), told to the workbench for gallery status.
1741
+ if (!SOLO) watchFocus({ picked: () => blocks?.picked ?? [], crossTexts: () => crossText.texts(), tell: (where) => workbench?.focus(where) });
1706
1742
  // Read at once, drawn once the page is ready; commits and autosave are made as the page edits (enter).
1707
1743
  if (!SOLO) agentChanges = createAgentChanges({
1708
1744
  slug: MAP.slug, token: MAP.token, commits: () => commits, autosave: () => autosave, say, session,
@@ -0,0 +1,104 @@
1
+ import { cutWords } from "../../shared/presence.mjs";
2
+ import { editable } from "./caret.mjs";
3
+ import { unitElement } from "./core.mjs";
4
+ import { readText, textSelection } from "./annotate/anchors.mjs";
5
+
6
+ // Where the reader is on the page, for gallery status (shared/presence.mjs): the block the caret is in, or the words and
7
+ // blocks selected, told to the workbench (session.mjs `focus`), which reports them to the server with its tab's other facts.
8
+ // Only what the reader has in front of them: the block's data-block-id (null for one the file has no id for yet: its commit is
9
+ // on its way) and its words, or the selected words, cut to 200 characters (shared/presence.mjs cutWords) before they leave the page.
10
+ //
11
+ // Given: picked() (the blocks selected whole: blocks.mjs), crossTexts() (text selected across blocks: text-select/, [{ host, range }]
12
+ // or null), tell({ caret, selection }) (the workbench's `focus`). Nothing is told while nothing changed: the selection and the caret
13
+ // are read 250ms after the last event that may have moved them (selectionchange, a press, a key, the focus), and the blocks selected
14
+ // whole, which no browser event announces, are looked at every 2s.
15
+
16
+ const AFTER = 250;
17
+ const WATCH = 2000;
18
+
19
+ /** A block's id as the file has it, or null. */
20
+ const idOf = (unit) => unit?.getAttribute?.("data-block-id") || null;
21
+ /** A block's words, cut. */
22
+ const wordsOf = (unit) => cutWords(readText([unit]).text).text;
23
+
24
+ /** The blocks the reader has selected whole: { kind: "blocks", blocks: [ids], text: the first one's words }, or null. */
25
+ function blockSelection(units) {
26
+ const list = units.filter((unit) => unit?.isConnected);
27
+ if (list.length === 0) return null;
28
+ return { kind: "blocks", blocks: list.map(idOf).filter(Boolean), text: wordsOf(list[0]) };
29
+ }
30
+
31
+ /** Words selected as one range: { kind: "text", blocks: [ids], text, length }, or null. */
32
+ function wordsSelection(range) {
33
+ const found = textSelection(range);
34
+ if (found) {
35
+ const words = cutWords(found.excerpt.quote);
36
+ return { kind: "text", blocks: found.units.map(idOf).filter(Boolean), text: words.text, length: found.excerpt.quote.length };
37
+ }
38
+ // Words the annotations leave out (the title, a code block's): the block holding them, the words as the browser gives them.
39
+ const unit = unitElement(range.startContainer.nodeType === 1 ? range.startContainer : range.startContainer.parentElement);
40
+ const text = range.toString();
41
+ const words = cutWords(text);
42
+ return words.text ? { kind: "text", blocks: [idOf(unit)].filter(Boolean), text: words.text, length: text.length } : null;
43
+ }
44
+
45
+ /** What the reader has selected now (blocks, text across blocks, text in one block), or null. */
46
+ function selectionNow({ picked, crossTexts }) {
47
+ const blocks = picked();
48
+ if (blocks.length > 0) return blockSelection(blocks);
49
+ const texts = crossTexts();
50
+ if (texts?.length) {
51
+ const range = document.createRange();
52
+ range.setStart(texts[0].range.startContainer, texts[0].range.startOffset);
53
+ range.setEnd(texts.at(-1).range.endContainer, texts.at(-1).range.endOffset);
54
+ return wordsSelection(range);
55
+ }
56
+ const selection = getSelection();
57
+ if (!selection?.rangeCount || selection.isCollapsed) return null;
58
+ const range = selection.getRangeAt(0);
59
+ if (!editable(range.commonAncestorContainer) && !editable(range.startContainer)) return null;
60
+ return wordsSelection(range.cloneRange());
61
+ }
62
+
63
+ /** The block the caret is in: { block, text }, or null (no caret in the page's text). */
64
+ function caretNow() {
65
+ const selection = getSelection();
66
+ const host = selection?.rangeCount ? editable(selection.anchorNode) : null;
67
+ const unit = host ? unitElement(host) : null;
68
+ return unit ? { block: idOf(unit), text: wordsOf(unit) } : null;
69
+ }
70
+
71
+ export function watchFocus({ picked, crossTexts, tell }) {
72
+ let last = null;
73
+ let timer = 0;
74
+ const read = () => {
75
+ timer = 0;
76
+ const selection = selectionNow({ picked, crossTexts });
77
+ const caret = selection ? null : caretNow();
78
+ const key = JSON.stringify({ caret, selection });
79
+ if (key === last) return;
80
+ last = key;
81
+ tell({ caret, selection });
82
+ };
83
+ const soon = () => {
84
+ if (!timer) timer = setTimeout(read, AFTER);
85
+ };
86
+ for (const type of ["selectionchange", "pointerup", "keyup", "focusin", "focusout"]) document.addEventListener(type, soon, { capture: true, passive: true });
87
+ // The blocks selected whole: no event says they changed, so their list is compared now and then.
88
+ let held = "";
89
+ const watching = setInterval(() => {
90
+ const now = picked().map(idOf).join(",");
91
+ if (now !== held) {
92
+ held = now;
93
+ soon();
94
+ }
95
+ }, WATCH);
96
+ return {
97
+ /** Tell now (the page is ready, or the workbench asks again). */
98
+ now: () => { last = null; clearTimeout(timer); read(); },
99
+ stop() {
100
+ clearInterval(watching);
101
+ clearTimeout(timer);
102
+ },
103
+ };
104
+ }