@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
@@ -132,6 +132,7 @@ export function createHandle({ info, active, selection, drag, menu, openMenu, dr
132
132
  let barShown = null; // the section the bar stands beside
133
133
  let shown = null; // the block the handle stands beside
134
134
  let noted = null; // the block the 批注 button stands beside
135
+ let noteWait = null; // { target, ready, timer }: a new block must stay eligible for 400 ms
135
136
  let pointer = null; // { x, y, target }: where the pointer last moved over the page, and what it was on
136
137
  let typing = false; // the handle hides while the reader types, until the pointer moves
137
138
  let still = null; // the page scrolled with the pointer here: the handle hides until it moves away
@@ -281,8 +282,19 @@ export function createHandle({ info, active, selection, drag, menu, openMenu, dr
281
282
 
282
283
  // The 批注 button beside `target` (shown by its `shape`) at `spot`, or none.
283
284
  function placeNote(target, shape, spot) {
285
+ if (noteWait?.target !== target) {
286
+ clearTimeout(noteWait?.timer);
287
+ noteWait = null;
288
+ noted = null;
289
+ note.remove();
290
+ if (target) {
291
+ const waiting = { target, ready: false, timer: null };
292
+ waiting.timer = setTimeout(() => { waiting.ready = true; schedule(); }, 400);
293
+ noteWait = waiting;
294
+ }
295
+ }
296
+ if (!target || !noteWait?.ready) return;
284
297
  noted = target;
285
- if (!target) return void note.remove();
286
298
  own(note, "note button", { owner: target, alive: () => noted === target && target.isConnected });
287
299
  drawAt(note, shape.layer, spot.left - shape.origin.left, spot.top - shape.origin.top);
288
300
  }
@@ -613,7 +625,7 @@ export function createHandle({ info, active, selection, drag, menu, openMenu, dr
613
625
  handle.remove();
614
626
  }
615
627
  if (!handle.isConnected || !dimmed?.isConnected) undim();
616
- if (noted && !noted.isConnected) placeNote(null);
628
+ if (noteWait && !noteWait.target.isConnected) placeNote(null);
617
629
  if (barShown && !barShown.isConnected) {
618
630
  barShown = null;
619
631
  bar.remove();
@@ -595,7 +595,7 @@ function keep(href, restore, page) {
595
595
  const before = ownHTML(host);
596
596
  link.replaceWith(mentionOf(link).title);
597
597
  use.recordStep(host, before);
598
- use.say(t("链接已移除。"));
598
+ use.say(t("链接已移除"));
599
599
  return;
600
600
  }
601
601
  const before = ownHTML(host);
@@ -616,7 +616,7 @@ function keep(href, restore, page) {
616
616
  }
617
617
  } else return;
618
618
  use.recordStep(host, before);
619
- use.say(href ? t("链接:{0}", wordsFor(href, page)) : entry ? t("入口已移除:页面上没有地方能打开这张详情卡片了 · Ctrl+Z 撤销") : t("链接已移除。"));
619
+ use.say(href ? t("链接:{0}", wordsFor(href, page)) : entry ? t("入口已移除:页面上没有地方能打开这张详情卡片了 · Ctrl+Z 撤销") : t("链接已移除"));
620
620
  }
621
621
 
622
622
  // The gray 链接 given its address: a link of it in its place, its words the address or a page's title
@@ -27,13 +27,13 @@ const ROUTINE = () => [
27
27
  t("{0} · Ctrl+Z 撤销"), // whatever was just done
28
28
  t("已新建分区 · 标题、分割线都可以按需加"),
29
29
  t("已复制 {0} · 在要放的块里按 Ctrl+V 粘贴到它后面"), t("已复制 {0} 格"),
30
- t("已保存。"), t("{0}。", t("标题已更新")),
31
- t("已取消调整。"), t("已取消移动。"), t("位置没有变化。"),
30
+ t("已保存"), t("标题已更新"),
31
+ t("已取消调整"), t("已取消移动"), t("位置没有变化"),
32
32
  t("已选中 {0} 块 · Delete 删除 · 拖动手柄一起移动 · Esc 取消"), t("已选中{0} · Delete 删除{1} · Esc 取消"),
33
33
  t("已选中{0} · Delete 删除 · Esc 取消{1}"), t("已选中这一块的文字 · 再按 {0} 选中{1}"), t("已选中这一块的文字"), // Ctrl+A (select-all/)
34
34
  t("正在移动{0} · 拖到要放的位置后松开 · 停在选项卡上切换,停在折叠块标题上展开 · Esc 取消"),
35
35
  t("接着打的字:{0} · 再按一次取消"), t("接着打的字照旧"),
36
- t("链接:{0}"), t("链接已移除。"),
36
+ t("链接:{0}"), t("链接已移除"),
37
37
  t("这一项上面没有同级的项,不能再缩进。"), t("这一项已经在最外层。"), t("选中的项上面没有同级的项,不能再缩进。"), t("选中的项已经在最外层。"),
38
38
  t("{0}已经在{1}。"), t("这里只有{0};可以拖动手柄移到别处。"),
39
39
  ];
@@ -319,7 +319,7 @@ export function createResize({ units, active, look, say, settle = () => {}, owns
319
319
  document.addEventListener("pointerup", () => { if (drag) finish(true); }, true);
320
320
  // After pointerup the drag is over already; any other way it ends, it is cancelled (gesture.mjs).
321
321
  endsGesture({ active: () => Boolean(drag), cancel: () => finish(false) });
322
- cancelOnEscape({ active: () => Boolean(drag), cancel: () => { finish(false); say(t("已取消调整。")); } });
322
+ cancelOnEscape({ active: () => Boolean(drag), cancel: () => { finish(false); say(t("已取消调整")); } });
323
323
 
324
324
  return {
325
325
  /** A table's width ("x", from its right edge) or scroll height ("y", from its bottom edge) dragged, the press `event` on it (the table tools' armed edge). */
@@ -27,7 +27,8 @@ export const SOLO = new URLSearchParams(location.search).has("solo");
27
27
  * workbench shows it: a toast, `room` px above the frame's bottom, what the page's bottom panels take;
28
28
  * text "" takes it away), noticeRoom(room) (that room changed: the workbench's toasts stand above it),
29
29
  * reload() (the workbench shows the report
30
- * again in a new page, swapped in once drawn), annotationsRemoved(items) (批注 deleted in the page:
30
+ * again in a new page, swapped in once drawn), focus({ caret, selection }) (where the reader is: the block the caret is in, the
31
+ * words or blocks selected; the workbench reports it to the server, gallery status), annotationsRemoved(items) (批注 deleted in the page:
31
32
  * the workbench says so and offers them back), open({ target, block } or { hash }) (the workbench
32
33
  * goes to another document), jump({ block, place }) (a link goes to a block of this page: the
33
34
  * workbench adds a history entry, `place` where the reader was, for the way back: `show-place`),
@@ -131,6 +132,8 @@ export function connectWorkbench({ slug, snapshot, commands, modals }) {
131
132
  // this page's place once drawn; this page does nothing more meanwhile.
132
133
  reload: () => send("reload", {}),
133
134
  annotationsRemoved: (items) => send("annotations-removed", { items }),
135
+ // Where the reader is, for gallery status (focus.mjs): { caret, selection }, told when it changed.
136
+ focus: (where) => send("focus", where),
134
137
  open: (place) => send("open", place),
135
138
  jump: (fields) => send("jump", fields),
136
139
  workspace: () => send("workspace", {}),
@@ -44,7 +44,7 @@ export function loadScript(src, ready, { before } = {}) {
44
44
 
45
45
  /** An address on the clipboard, and said so: called right in the click that asked, as a browser takes no write after a wait. */
46
46
  export function copyAddress(address, say) {
47
- navigator.clipboard?.writeText(address).then(() => say(t("已复制链接。")), () => say(t("没能复制:浏览器不允许写剪贴板。"), "error"));
47
+ navigator.clipboard?.writeText(address).then(() => say(t("已复制链接")), () => say(t("没能复制:浏览器不允许写剪贴板。"), "error"));
48
48
  }
49
49
 
50
50
  export function button(label, action, { primary = false, ...props } = {}) {
@@ -5,7 +5,7 @@ import { BLOCK_ID, randomId } from "./ids.mjs";
5
5
  // local Agent, each about a piece of a report: words of its text, or blocks taken whole. One has
6
6
  // words: an item with none is none (cleanItem). Kept in reports/<slug>/annotations.json, never in report.html:
7
7
  //
8
- // { v: 1, items: [{ id, created, updated, text, anchor }] }
8
+ // { v: 1, items: [{ id, created, updated, text, anchor, done? }] } (done: true once an Agent has acted on it: gallery notes done)
9
9
  // anchor: { kind: "text", blocks: [data-block-id…], quote, prefix, suffix }
10
10
  // | { kind: "blocks", blocks: [data-block-id…], quote }
11
11
  //
@@ -396,9 +396,28 @@ export function cleanItem(item, now = new Date().toISOString()) {
396
396
  if (!anchor) return null;
397
397
  const text = item.text.replace(/\r\n?/g, "\n").slice(0, TEXT_MOST);
398
398
  if (!text.trim()) return null;
399
- return { id: item.id, created: typeof item.created === "string" ? item.created : now, updated: now, text, anchor };
399
+ return { id: item.id, created: typeof item.created === "string" ? item.created : now, updated: now, text, anchor, ...(item.done === true ? { done: true } : {}) };
400
400
  }
401
401
 
402
+ /** `items` with those whose id `ids` holds marked done (`done` true) or not done (false), the others as they were. */
403
+ export function markItems(items, ids, done, now = new Date().toISOString()) {
404
+ const named = new Set(ids);
405
+ return items.map((item) => (named.has(item.id) && Boolean(item.done) !== done ? cleanItem({ ...item, done }, now) : item));
406
+ }
407
+
408
+ /**
409
+ * `items` without those a request takes out: `ids` (those ids), `done` (every one marked done), `all`: { items (the rest),
410
+ * removed (those taken out, as they were, for 撤销) }.
411
+ */
412
+ export function withoutItems(items, { ids = [], done = false, all = false } = {}) {
413
+ const named = new Set(ids);
414
+ const takes = (item) => all || named.has(item.id) || (done && item.done === true);
415
+ return { items: items.filter((item) => !takes(item)), removed: items.filter(takes) };
416
+ }
417
+
418
+ /** The ids of `ids` that no item of `items` has. */
419
+ export const unknownIds = (items, ids) => ids.filter((id) => !items.some((item) => item.id === id));
420
+
402
421
  /** The annotations of a file's text (annotations.json), those it holds that are not annotations left out. */
403
422
  export function readItems(text) {
404
423
  let data = null;
@@ -555,7 +574,7 @@ export function cardTops(items, front = null) {
555
574
  * the section bar's line at +12 to +16); the card's width by its words or blocks, in the column, where
556
575
  * the margin has no room for cards (those are margin.mjs's: MARGIN.card).
557
576
  */
558
- export const RAIL = Object.freeze({ marker: 24, markerAt: 14, bar: 7, card: 280 });
577
+ export const RAIL = Object.freeze({ marker: 24, markerAt: 10, bar: 7, card: 280 });
559
578
 
560
579
  /**
561
580
  * Where a marker stands across a window `width` wide, the column's right edge being `right`: `markerAt`
@@ -4,7 +4,7 @@ import { componentSettings, problemIn, writeComponentSettings } from "./componen
4
4
  import { refusal, said } from "./i18n/index.mjs";
5
5
  import { DEFAULT_PACK, OWN_SCOPE, PACK_FILE, PACK_ID, readPackManifest } from "./pack-manifest.mjs";
6
6
  import { packsDirectory } from "./paths.mjs";
7
- import { filesIn, fingerprintOf, fingerprintOfFiles, isSystemFile, recordOf, shippedBlocks, shippedPackIds, versionAfter, writeRecord, writingOrder } from "./shipped-blocks.mjs";
7
+ import { filesIn, fingerprintOf, fingerprintOfFiles, followedAlready, isSystemFile, recordOf, shippedBlocks, shippedPackIds, versionAfter, writeRecord, writingOrder } from "./shipped-blocks.mjs";
8
8
  import { DEVICE_NAMES, PACKAGE_MANIFEST, layoutOf, markerOf, packageFolders } from "./workspace.mjs";
9
9
 
10
10
  // Block packs (自定义块包: notes/plans/block-packs-plan-2026-11-09.md; docs/render.md "Block packs"). A pack
@@ -312,6 +312,7 @@ export function workspacePacks(root, { settings = null, shippedIds = shippedPack
312
312
  for (const name of fs.readdirSync(components)) if (PACK_ID.test(name) && fs.existsSync(path.join(components, name, PACK_FILE))) scopes.add(name);
313
313
  } catch {}
314
314
  const switches = (settings ?? componentSettings(root)).packs ?? {};
315
+ let ownBlocks;
315
316
  const listed = [...scopes].map((id) => {
316
317
  const folder = path.join(components, id);
317
318
  let manifest = null;
@@ -348,8 +349,12 @@ export function workspacePacks(root, { settings = null, shippedIds = shippedPack
348
349
  return null;
349
350
  }
350
351
  };
351
- const ownBlocks = official ? shippedBlocks(directory) : [];
352
- const behind = official && shipped && changed.some((block) => (block.endsWith(`/${PACK_FILE}`) ? versionAfter(shipped.version, manifest?.version ?? null) : (ownBlocks.some((one) => one.id === block) && versionAfter(ownBlocks.find((one) => one.id === block).version, versionAt(block)))));
352
+ const behind = official && shipped && changed.some((block) => {
353
+ if (block.endsWith(`/${PACK_FILE}`)) return versionAfter(shipped.version, manifest?.version ?? null);
354
+ ownBlocks ??= new Map(shippedBlocks(directory).map((one) => [one.id, one]));
355
+ const own = ownBlocks.get(block);
356
+ return own && versionAfter(own.version, versionAt(block));
357
+ });
353
358
  const newer = behind ? shipped.version : null;
354
359
  const told = id === OWN_SCOPE ? DEFAULT_PACK : manifest ?? (problem ? null : shipped);
355
360
  return {
@@ -392,7 +397,8 @@ function installedLines(root, made, first) {
392
397
  * cannot be written leaves that pack for the next start), one installed is brought up to Gallery's.
393
398
  * `env`: whose NO_PACKS is read; `log`: told what was done, a line each. Never rejects: { did: "off"
394
399
  * (NO_PACKS), "unread" (gallery.json does not read as Gallery's) or "done", packs: [installPack's answer
395
- * with `first`, or { id, failed }] }.
400
+ * with `first`, or { id, failed }] }. The blocks it leaves as Gallery ships them (added, updated or the
401
+ * same) are followShipped's looked at (followedAlready): nothing for it to do of them in this process.
396
402
  */
397
403
  export async function installOfficialPacks(root, { directory = packsDirectory, env = process.env, log = () => {} } = {}) {
398
404
  if (packsSkipped(env)) return { did: "off", packs: [] };
@@ -402,6 +408,7 @@ export async function installOfficialPacks(root, { directory = packsDirectory, e
402
408
  } catch {}
403
409
  if (!marker.ours) return { did: "unread", packs: [] };
404
410
  const ids = shippedPackIds(directory);
411
+ const shipped = shippedBlocks(directory);
405
412
  const packs = [];
406
413
  for (const id of ids) {
407
414
  try {
@@ -409,6 +416,8 @@ export async function installOfficialPacks(root, { directory = packsDirectory, e
409
416
  if (first && OFF_AT_FIRST.includes(id) && componentSettings(root).packs[id] === undefined) await writeComponentSettings(root, { pack: id, enabled: false });
410
417
  const made = installPack(root, folderFiles(path.join(directory, id)), { official: true, shippedIds: ids });
411
418
  packs.push({ ...made, first });
419
+ const current = new Set([...made.added, ...made.updated, ...made.same]);
420
+ followedAlready(root, shipped.filter((block) => current.has(block.id)));
412
421
  for (const line of installedLines(root, made, first)) log(line);
413
422
  } catch (error) {
414
423
  log(`Gallery's pack ${id} could not be installed in ${path.resolve(root)}: ${error?.message ?? error}`);
@@ -123,6 +123,20 @@ export function ownLookFor(look, workspace) {
123
123
  return Object.fromEntries(Object.keys(LOOK).filter((key) => shown[key] !== base[key]).map((key) => [key, shown[key]]));
124
124
  }
125
125
 
126
+ /** A legacy full-look request: retain explicit choices for fields whose displayed value did not change. */
127
+ export function updatedOwnLook(own, workspace, look) {
128
+ const before = fullLook(effectiveLook(own, workspace));
129
+ const after = fullLook(look);
130
+ const changed = ownLookFor(look, workspace);
131
+ const kept = ownLookOf(own);
132
+ for (const key of Object.keys(LOOK)) {
133
+ if (before[key] === after[key]) continue;
134
+ if (Object.hasOwn(changed, key)) kept[key] = changed[key];
135
+ else delete kept[key];
136
+ }
137
+ return kept;
138
+ }
139
+
126
140
  /**
127
141
  * A document's doc.json (its parsed object) with its own look as given (ownLookOf: an explicit default
128
142
  * kept): in the field's place when it had one, else last; the field gone when it makes no choice.
@@ -53,7 +53,7 @@ export const clashingIds = (ids = registeredIds()) => [...ids].filter(([, roots]
53
53
  /** Whether a draft's doc.json says it is a document (not the 写作偏好 page of an old layout), and what it says. */
54
54
  async function metadataAt(folder) {
55
55
  const files = documentFiles(folder);
56
- const [html, metadata] = await Promise.all([exists(files.html), fs.readFile(files.json, "utf8").then((text) => JSON.parse(text.replace(/^/, "")), () => null)]);
56
+ const [html, metadata] = await Promise.all([exists(files.html), fs.readFile(files.json, "utf8").then((text) => JSON.parse(text.replace(/^/, ""))).catch(() => null)]);
57
57
  return { files, html, metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : null };
58
58
  }
59
59
 
@@ -6,6 +6,12 @@ import { isExtensionId } from "./outline-shape.mjs";
6
6
  import { sha256, writeAtomic } from "./files.mjs";
7
7
  import { withOutlineLock } from "./outline.mjs";
8
8
 
9
+ // The recursive workspace watcher covers imported modules as well as manifest entries.
10
+ // Requests never rescan the whole extension tree just to serve a single asset.
11
+ const generations = new Map();
12
+ let generation = 0;
13
+ export function invalidateExtension(folder) { generations.set(path.resolve(folder), ++generation); }
14
+
9
15
  export const extensionDirectory = id => {
10
16
  if (!isExtensionId(id)) throw new Error("扩展 ID 不正确");
11
17
  return path.join(layoutOf(defaultWorkspace()).extensions, id);
@@ -42,42 +48,64 @@ export async function readManifest(folder) {
42
48
  const value = JSON.parse(text);
43
49
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("extension.json 必须是对象");
44
50
  if (value.id !== undefined && !isExtensionId(value.id)) throw new Error("扩展 ID 不正确");
51
+ for (const key of ["name", "version", "description"]) if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length > 1000)) throw new Error("扩展名称、版本和描述必须是短文本");
52
+ if (value.icon !== undefined) {
53
+ if (typeof value.icon !== "string" || !value.icon || value.icon.length > 256) throw new Error("icon 不正确");
54
+ if (/\.(?:png|jpe?g|webp|gif|svg)$/i.test(value.icon)) await extensionFile(folder, value.icon);
55
+ else if (value.icon.length > 16 || /[/\\]/.test(value.icon)) throw new Error("icon 必须是表情或扩展里的图片");
56
+ }
45
57
  if (value.workbench !== undefined && (!value.workbench || typeof value.workbench !== "object" || Array.isArray(value.workbench))) throw new Error("workbench 必须是对象");
46
58
  for (const name of [value.workbench?.script, value.workbench?.style, value.agent].filter(value => value !== undefined)) await extensionFile(folder, name);
47
59
  if (value.server !== undefined) {
48
60
  const command = value.server?.command;
49
61
  if (!Array.isArray(command) || !command.length || command.length > 32 || command.some(word => typeof word !== "string" || !word || word.includes("\0"))) throw new Error("server.command 必须是非空的参数数组");
62
+ if (value.server.timeout !== undefined && (!Number.isInteger(value.server.timeout) || value.server.timeout < 1000 || value.server.timeout > 600000)) throw new Error("server.timeout 必须是 1000 到 600000 毫秒");
50
63
  }
51
- return { ...value, revision: sha256(text) };
64
+ // Files named by the manifest can change while extension.json stays byte-for-byte identical.
65
+ // Their metadata gives clients and the backend manager a fresh revision after a replacement.
66
+ const named = [value.workbench?.script, value.workbench?.style, ...(value.server?.command?.slice(1) ?? [])].filter(name => typeof name === "string" && !path.isAbsolute(name));
67
+ const stamps = await Promise.all(named.map(async name => {
68
+ try { const file = await extensionFile(folder, name); const stat = await fs.stat(file); return [name, stat.size, stat.mtimeMs]; } catch { return [name, null]; }
69
+ }));
70
+ return { ...value, revision: sha256(JSON.stringify([text, stamps, generations.get(path.resolve(folder)) ?? 0])) };
52
71
  }
53
72
 
54
73
  const configFile = () => path.join(defaultWorkspace(), "gallery.json");
55
74
  const configuration = async () => JSON.parse(await fs.readFile(configFile(), "utf8"));
56
75
 
57
- export async function listCapabilities() {
76
+ export async function listCapabilities({ ids = null } = {}) {
58
77
  const config = await configuration();
59
- return Promise.all((await extensionFolders(defaultWorkspace())).map(async ({ id, folder }) => {
78
+ const wantedIds = ids === null ? null : new Set(ids);
79
+ const folders = await extensionFolders(defaultWorkspace());
80
+ return Promise.all(folders.filter(({ id }) => !wantedIds || wantedIds.has(id)).map(async ({ id, folder }) => {
81
+ const wanted = config.extensions?.[id]?.enabled === true;
82
+ const configured = Object.hasOwn(config.extensions ?? {}, id);
60
83
  try {
61
84
  const manifest = await readManifest(folder);
62
- const grant = config.extensions?.[id];
63
- return { id, folder, manifest, enabled: grant?.enabled === true && grant.revision === manifest.revision,
64
- confirmed: grant?.revision === manifest.revision, agent: manifest.agent ? path.join(folder, manifest.agent) : null };
85
+ return { id, folder, manifest, enabled: wanted, wanted, agent: manifest.agent ? path.join(folder, manifest.agent) : null };
65
86
  } catch (error) {
66
- return { id, folder, enabled: false, confirmed: false, manifest: null, error: error.code === "ENOENT" ? null : error.message };
87
+ let message = error.message;
88
+ if (error.code === "ENOENT") {
89
+ const hasManifest = await fs.lstat(path.join(folder, "extension.json")).then(() => true, () => false);
90
+ const hasSidebar = await fs.lstat(path.join(folder, "sidebar.json")).then(() => true, () => false);
91
+ message = hasManifest ? "扩展引用的文件不存在" : (!configured && hasSidebar ? null : "找不到 extension.json");
92
+ }
93
+ return { id, folder, enabled: false, wanted, manifest: null, error: message };
67
94
  }
68
95
  }));
69
96
  }
70
97
 
71
- export async function setExtensionEnabled({ id, enabled, revision, confirmed = false }) {
98
+ export async function setExtensionEnabled({ id, enabled }) {
72
99
  const folder = extensionDirectory(id);
73
100
  if (typeof enabled !== "boolean") throw new Error("扩展开关的值不正确");
74
101
  return withOutlineLock(defaultWorkspace(), async () => {
75
- const manifest = await readManifest(folder);
76
- if (manifest.revision !== revision) throw new Error("扩展刚刚更新,请重新打开设置");
102
+ if (enabled) await readManifest(folder);
103
+ else if (!(await fs.lstat(folder).catch(() => null))) throw new Error("找不到扩展");
77
104
  const config = await configuration();
78
- if (enabled && config.extensions?.[id]?.revision !== revision && !confirmed) throw new Error("请先确认扩展的脚本和后台权限");
79
- config.extensions = { ...config.extensions, [id]: { enabled, revision } };
105
+ if ((config.extensions?.[id]?.enabled === true) === enabled) return { changed: false };
106
+ config.extensions = { ...config.extensions, [id]: { enabled } };
80
107
  await writeAtomic(configFile(), JSON.stringify(config, null, 2) + "\n");
108
+ return { changed: true };
81
109
  });
82
110
  }
83
111
 
@@ -0,0 +1,17 @@
1
+ // What the workbench shows as 收藏 when it reads the list again (pure, so that the page imports it; the file side is ./favorites.mjs).
2
+ // The browser keeps its own list (localStorage) and the user's data folder keeps one that gallery favorite and every browser share: `local`
3
+ // is the browser's, `seen` what the shared list held when this browser last read it, `server` what it holds now. A three-way merge: an
4
+ // entry the shared list dropped since (in `seen`, not in `server`) leaves the browser's; one it gained since (in `server`, not in `seen`)
5
+ // joins it; one the browser took out since (in `seen` and `server`, not in `local`) stays out and is for the shared list to drop (`drop`:
6
+ // a read that lands while the browser's own removal is on its way never brings the star back); one only the browser has is for the
7
+ // shared list to take (`push`). The browser's order stays, the shared list's new entries last.
8
+ export function mergeFavorites({ local, seen, server }) {
9
+ const gone = new Set(seen.filter((key) => !server.includes(key)));
10
+ const own = local.filter((key) => !gone.has(key));
11
+ const gained = server.filter((key) => !seen.includes(key) && !own.includes(key));
12
+ return {
13
+ favorites: [...own, ...gained],
14
+ push: own.filter((key) => !server.includes(key)),
15
+ drop: server.filter((key) => seen.includes(key) && !local.includes(key)),
16
+ };
17
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeAtomic } from "./files.mjs";
4
+ import { entryPattern, isForeign } from "./outline-shape.mjs";
5
+ import { outlineRoot, withOutlineLock } from "./outline.mjs";
6
+ import { userDirectory } from "./user-directory.mjs";
7
+ import { workspaceId, workspaceKey } from "./workspace-registry.mjs";
8
+
9
+ // The sidebar's 收藏 (favorites): entries (a slug of the workspace, a web link's, or "~<ws id>/<slug>" for a draft of
10
+ // another workspace) the reader starred. The workbench keeps them in its browser (localStorage, which no command can
11
+ // reach) and, since gallery favorite, in favorites.json of the user's data folder too (beside the registry of
12
+ // workspaces: ./user-directory.mjs), as the one place a gallery command and the browsers of this machine share; never
13
+ // in a workspace's files: a star is the reader's, not the project's (a workspace synced or shared carries none). The
14
+ // list is written under the default workspace's outline lock; its keys are the ones the default
15
+ // workspace's sidebar names the entries by, so that a workspace's own entries are plain slugs there and another
16
+ // workspace's are "~<ws id>/<slug>" (the projection ./outline.mjs does for the tree): `favoriteKey` and `favoriteSlug`
17
+ // turn one into the other for a command working in a workspace other than the default.
18
+
19
+ const readJson = (file) => fs.readFile(file, "utf8").then(JSON.parse).catch(() => null);
20
+
21
+ /** The file the shared list is kept in: favorites.json of the user's data folder (GALLERY_DATA_DIR in the tests). */
22
+ export const favoritesFile = () => path.join(userDirectory("data"), "favorites.json");
23
+
24
+ /** The key entry `slug` of workspace `root` has in the stored list: plain in the default workspace, "~<id>/<slug>" elsewhere. */
25
+ export function favoriteKey(root, slug) {
26
+ const home = outlineRoot(root);
27
+ if (workspaceKey(root) === workspaceKey(home)) return slug;
28
+ const homes = `~${workspaceId(home)}/`;
29
+ if (slug.startsWith(homes)) return slug.slice(homes.length); // the default workspace's own, as the file names them
30
+ return isForeign(slug) ? slug : `~${workspaceId(root)}/${slug}`;
31
+ }
32
+
33
+ /** A stored key as workspace `root`'s sidebar names the entry (its own entries lose the prefix, the default workspace's gain one: the projection ./outline.mjs does for the tree). */
34
+ export function favoriteSlug(root, key) {
35
+ const home = outlineRoot(root);
36
+ if (workspaceKey(root) === workspaceKey(home)) return key;
37
+ const prefix = `~${workspaceId(root)}/`;
38
+ if (key.startsWith(prefix)) return key.slice(prefix.length);
39
+ return isForeign(key) ? key : `~${workspaceId(home)}/${key}`; // the default workspace's own entries are another workspace's drafts here
40
+ }
41
+
42
+ /** The favorites as a file holds them: entries once each, only valid ones. */
43
+ const kept = (value) => [...new Set((Array.isArray(value) ? value : []).filter((entry) => typeof entry === "string" && entryPattern.test(entry)))];
44
+
45
+ /**
46
+ * The file's lists are kept by the default workspace whose sidebar they belong to (`workspaces`: its id -> its list), so that
47
+ * another default workspace (GALLERY_DEFAULT_WORKSPACE, a test's) never sees a list that names other entries.
48
+ */
49
+ const listIn = (document, root) => kept(document?.workspaces?.[workspaceId(outlineRoot(root))]);
50
+
51
+ /** The stored favorites seen from workspace `root` (its own entries as plain slugs). Read alone. */
52
+ export async function readFavorites(root) {
53
+ return listIn(await readJson(favoritesFile()), root).map((key) => favoriteSlug(root, key));
54
+ }
55
+
56
+ /**
57
+ * Favorites changed in the stored list under the outline's lock: `add` and `remove` are entries as workspace `root`'s
58
+ * sidebar names them. Resolves to { favorites (seen from `root`), changed }. The other default workspaces' lists stay as they are.
59
+ */
60
+ export function changeFavorites(root, { add = [], remove = [] }) {
61
+ return withOutlineLock(root, async () => {
62
+ const file = favoritesFile();
63
+ const value = await readJson(file);
64
+ const document = value && typeof value === "object" && !Array.isArray(value) ? value : {};
65
+ const id = workspaceId(outlineRoot(root));
66
+ const before = listIn(document, root);
67
+ const dropped = new Set(kept(remove).map((slug) => favoriteKey(root, slug)));
68
+ const after = [...before.filter((key) => !dropped.has(key)), ...kept(add).map((slug) => favoriteKey(root, slug)).filter((key) => !before.includes(key))];
69
+ const changed = JSON.stringify(before) !== JSON.stringify(after);
70
+ if (changed) {
71
+ await fs.mkdir(path.dirname(file), { recursive: true });
72
+ const workspaces = document.workspaces && typeof document.workspaces === "object" && !Array.isArray(document.workspaces) ? document.workspaces : {};
73
+ await writeAtomic(file, `${JSON.stringify({ version: 1, workspaces: { ...workspaces, [id]: after } }, null, 2)}\n`);
74
+ }
75
+ return { favorites: after.map((key) => favoriteSlug(root, key)), changed };
76
+ });
77
+ }