@svgrid/enterprise 3.0.0 → 3.0.2

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 (390) hide show
  1. package/README.md +95 -95
  2. package/dist/ExpressionEditorHarness.svelte.d.ts +2 -2
  3. package/dist/SvAdvancedFilter.svelte.d.ts +2 -2
  4. package/dist/SvAlertRuleEditor.svelte.d.ts +2 -2
  5. package/dist/SvAlertsManager.svelte.d.ts +3 -3
  6. package/dist/SvAlertsPanel.svelte.d.ts +1 -1
  7. package/dist/SvAuthGate.svelte +115 -115
  8. package/dist/SvAuthGate.svelte.d.ts +1 -1
  9. package/dist/SvBoard.svelte.d.ts +1 -1
  10. package/dist/SvExportMenu.svelte +553 -553
  11. package/dist/SvExportMenu.svelte.d.ts +2 -2
  12. package/dist/SvExpressionEditor.svelte.d.ts +2 -2
  13. package/dist/SvFileInput.svelte +113 -113
  14. package/dist/SvFormulaBar.svelte +606 -0
  15. package/dist/SvFormulaBar.svelte.d.ts +62 -0
  16. package/dist/SvGridAlerts.svelte.d.ts +2 -2
  17. package/dist/SvGridBoard.svelte +2358 -2358
  18. package/dist/SvGridBulkEditDrawer.svelte.d.ts +1 -1
  19. package/dist/SvGridEditPanel.svelte +1157 -1157
  20. package/dist/SvGridEditPanel.svelte.d.ts +3 -3
  21. package/dist/SvGridMasterDetail.svelte +116 -116
  22. package/dist/SvGridMasterDetail.svelte.d.ts +1 -1
  23. package/dist/SvGridScheduler.svelte +5358 -5358
  24. package/dist/SvImportDialog.svelte +1020 -1020
  25. package/dist/SvImportDialog.svelte.d.ts +2 -2
  26. package/dist/SvLookupInput.svelte +180 -180
  27. package/dist/SvLookupInput.svelte.d.ts +1 -1
  28. package/dist/SvPivotDesigner.svelte +2041 -1990
  29. package/dist/SvPivotDesigner.svelte.d.ts +2 -2
  30. package/dist/SvRecordDetail.svelte.d.ts +1 -1
  31. package/dist/SvRibbonIcon.svelte +46 -0
  32. package/dist/SvRibbonIcon.svelte.d.ts +17 -0
  33. package/dist/SvSchedule.svelte.d.ts +1 -1
  34. package/dist/SvSchemaChart.svelte +236 -234
  35. package/dist/SvSchemaChart.svelte.d.ts +2 -2
  36. package/dist/SvSchemaDashboard.svelte +130 -130
  37. package/dist/SvSchemaDashboard.svelte.d.ts +3 -3
  38. package/dist/SvSheet.svelte +4114 -0
  39. package/dist/SvSheet.svelte.d.ts +142 -0
  40. package/dist/SvSheetComment.svelte +98 -0
  41. package/dist/SvSheetComment.svelte.d.ts +22 -0
  42. package/dist/SvSheetConditionalFormat.svelte +162 -0
  43. package/dist/SvSheetConditionalFormat.svelte.d.ts +14 -0
  44. package/dist/SvSheetDataValidation.svelte +249 -0
  45. package/dist/SvSheetDataValidation.svelte.d.ts +14 -0
  46. package/dist/SvSheetFilterMenu.svelte +217 -0
  47. package/dist/SvSheetFilterMenu.svelte.d.ts +17 -0
  48. package/dist/SvSheetFindReplace.svelte +132 -0
  49. package/dist/SvSheetFindReplace.svelte.d.ts +10 -0
  50. package/dist/SvSheetFormatCells.svelte +421 -0
  51. package/dist/SvSheetFormatCells.svelte.d.ts +17 -0
  52. package/dist/SvSheetGoalSeek.svelte +144 -0
  53. package/dist/SvSheetGoalSeek.svelte.d.ts +12 -0
  54. package/dist/SvSheetInsertFunction.svelte +147 -0
  55. package/dist/SvSheetInsertFunction.svelte.d.ts +8 -0
  56. package/dist/SvSheetListPicker.svelte +89 -0
  57. package/dist/SvSheetListPicker.svelte.d.ts +16 -0
  58. package/dist/SvSheetManageRules.svelte +185 -0
  59. package/dist/SvSheetManageRules.svelte.d.ts +16 -0
  60. package/dist/SvSheetNameManager.svelte +225 -0
  61. package/dist/SvSheetNameManager.svelte.d.ts +11 -0
  62. package/dist/SvSheetPasteSpecial.svelte +105 -0
  63. package/dist/SvSheetPasteSpecial.svelte.d.ts +12 -0
  64. package/dist/SvSheetRemoveDuplicates.svelte +149 -0
  65. package/dist/SvSheetRemoveDuplicates.svelte.d.ts +12 -0
  66. package/dist/SvSheetRibbon.svelte +1201 -0
  67. package/dist/SvSheetRibbon.svelte.d.ts +34 -0
  68. package/dist/SvSheetSizeDialog.svelte +58 -0
  69. package/dist/SvSheetSizeDialog.svelte.d.ts +11 -0
  70. package/dist/SvSheetTabs.svelte +441 -0
  71. package/dist/SvSheetTabs.svelte.d.ts +36 -0
  72. package/dist/SvSheetTabs.test-harness.svelte.d.ts +17 -0
  73. package/dist/SvSheetTabs.test-harness.svelte.js +22 -0
  74. package/dist/SvSheetTextToColumns.svelte +172 -0
  75. package/dist/SvSheetTextToColumns.svelte.d.ts +13 -0
  76. package/dist/SvSheetValidationAlert.svelte +83 -0
  77. package/dist/SvSheetValidationAlert.svelte.d.ts +15 -0
  78. package/dist/advanced-filter/expr-columns-from-grid.d.ts +1 -1
  79. package/dist/advanced-filter-enable.js +3 -3
  80. package/dist/alerts/alert-engine-attach.d.ts +2 -2
  81. package/dist/alerts/alert-engine-attach.js +3 -3
  82. package/dist/alerts/alert-engine.d.ts +1 -1
  83. package/dist/alerts/alert-engine.js +2 -2
  84. package/dist/alerts/alert-formats.d.ts +1 -1
  85. package/dist/alerts/alert-formats.js +1 -1
  86. package/dist/alerts/alert-observer.d.ts +3 -3
  87. package/dist/alerts/alert-observer.js +1 -1
  88. package/dist/alerts/alert-storage.d.ts +1 -1
  89. package/dist/alerts/alert-store.svelte.d.ts +1 -1
  90. package/dist/alerts/alert-types.d.ts +2 -2
  91. package/dist/alerts.js +2 -2
  92. package/dist/board.js +2 -2
  93. package/dist/cdn/svgrid-enterprise.svelte-external.js +19046 -5428
  94. package/dist/designer/assets/GridMenus-BlsvL_aU.js +8 -0
  95. package/dist/designer/assets/SvChartMenu-BYO9cYdy.css +1 -0
  96. package/dist/designer/assets/SvChartMenu-D9hEeHar.js +1 -0
  97. package/dist/designer/assets/SvDateRangeInput-BBRzrQeg.js +1 -0
  98. package/dist/designer/assets/SvDateRangeInput-C0iCCZ8o.css +1 -0
  99. package/dist/designer/assets/SvDateTimePicker-DARvKUL2.js +1 -0
  100. package/dist/designer/assets/SvDateTimePicker-DPv09DIT.css +1 -0
  101. package/dist/designer/assets/{preload-helper-CG0n22O7.js → SvGrid.helpers-BgT-gnCo.js} +1 -1
  102. package/dist/designer/assets/SvGridCellEditor-C7uQXYds.js +4 -0
  103. package/dist/designer/assets/SvGridChart-B8j1SSy3.js +3 -0
  104. package/dist/designer/assets/SvGridChart-BgLuPO7I.css +1 -0
  105. package/dist/designer/assets/SvGridChartBuilder-08YWWeqY.css +1 -0
  106. package/dist/designer/assets/SvGridChartBuilder-kvyIU_Rl.js +1 -0
  107. package/dist/designer/assets/SvGridChartPanel-4Pc8y5yO.css +1 -0
  108. package/dist/designer/assets/SvGridChartPanel-pT6ITc2P.js +4 -0
  109. package/dist/designer/assets/SvGridChartView-7msfnWZT.css +1 -0
  110. package/dist/designer/assets/SvGridChartView-bg281UA1.js +1 -0
  111. package/dist/designer/assets/SvGridDropdown-Bb3HH2Aj.js +1 -0
  112. package/dist/designer/assets/SvGridDropdown-CcidB3rm.css +1 -0
  113. package/dist/designer/assets/SvListBox-BDS66lmZ.js +110 -0
  114. package/dist/designer/assets/SvListBox-BXfyqy4a.css +1 -0
  115. package/dist/designer/assets/SvMenuList-DJ47ba1h.css +1 -0
  116. package/dist/designer/assets/SvMenuList-Dgq2D5VN.js +1 -0
  117. package/dist/designer/assets/SvTabs-DKmwb5p1.css +1 -0
  118. package/dist/designer/assets/SvTabs-UFCMTHXS.js +1 -0
  119. package/dist/designer/assets/cell-formatting-wr5wm7US.js +1 -0
  120. package/dist/designer/assets/chart-cIPUsbM5.js +1 -0
  121. package/dist/designer/assets/chart-export-B6bkQ3X8.js +11 -0
  122. package/dist/designer/assets/chart-export-pdf-BbA28-gt.js +11 -0
  123. package/dist/designer/assets/chart-panel-messages-DtTxpBUU.js +1 -0
  124. package/dist/designer/assets/chart-summary-Czu_5epb.js +1 -0
  125. package/dist/designer/assets/chart-trend-D5I5VEe8.js +1 -0
  126. package/dist/designer/assets/chart-validate-BMqM-qkF.js +1 -0
  127. package/dist/designer/assets/column-resize-BYDzR1Ve.js +1 -0
  128. package/dist/designer/assets/date-format-B8JbEtIG.js +1 -0
  129. package/dist/designer/assets/date-format-DKS-z0xS.css +1 -0
  130. package/dist/designer/assets/editor-contract-Bpoa7A5g.js +1 -0
  131. package/dist/designer/assets/export-format-D-eP4b2l.js +6 -0
  132. package/dist/designer/assets/focus-trap-CAreAhlU.js +1 -0
  133. package/dist/designer/assets/index-CMFgAoVV.css +1 -0
  134. package/dist/designer/assets/{index-zUBix-JQ.js → index-DwU7CAMm.js} +2985 -141
  135. package/dist/designer/assets/list-option-BP_dWS66.js +1 -0
  136. package/dist/designer/assets/preload-helper-HclGiUj8.js +1 -0
  137. package/dist/designer/assets/row-resize-DLktcqUK.js +1 -0
  138. package/dist/designer/assets/src-CITpcm6b.js +1 -0
  139. package/dist/designer/assets/src-EdI3Xqds.js +58 -0
  140. package/dist/designer/index.html +32 -23
  141. package/dist/export-conditional.d.ts +1 -1
  142. package/dist/export-pdf.d.ts +89 -0
  143. package/dist/export-pdf.js +130 -0
  144. package/dist/export-print.js +19 -19
  145. package/dist/export-xls.d.ts +2 -2
  146. package/dist/export.d.ts +2 -2
  147. package/dist/export.js +23 -16
  148. package/dist/expressions/builder-tree.d.ts +2 -2
  149. package/dist/expressions/builder-tree.js +1 -1
  150. package/dist/expressions/compile.d.ts +1 -1
  151. package/dist/import.js +1 -1
  152. package/dist/index.d.ts +116 -53
  153. package/dist/index.js +117 -50
  154. package/dist/install.d.ts +4 -4
  155. package/dist/install.js +16 -11
  156. package/dist/license.d.ts +3 -2
  157. package/dist/license.js +9 -4
  158. package/dist/node/studio.js +187 -116
  159. package/dist/pivot-chart.d.ts +26 -2
  160. package/dist/pivot-chart.js +25 -69
  161. package/dist/pivot-designer.d.ts +1 -1
  162. package/dist/pivot-enable.js +3 -3
  163. package/dist/print.d.ts +1 -1
  164. package/dist/print.js +4 -4
  165. package/dist/scheduler-config.d.ts +5 -5
  166. package/dist/scheduler-freebusy.d.ts +1 -1
  167. package/dist/scheduler-freebusy.js +1 -1
  168. package/dist/scheduler.js +2 -2
  169. package/dist/schema-designer.d.ts +1 -1
  170. package/dist/schema-designer.js +2 -2
  171. package/dist/selection-bar.js +2 -2
  172. package/dist/sheet/address.d.ts +43 -0
  173. package/dist/sheet/address.js +81 -0
  174. package/dist/sheet/ast.d.ts +94 -0
  175. package/dist/sheet/ast.js +56 -0
  176. package/dist/sheet/auto-filter.d.ts +66 -0
  177. package/dist/sheet/auto-filter.js +147 -0
  178. package/dist/sheet/autocomplete.d.ts +42 -0
  179. package/dist/sheet/autocomplete.js +162 -0
  180. package/dist/sheet/coerce.d.ts +31 -0
  181. package/dist/sheet/coerce.js +122 -0
  182. package/dist/sheet/commands.d.ts +89 -0
  183. package/dist/sheet/commands.js +230 -0
  184. package/dist/sheet/comments.d.ts +29 -0
  185. package/dist/sheet/comments.js +73 -0
  186. package/dist/sheet/conditional-formats.d.ts +144 -0
  187. package/dist/sheet/conditional-formats.js +316 -0
  188. package/dist/sheet/deps.d.ts +60 -0
  189. package/dist/sheet/deps.js +199 -0
  190. package/dist/sheet/document.d.ts +150 -0
  191. package/dist/sheet/document.js +264 -0
  192. package/dist/sheet/edit-keys.d.ts +14 -0
  193. package/dist/sheet/edit-keys.js +40 -0
  194. package/dist/sheet/entry.d.ts +31 -0
  195. package/dist/sheet/entry.js +76 -0
  196. package/dist/sheet/evaluate.d.ts +61 -0
  197. package/dist/sheet/evaluate.js +337 -0
  198. package/dist/sheet/find-replace.d.ts +62 -0
  199. package/dist/sheet/find-replace.js +152 -0
  200. package/dist/sheet/format-store.d.ts +99 -0
  201. package/dist/sheet/format-store.js +233 -0
  202. package/dist/sheet/freeze.d.ts +54 -0
  203. package/dist/sheet/freeze.js +58 -0
  204. package/dist/sheet/function-catalog.d.ts +10 -0
  205. package/dist/sheet/function-catalog.js +94 -0
  206. package/dist/sheet/functions.d.ts +28 -0
  207. package/dist/sheet/functions.js +362 -0
  208. package/dist/sheet/goal-seek.d.ts +73 -0
  209. package/dist/sheet/goal-seek.js +153 -0
  210. package/dist/sheet/index.d.ts +53 -0
  211. package/dist/sheet/index.js +53 -0
  212. package/dist/sheet/merges.d.ts +71 -0
  213. package/dist/sheet/merges.js +137 -0
  214. package/dist/sheet/names.d.ts +31 -0
  215. package/dist/sheet/names.js +87 -0
  216. package/dist/sheet/navigate.d.ts +52 -0
  217. package/dist/sheet/navigate.js +135 -0
  218. package/dist/sheet/number-format.d.ts +63 -0
  219. package/dist/sheet/number-format.js +508 -0
  220. package/dist/sheet/palette.d.ts +19 -0
  221. package/dist/sheet/palette.js +84 -0
  222. package/dist/sheet/parse.d.ts +13 -0
  223. package/dist/sheet/parse.js +108 -0
  224. package/dist/sheet/paste-special.d.ts +164 -0
  225. package/dist/sheet/paste-special.js +592 -0
  226. package/dist/sheet/protection.d.ts +21 -0
  227. package/dist/sheet/protection.js +48 -0
  228. package/dist/sheet/rects.d.ts +45 -0
  229. package/dist/sheet/rects.js +112 -0
  230. package/dist/sheet/refs.d.ts +82 -0
  231. package/dist/sheet/refs.js +266 -0
  232. package/dist/sheet/ribbon-icons.d.ts +31 -0
  233. package/dist/sheet/ribbon-icons.js +350 -0
  234. package/dist/sheet/ribbon.d.ts +173 -0
  235. package/dist/sheet/ribbon.js +753 -0
  236. package/dist/sheet/shortcuts.d.ts +164 -0
  237. package/dist/sheet/shortcuts.js +623 -0
  238. package/dist/sheet/structure.d.ts +87 -0
  239. package/dist/sheet/structure.js +166 -0
  240. package/dist/sheet/tables.d.ts +70 -0
  241. package/dist/sheet/tables.js +147 -0
  242. package/dist/sheet/tokenize.d.ts +54 -0
  243. package/dist/sheet/tokenize.js +304 -0
  244. package/dist/sheet/transforms.d.ts +76 -0
  245. package/dist/sheet/transforms.js +154 -0
  246. package/dist/sheet/validation.d.ts +92 -0
  247. package/dist/sheet/validation.js +241 -0
  248. package/dist/sheet/workbook.d.ts +72 -0
  249. package/dist/sheet/workbook.js +409 -0
  250. package/dist/sheet-enable.d.ts +6 -0
  251. package/dist/sheet-enable.js +46 -0
  252. package/dist/sources/aggregate.d.ts +1 -1
  253. package/dist/sources/auth-supabase.js +1 -1
  254. package/dist/sources/dashboard.d.ts +2 -2
  255. package/dist/sources/dashboard.js +1 -1
  256. package/dist/sources/index.d.ts +13 -13
  257. package/dist/sources/index.js +13 -13
  258. package/dist/sources/realtime-supabase.js +1 -1
  259. package/dist/sources/relation-lookup.d.ts +1 -1
  260. package/dist/sources/relation-lookup.js +1 -1
  261. package/dist/sources/rest-adapters.d.ts +1 -1
  262. package/dist/sources/rest-adapters.js +1 -1
  263. package/dist/sources/rest.d.ts +2 -2
  264. package/dist/sources/rest.js +3 -3
  265. package/dist/sources/supabase.d.ts +2 -2
  266. package/dist/sources/supabase.js +3 -3
  267. package/dist/sources/with-entity-rules.d.ts +1 -1
  268. package/dist/sources/with-entity-rules.js +1 -1
  269. package/dist/sources/with-relation-labels.d.ts +1 -1
  270. package/dist/studio/emit-project.js +2161 -2127
  271. package/dist/studio/emit-schema.js +369 -369
  272. package/dist/studio/index.d.ts +1 -1
  273. package/dist/studio/index.js +1 -1
  274. package/dist/studio/introspect-db.js +45 -45
  275. package/dist/studio/project.d.ts +10 -0
  276. package/dist/studio/samples/crm.js +6 -6
  277. package/dist/studio/samples/fleet.js +7 -7
  278. package/dist/studio/samples/insurance.js +9 -9
  279. package/dist/studio/samples/inventory.js +12 -12
  280. package/dist/studio/samples/projects.js +5 -5
  281. package/dist/studio/samples/support.js +8 -8
  282. package/dist/studio/scaffold-app.js +104 -104
  283. package/dist/studio/scaffold.js +151 -151
  284. package/dist/studio/ui-components.d.ts +7 -0
  285. package/dist/studio/ui-components.generated.js +63 -1
  286. package/dist/studio/ui-components.js +35 -0
  287. package/dist/sveltekit/in-memory.d.ts +3 -3
  288. package/dist/sveltekit/in-memory.js +4 -4
  289. package/dist/sveltekit/index.d.ts +6 -6
  290. package/dist/sveltekit/index.js +5 -5
  291. package/dist/sveltekit/query-plan.d.ts +1 -1
  292. package/dist/sveltekit/sql-source.d.ts +4 -4
  293. package/dist/sveltekit/sql-source.js +4 -4
  294. package/dist/sveltekit/sql.d.ts +1 -1
  295. package/dist/sveltekit/transport.d.ts +2 -2
  296. package/dist/sveltekit/transport.js +1 -1
  297. package/dist/upgrade-prompt.js +25 -25
  298. package/dist/version.d.ts +1 -1
  299. package/dist/version.js +1 -1
  300. package/package.json +12 -4
  301. package/dist/designer/assets/GridMenus-Cgilexpk.js +0 -8
  302. package/dist/designer/assets/SvDateRangeInput-BV5OXYQ2.css +0 -1
  303. package/dist/designer/assets/SvDateRangeInput-CVn-yqrQ.js +0 -1
  304. package/dist/designer/assets/SvDateTimePicker-BdioTrJQ.js +0 -1
  305. package/dist/designer/assets/SvDateTimePicker-CY8TK4BT.css +0 -1
  306. package/dist/designer/assets/SvField-BF-9TJSd.css +0 -1
  307. package/dist/designer/assets/SvField-BsqiA5me.js +0 -1
  308. package/dist/designer/assets/SvGridCellEditor-dLY7_kk9.js +0 -2
  309. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +0 -2
  310. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +0 -1
  311. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +0 -10
  312. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +0 -1
  313. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +0 -1
  314. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +0 -1
  315. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +0 -1
  316. package/dist/designer/assets/SvListBox-CVYQcLX9.js +0 -1
  317. package/dist/designer/assets/SvListBox-DgN8kvjt.css +0 -1
  318. package/dist/designer/assets/chart-DHEgt4lN.js +0 -1
  319. package/dist/designer/assets/column-resize-C_inr5zT.js +0 -1
  320. package/dist/designer/assets/date-format-4DJvNtG2.js +0 -1
  321. package/dist/designer/assets/date-format-Dzy4mTGh.css +0 -1
  322. package/dist/designer/assets/easing-zaypKrr0.js +0 -3
  323. package/dist/designer/assets/export-format-DwjuTfrQ.js +0 -6
  324. package/dist/designer/assets/index-Cp7fDJ3b.css +0 -1
  325. package/dist/designer/assets/js-scroller.svelte-Bhxqb3-c.js +0 -110
  326. package/dist/designer/assets/row-resize-B0FWHcNr.js +0 -1
  327. package/dist/designer/assets/src-B7DNcoQK.css +0 -1
  328. package/dist/designer/assets/src-BoYyigAC.js +0 -2896
  329. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -1
  330. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -178
  331. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +0 -3875
  332. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +0 -1
  333. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +0 -21
  334. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +0 -1
  335. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +0 -1938
  336. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +0 -1
  337. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +0 -205
  338. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +0 -1
  339. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +0 -18
  340. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +0 -1
  341. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +0 -264
  342. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +0 -1
  343. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +0 -697
  344. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +0 -1
  345. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -3
  346. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -134
  347. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -1
  348. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -55
  349. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -1
  350. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -2
  351. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -46843
  352. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -1
  353. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -237
  354. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -1
  355. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -2
  356. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -4
  357. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -1
  358. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -7
  359. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -4
  360. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -1
  361. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -5
  362. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -1
  363. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -5
  364. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -1
  365. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -5
  366. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -1
  367. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -6
  368. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -83
  369. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -1
  370. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -559
  371. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -1
  372. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -24
  373. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -1
  374. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -122
  375. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -1
  376. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -2
  377. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -89
  378. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -1
  379. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -241
  380. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -1
  381. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +0 -4938
  382. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +0 -1
  383. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +0 -495
  384. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +0 -1
  385. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +0 -190
  386. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +0 -1
  387. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +0 -8
  388. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +0 -1
  389. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +0 -138
  390. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +0 -1
@@ -0,0 +1,4114 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The spreadsheet shell: everything between the ribbon and the sheet tabs.
4
+ *
5
+ * SvSheetRibbon Home / Insert / Formulas / Data
6
+ * SvFormulaBar Name Box + fx, showing the RAW text of the active cell
7
+ * SvGrid A..Z column headers over the built-in 1..N gutter
8
+ * SvSheetTabs sheet tabs
9
+ * status bar Sum / Average / Count of the selection, as Excel has
10
+ *
11
+ * Composition, not a second grid. Every part here already existed and was
12
+ * being re-assembled by hand in four separate demos; the value of the
13
+ * component is that the wiring BETWEEN them is done once and correctly -
14
+ * the formula bar shows the raw text rather than the computed value, the
15
+ * format store is keyed by row id so sorting cannot strand formatting, the
16
+ * ribbon and the keyboard run the same actions, and the tab strip is told
17
+ * when a shortcut moved the active sheet.
18
+ *
19
+ * <SvSheet workbook={wb} />
20
+ *
21
+ * That is the whole required API. Everything else is opt-out.
22
+ */
23
+ import {
24
+ SvGrid,
25
+ SvPopover,
26
+ SvModal,
27
+ tableFeatures,
28
+ renderSnippet,
29
+ buildFillPattern,
30
+ type GridColumns,
31
+ type SvGridApi,
32
+ type TableFeatures,
33
+ type ContextMenuItem,
34
+ type ContextMenuIcon,
35
+ } from '@svgrid/grid'
36
+ import { tick, untrack } from 'svelte'
37
+ import type { GridCommandContext } from '@svgrid/grid/shortcuts'
38
+ import SvSheetRibbon from './SvSheetRibbon.svelte'
39
+ import SvFormulaBar from './SvFormulaBar.svelte'
40
+ import SvSheetTabs from './SvSheetTabs.svelte'
41
+ import SvSheetFindReplace from './SvSheetFindReplace.svelte'
42
+ import SvSheetPasteSpecial from './SvSheetPasteSpecial.svelte'
43
+ import SvSheetFormatCells from './SvSheetFormatCells.svelte'
44
+ import SvSheetInsertFunction from './SvSheetInsertFunction.svelte'
45
+ import SvSheetSizeDialog from './SvSheetSizeDialog.svelte'
46
+ import SvSheetNameManager from './SvSheetNameManager.svelte'
47
+ import SvSheetGoalSeek from './SvSheetGoalSeek.svelte'
48
+ import SvSheetTextToColumns from './SvSheetTextToColumns.svelte'
49
+ import SvSheetRemoveDuplicates from './SvSheetRemoveDuplicates.svelte'
50
+ import { currentRegion, isBlankValue } from './sheet/navigate'
51
+ import { freezeAtActiveCell, freezeTopRow, freezeFirstColumn, applyFreeze, type FreezeState } from './sheet/freeze'
52
+ import { gridOf } from './sheet/shortcuts'
53
+ import { enableSheet } from './sheet-enable'
54
+ import type { Workbook, SheetData } from './sheet/workbook'
55
+ import { createSheetDocument, type SheetDocument, type SheetState, type SheetChangeReason } from './sheet/document'
56
+ import {
57
+ setWorkbook, setFormatTarget, setFindReplaceHandler, setFormatDialogHandler, setPasteSpecialHandler,
58
+ setRibbonActionHandler,
59
+ getFormatTarget, withFormatUndo, applyFormat, clearFormats,
60
+ } from './sheet/shortcuts'
61
+ import { applyBorders, type BorderPreset } from './sheet/ribbon'
62
+ import { RIBBON_ICONS, type RibbonIconName } from './sheet/ribbon-icons'
63
+ import {
64
+ planPaste, resolvePasteCell, buildClipboardPayload, parseClipboardHtml, readClipboardOrigin, anchorForeignFormulas,
65
+ type ClipboardGrid, type PasteSpecialOptions, type PasteWhat,
66
+ } from './sheet/paste-special'
67
+ import { setStructureTarget, insertRows, insertColumns, deleteRows, deleteColumns, axisForSelection } from './sheet/structure'
68
+ import { setFindTarget } from './sheet/find-replace'
69
+ import { setFillTranslator, setSheetValueProbe } from './sheet/commands'
70
+ import { translateFormula } from './sheet/refs'
71
+ import { entryToStyle, type SheetFormatStore, type CellFormatEntry } from './sheet/format-store'
72
+ import { compileNumberFormat } from './sheet/number-format'
73
+ import { colToLetters, lettersToCol, parseA1 } from './sheet/address'
74
+ import { referenceSpans, type ReferenceSpan } from './sheet/refs'
75
+ import { balanceParens } from './sheet/autocomplete'
76
+ import { parseEntry, completeEntry } from './sheet/entry'
77
+ import { isError, type CellValue } from './sheet/ast'
78
+ import { isLocked, rectsHaveLocked, rectsMixLocked, PROTECTED_MESSAGE } from './sheet/protection'
79
+ import { commentAt, withComment, nextComment, listComments, type NotesMap } from './sheet/comments'
80
+ import SvSheetComment from './SvSheetComment.svelte'
81
+ import SvSheetListPicker from './SvSheetListPicker.svelte'
82
+ import SvSheetDataValidation from './SvSheetDataValidation.svelte'
83
+ import SvSheetValidationAlert from './SvSheetValidationAlert.svelte'
84
+ import SvSheetConditionalFormat from './SvSheetConditionalFormat.svelte'
85
+ import SvSheetManageRules from './SvSheetManageRules.svelte'
86
+ import {
87
+ ruleStats, evaluateCf, removeCf, cfId, COLOR_SCALES, DATA_BAR_COLOR,
88
+ type CfRule, type CfRuleBody, type CfStats, type CfResult, type CfContext, type CfPreset, type CfBody, type CfStyledRule,
89
+ } from './sheet/conditional-formats'
90
+ import type { Rect } from './sheet/rects'
91
+ import {
92
+ mergePlan, unmergePlan, toGridMerges, isCoveredCell, selectionMerged, mergesIn, sortBlockedByMerges, reorderMerges,
93
+ mergeAt as sheetMergeAt, normalRect, expandToMerges, type MergeKind, type MergePlan,
94
+ } from './sheet/merges'
95
+ import {
96
+ distinctValues, hiddenRowsFor, withColumnFilter, isFiltering, type AutoFilterState, type ColumnFilter,
97
+ } from './sheet/auto-filter'
98
+ import SvSheetFilterMenu from './SvSheetFilterMenu.svelte'
99
+ import {
100
+ ruleAt, checkEntry, listChoices, removeValidation, validationId,
101
+ type ValidationRule, type ValidationContext, type ValidationVerdict, type ValidationSpec,
102
+ } from './sheet/validation'
103
+ import type { RibbonActionId } from './sheet/ribbon'
104
+
105
+ type Props = {
106
+ /**
107
+ * The document to edit: the workbook plus everything a sheet keeps beside
108
+ * its cells (formats, sizes, hidden lines, frozen panes, comments). Build
109
+ * one with `createSheetDocument` to save it, restore it or listen to it
110
+ * outside the component; without it the shell makes its own from
111
+ * `workbook` or `data` and hands it back through `onReady`.
112
+ */
113
+ document?: SheetDocument
114
+ /** The workbook to edit. One is created from `data` when absent. */
115
+ workbook?: Workbook
116
+ /** Seed sheets, when not supplying a workbook. */
117
+ data?: ReadonlyArray<SheetData>
118
+ /** Minimum grid size, so a sparse sheet still looks like a sheet. */
119
+ rows?: number
120
+ columns?: number
121
+ /**
122
+ * Grid viewport height in pixels, or `'100%'` to fill the parent.
123
+ *
124
+ * The chrome around the grid - ribbon, formula bar, tabs, status bar -
125
+ * is about 290px tall, so a fixed grid height is a fixed component
126
+ * height with that added on. In a flex column that is too tall for the
127
+ * space, the tab strip is what gets pushed under whatever comes next.
128
+ * With `'100%'` the component is a flex item that fills its column and
129
+ * the grid takes whatever the chrome leaves.
130
+ */
131
+ height?: number | '100%'
132
+ /** Default column width. */
133
+ columnWidth?: number
134
+ /** Row height in pixels. Excel's is 20 at 100%; 22 fits the grid's 13px font. */
135
+ rowHeight?: number
136
+ /**
137
+ * `theme` (the default) paints the shell with the host's `--sg-*`
138
+ * tokens, so it follows whatever theme the app around it runs, as the
139
+ * grid does. `excel` pins Excel's own palette and geometry, light or
140
+ * dark with the page, for an app that wants it to read as Excel
141
+ * whatever the theme.
142
+ */
143
+ look?: 'excel' | 'theme'
144
+ /**
145
+ * Per-column overrides, keyed by column letter: `{ A: 150 }`.
146
+ *
147
+ * A sheet's first column usually holds labels and the rest hold numbers,
148
+ * so one width for all of them truncates the only column that needs the
149
+ * room. Columns stay drag-resizable either way.
150
+ */
151
+ columnWidths?: Readonly<Record<string, number>>
152
+ /**
153
+ * Cell formats to start with, keyed by A1 address: `{ B2: { bold: true },
154
+ * 'E13': { numFmt: '0.0%' } }`.
155
+ *
156
+ * A bare address formats the sheet that is active when the shell mounts.
157
+ * Qualify it to reach another sheet, the way a formula would:
158
+ * `{ 'Orders!F2': { numFmt: '$#,##0.00' }, "'Price list'!C2": ... }`.
159
+ * Formats are per sheet, as they are in Excel; a fill on Summary!C5 says
160
+ * nothing about Orders!C5.
161
+ *
162
+ * Without this the sheet can only be formatted by hand after it loads,
163
+ * which makes it impossible to ship a document that opens looking the way
164
+ * it was saved. Applied once at mount; after that the stores own it.
165
+ */
166
+ formats?: Readonly<Record<string, CellFormatEntry>>
167
+ /**
168
+ * Ribbon buttons the shell has nothing behind: Insert > Table and
169
+ * Insert > Chart. Left off unless listed here, which says the
170
+ * application answers them in `onAction`.
171
+ */
172
+ extras?: ReadonlyArray<'insert-table' | 'insert-chart'>
173
+ /** Hide any part of the chrome. */
174
+ showRibbon?: boolean
175
+ showFormulaBar?: boolean
176
+ showTabs?: boolean
177
+ showStatusBar?: boolean
178
+ /**
179
+ * Every action the ribbon, the cell menu, the formula bar and the
180
+ * shortcuts raise. The shell has a dialog of its own for Find and
181
+ * Replace, Paste Special, Format Cells and Insert Function; a handler
182
+ * that returns `true` has taken the action over and the shell's dialog
183
+ * stays closed, so an application can put its own in their place.
184
+ */
185
+ onAction?: (action: RibbonActionId, cmd: GridCommandContext) => void | boolean
186
+ onReady?: (api: SheetApi, document: SheetDocument) => void
187
+ /**
188
+ * Something the user did landed in the document: a cell, a format, a
189
+ * size, a hidden line, a frozen pane, a sheet, a comment, or a structural
190
+ * edit. Called once per tick with every reason since the last call, so a
191
+ * paste of forty cells is one call; the natural place for an autosave
192
+ * (`getState()`, debounced). Undo and redo report too.
193
+ */
194
+ onChange?: (reasons: ReadonlyArray<SheetChangeReason>) => void
195
+ }
196
+
197
+ /**
198
+ * A grid row carries the RAW text of every cell on it, keyed by column
199
+ * letter, as well as its position.
200
+ *
201
+ * It would be tempting to keep the values only in the workbook and let the
202
+ * cell snippet read them, since the snippet is what paints. That does not
203
+ * work, and not only for rendering: the grid's own machinery reads
204
+ * `row[field]`, so an empty row object means `cmd.getCellValue` returns
205
+ * undefined for every cell, and then Ctrl+Arrow thinks the sheet is blank,
206
+ * AutoSum finds no run to total, and the inline editor has nothing to open
207
+ * on. The workbook stays authoritative; this is its projection.
208
+ */
209
+ type SheetRow = { id: string; index: number } & Record<string, string | number>
210
+ type SheetApi = SvGridApi<TableFeatures, SheetRow>
211
+
212
+ let {
213
+ document: sheetDocument,
214
+ workbook,
215
+ data,
216
+ rows: minRows = 50,
217
+ columns: minCols = 12,
218
+ height = 420,
219
+ columnWidth = 96,
220
+ rowHeight = 22,
221
+ look = 'theme',
222
+ columnWidths,
223
+ formats,
224
+ extras = [],
225
+ showRibbon = true,
226
+ showFormulaBar = true,
227
+ showTabs = true,
228
+ showStatusBar = true,
229
+ onAction,
230
+ onReady,
231
+ onChange,
232
+ }: Props = $props()
233
+
234
+ enableSheet()
235
+
236
+ // Read once, on purpose: the document IS what is being edited. Rebuilding
237
+ // it when the prop identity changed would throw away every edit, so a
238
+ // consumer that wants a different document mounts a different <SvSheet>.
239
+ // svelte-ignore state_referenced_locally
240
+ const doc: SheetDocument =
241
+ sheetDocument ?? createSheetDocument(workbook ? { workbook } : { sheets: data ? [...data] : [{ name: 'Sheet1', cells: [] }] })
242
+ const wb: Workbook = doc.workbook
243
+
244
+ /** Report a change to the document; the `onChange` prop hears it once per tick. */
245
+ const changed = (reason: SheetChangeReason) => doc.changed(reason)
246
+ $effect(() => {
247
+ if (!onChange) return
248
+ return doc.subscribe((reasons) => onChange(reasons))
249
+ })
250
+
251
+ /**
252
+ * The document as it stands, for saving: every sheet's cells and names
253
+ * plus the formats, sizes, hidden lines, frozen panes and comments. The
254
+ * active sheet's sizes and hidden lines live on the grid while it shows,
255
+ * so they are read back into the document first.
256
+ */
257
+ export function getState(): SheetState {
258
+ stashLive(wb.active)
259
+ return doc.getState()
260
+ }
261
+
262
+ /**
263
+ * Put a saved document back, in place: the workbook the consumer holds
264
+ * stays the same object. The grid's history is cleared, since none of it
265
+ * describes the sheet any more.
266
+ */
267
+ export function setState(state: SheetState): void {
268
+ doc.setState(state)
269
+ // The active sheet may have changed with the state; the targets rebind
270
+ // to it without a swap (the document already holds every sheet's parts).
271
+ targetsBoundTo = wb.active
272
+ registerSheetTargets()
273
+ applyLive(wb.active)
274
+ const cmd = cmdOf()
275
+ if (cmd) applyFreeze(cmd, doc.get(wb.active).freeze)
276
+ api?.clearHistory()
277
+ active = { rowIndex: 0, colIndex: 0 }
278
+ bump()
279
+ }
280
+
281
+ /**
282
+ * One counter drives every read of the workbook and the format store.
283
+ *
284
+ * Neither is `$state` - they are plain objects shared with the keyboard
285
+ * layer, which has no Svelte in it - so nothing else would tell Svelte that
286
+ * a cell changed. Everything reactive below reads `version` first.
287
+ */
288
+ let version = $state(0)
289
+ // The sheet list and the active sheet as last seen, so a tab click, Insert
290
+ // > New Sheet or Shift+F11 (none of which come through this component)
291
+ // still report as a change.
292
+ let sheetsSeen = ''
293
+ const bump = () => {
294
+ // A tab click switches sheets through the workbook alone, with no call
295
+ // into this component, so the switch is noticed here: the first repaint
296
+ // after it rebinds the per-sheet targets before anything reads them.
297
+ if (wb.active !== targetsBoundTo) registerSheetTargets()
298
+ const signature = wb.sheets.join('\u0000') + '\u0000' + wb.active
299
+ if (sheetsSeen && signature !== sheetsSeen) changed({ kind: 'sheets' })
300
+ sheetsSeen = signature
301
+ cfStatsCache.clear()
302
+ refreshAutoFilter()
303
+ version += 1
304
+ }
305
+
306
+ /**
307
+ * Tell the shell the workbook changed underneath it.
308
+ *
309
+ * Every edit made THROUGH the shell - typing, the ribbon, a shortcut, the
310
+ * fill handle, `cmd.setCellValue` from an `onAction` handler - already
311
+ * repaints. A write the shell cannot see does not: a Name Manager that
312
+ * redefines a name, an import that replaces a sheet, a solver that has just
313
+ * applied its answer with `wb.setRaw`. Those land in the workbook while the
314
+ * cells keep showing the values from before, so call this afterwards.
315
+ *
316
+ * let sheet: SvSheet
317
+ * <SvSheet bind:this={sheet} workbook={wb} onAction={...} />
318
+ * wb.names.define('TaxRate', 'Inputs!B3'); wb.recalculate(); sheet.refresh()
319
+ */
320
+ export function refresh() { bump() }
321
+
322
+ // The Name Box lists the defined names. Read under `version` like every
323
+ // other view of the workbook: names are defined at runtime too, and a list
324
+ // read once at mount would leave the box a name short of the sheet.
325
+ const definedNames = $derived.by(() => {
326
+ void version
327
+ return wb.names.list()
328
+ })
329
+
330
+ let active = $state({ rowIndex: 0, colIndex: 0 })
331
+ let selection = $state<ReadonlyArray<readonly [number, number, number, number]>>([])
332
+ let api = $state<SheetApi | null>(null)
333
+ /** Excel's Ctrl+` - show the formulas instead of their results. */
334
+ let showFormulas = $state(false)
335
+ /*
336
+ * View > Show. View settings of this instance, not of the document: a
337
+ * saved sheet comes back with them as the host set them, which is what
338
+ * `showFormulaBar` already meant.
339
+ */
340
+ let gridlinesOn = $state(true)
341
+ let formulaBarOn = $state(true)
342
+ let headingsOn = $state(true)
343
+ /** Excel's collapsed ribbon (Ctrl+F1, a double-click on a tab, the chevron). */
344
+ let ribbonCollapsed = $state(false)
345
+
346
+ /*
347
+ * One format store per sheet, keyed by the sheet's name the way the
348
+ * workbook keys its cells. A single store for the workbook was the first
349
+ * version, and it made C5 bold on every sheet: a format is an address plus
350
+ * a sheet, and the store only knew the address.
351
+ */
352
+ function storeFor(sheet: string = wb.active): SheetFormatStore {
353
+ return doc.get(sheet).formats
354
+ }
355
+
356
+ /** Split `Orders!F2` or `'Price list'!F2` into its sheet and address. */
357
+ function splitSheet(address: string): { sheet: string | null; cell: string } {
358
+ const bang = address.lastIndexOf('!')
359
+ if (bang < 0) return { sheet: null, cell: address }
360
+ const sheet = address.slice(0, bang).replace(/^'(.*)'$/, '$1')
361
+ return { sheet, cell: address.slice(bang + 1) }
362
+ }
363
+
364
+ // Seeded once. `parseA1` is the same address parser the formula engine
365
+ // uses, so 'E13' here and =E13 in a cell cannot disagree about what E13 is.
366
+ // svelte-ignore state_referenced_locally
367
+ for (const [address, entry] of Object.entries(formats ?? {})) {
368
+ const { sheet, cell } = splitSheet(address)
369
+ const ref = parseA1(cell)
370
+ if (!ref || ref.row === null) continue
371
+ storeFor(sheet ?? wb.active).set([[ref.row, ref.col, ref.row, ref.col]], entry, {
372
+ rowIdAt: (i: number) => `r${i}`,
373
+ columnIdAt: (i: number) => colToLetters(i),
374
+ })
375
+ }
376
+
377
+ const rowCount = $derived.by(() => {
378
+ void version
379
+ return Math.max(minRows, wb.rowCount(wb.active) + 1)
380
+ })
381
+ const colCount = $derived.by(() => {
382
+ void version
383
+ return Math.max(minCols, wb.colCount(wb.active) + 1)
384
+ })
385
+
386
+ /*
387
+ * Row ids are positional (`r0`, `r1`) because a sheet cell IS its position:
388
+ * A1 means "first row, first column" and nothing else. That is the one
389
+ * place this differs from a data grid, where the row id follows the record.
390
+ */
391
+ const gridRows = $derived.by<SheetRow[]>(() => {
392
+ void version
393
+ return Array.from({ length: rowCount }, (_, index) => {
394
+ const row = { id: `r${index}`, index } as SheetRow
395
+ for (let c = 0; c < colCount; c += 1) {
396
+ row[colToLetters(c)] = wb.getRaw(wb.active, index, c)
397
+ }
398
+ return row
399
+ })
400
+ })
401
+
402
+ const lookup = {
403
+ rowIdAt: (i: number) => (i >= 0 && i < rowCount ? `r${i}` : null),
404
+ columnIdAt: (i: number) => (i >= 0 && i < colCount ? colToLetters(i) : null),
405
+ }
406
+
407
+ // --- protection -------------------------------------------------------------
408
+ /**
409
+ * Excel's sheet protection: every cell is locked unless Format Cells >
410
+ * Protection unlocked it, and the flags mean nothing until Protect Sheet
411
+ * is on. A protected sheet refuses to change a locked cell wherever the
412
+ * change comes from (typing, the formula bar, paste, fill, the ribbon's
413
+ * formats, a sort, an insert), keeps its row and column sizes, and says
414
+ * why in the status bar. There is no password: the sheet is protected
415
+ * against mistakes, not against its user.
416
+ */
417
+ const protectedNow = () => doc.get(wb.active).protected
418
+ const isProtected = $derived.by(() => { void version; return protectedNow() })
419
+ /** Whether (r, c) may not change right now. */
420
+ function locked(r: number, c: number): boolean {
421
+ return protectedNow() && isLocked(storeFor().get(`r${r}`, colToLetters(c)))
422
+ }
423
+ function refuse() { say(PROTECTED_MESSAGE) }
424
+ /** The selection, or the active cell, as rectangles. */
425
+ function selectedRects(cmd?: GridCommandContext | null): ReadonlyArray<readonly [number, number, number, number]> {
426
+ if (cmd?.ranges.length) return cmd.ranges
427
+ if (selection.length) return selection
428
+ return [[active.rowIndex, active.colIndex, active.rowIndex, active.colIndex] as const]
429
+ }
430
+ // --- comments ---------------------------------------------------------------
431
+ /**
432
+ * Excel's notes: a text on a cell, marked by a corner and read on hover.
433
+ * The document keeps them per sheet in the grid's own `notes` shape and
434
+ * moves them with an insert or delete; the grid draws the mark and the
435
+ * tooltip from `notes`, which is handed a fresh object after every change
436
+ * because the grid re-reads it only when the object is a different one.
437
+ * The editor is Excel's note box: it opens beside the cell, and whatever
438
+ * closes it keeps the text (Escape and a click elsewhere included).
439
+ */
440
+ const notesNow = (): NotesMap => doc.get(wb.active).notes
441
+ // The document keys a row `r4` as the format store does; the grid keys it
442
+ // by its index under the default getRowId, so the map it is handed is
443
+ // re-keyed, and fresh, on every repaint.
444
+ const activeNotes = $derived.by<NotesMap>(() => {
445
+ void version
446
+ const out: NotesMap = {}
447
+ for (const [rowId, line] of Object.entries(notesNow())) out[rowId.slice(1)] = line
448
+ return out
449
+ })
450
+ /**
451
+ * The one thing anchored to a cell at a time: the comment editor, with
452
+ * the text as typed so far, or a validated cell's list.
453
+ */
454
+ type CellPopover =
455
+ | { kind: 'comment'; r: number; c: number; draft: string; initial: string }
456
+ | { kind: 'list'; r: number; c: number; choices: string[] }
457
+ | { kind: 'filter'; r: number; c: number; values: ReturnType<typeof distinctValues>; numeric: boolean; header: string; filter: ColumnFilter | null }
458
+ let cellPopover = $state<CellPopover | null>(null)
459
+ /** Where the editor points: the cell's box relative to the grid host. */
460
+ let anchorRect = $state<{ left: number; top: number; width: number; height: number } | null>(null)
461
+ let gridHost = $state<HTMLDivElement | null>(null)
462
+ let showComments = $state(false)
463
+ const allComments = $derived.by(() => { void version; return listComments(notesNow()) })
464
+
465
+ /** Write, replace or (with blank text) remove the comment on (r, c), one undo. */
466
+ function setComment(r: number, c: number, text: string) {
467
+ const sheet = wb.active
468
+ const before = doc.get(sheet).notes
469
+ const after = withComment(before, r, c, text)
470
+ const put = (notes: NotesMap) => {
471
+ doc.get(sheet).notes = notes
472
+ bump()
473
+ changed({ kind: 'comments' })
474
+ }
475
+ put(after)
476
+ cmdOf()?.recordUndo(() => put(before), () => put(after))
477
+ }
478
+
479
+ /** Open the note box on (r, c), moving the cursor there first. */
480
+ function openCommentEditor(r: number, c: number) {
481
+ const cmd = cmdOf()
482
+ if (!cmd) return
483
+ if (cellPopover) closeCellPopover()
484
+ cmd.setActiveCell(r, c)
485
+ cmd.setSelection(r, c)
486
+ cmd.scrollIntoView(r, c)
487
+ active = { rowIndex: r, colIndex: c }
488
+ const initial = commentAt(notesNow(), r, c) ?? ''
489
+ cellPopover = { kind: 'comment', r, c, draft: initial, initial }
490
+ // The cell may have just been scrolled into the window: measure once
491
+ // the grid has painted it.
492
+ void tick().then(() => requestAnimationFrame(measureAnchor))
493
+ }
494
+
495
+ /** The box the popover points at, re-read on every scroll. */
496
+ function measureAnchor() {
497
+ const pop = cellPopover
498
+ const host = gridHost
499
+ if (!pop || !host) { anchorRect = null; return }
500
+ const td = host.querySelector<HTMLElement>(`td[data-svgrid-row="${pop.r}"][data-svgrid-col="${pop.c}"]`)
501
+ if (!td) {
502
+ // Scrolled out of the window: the note closes and keeps its text, as
503
+ // a note in Excel does when its cell leaves the screen; a list closes.
504
+ closeCellPopover()
505
+ return
506
+ }
507
+ const a = td.getBoundingClientRect()
508
+ const b = host.getBoundingClientRect()
509
+ anchorRect = { left: a.left - b.left, top: a.top - b.top, width: a.width, height: a.height }
510
+ }
511
+
512
+ /** Close whatever is anchored; a note keeps what was typed unless told otherwise. */
513
+ function closeCellPopover(keep = true) {
514
+ const pop = cellPopover
515
+ if (!pop) return
516
+ cellPopover = null
517
+ anchorRect = null
518
+ if (pop.kind === 'comment' && keep && pop.draft !== pop.initial) setComment(pop.r, pop.c, pop.draft)
519
+ const cmd = cmdOf()
520
+ if (cmd) focusSheet(cmd)
521
+ }
522
+
523
+ /** Review > Previous / Next Comment: walk to it and open it. */
524
+ function walkComments(dir: 1 | -1) {
525
+ const to = nextComment(notesNow(), { row: active.rowIndex, col: active.colIndex }, dir)
526
+ if (!to) { say('No comments on this sheet.'); return }
527
+ openCommentEditor(to.row, to.col)
528
+ }
529
+
530
+ // --- data validation --------------------------------------------------------
531
+ /**
532
+ * Excel's Data Validation: a rule over some cells says what may be typed
533
+ * into them. Only typed entries are checked (the in-cell editor and the
534
+ * formula bar), as in Excel: paste, fill and the commands write what they
535
+ * are given. A rejected entry never reaches the workbook; the alert box
536
+ * offers Retry, which reopens the editor with the entry, and a Warning
537
+ * rule lets the entry through on Yes.
538
+ */
539
+ const rulesNow = (): ReadonlyArray<ValidationRule> => doc.get(wb.active).validation
540
+ const validationCtx: ValidationContext = {
541
+ evaluate: (text, entry) => wb.evaluateText(wb.active, text, entry),
542
+ range: (text) => wb.evaluateRange(wb.active, text),
543
+ }
544
+ /** The alert waiting to be shown, with the entry that caused it. */
545
+ let pendingAlert = $state<{ r: number; c: number; text: string; verdict: Extract<ValidationVerdict, { ok: false }> } | null>(null)
546
+ let dataValidationOpen = $state(false)
547
+
548
+ // --- conditional formatting -------------------------------------------------
549
+ /**
550
+ * Excel's rules over cells that colour them by what they hold, evaluated
551
+ * over the COMPUTED values (a formula's result, not its text). Each rule's
552
+ * statistics over its range (min, max, mean, rank, counts) are worked out
553
+ * once per repaint and cached, so a thousand cells cost one pass; the
554
+ * cache empties in `bump()`. The rule's style paints after the cell's own,
555
+ * so the rule wins where both speak, as in Excel.
556
+ */
557
+ const cfNow = (): ReadonlyArray<CfRule> => doc.get(wb.active).conditionalFormats
558
+ const cfCtx: CfContext = { evaluate: (text) => wb.evaluateText(wb.active, text) }
559
+ const cfStatsCache = new Map<string, CfStats>()
560
+ function cfStatsFor(rule: CfRule): CfStats {
561
+ let stats = cfStatsCache.get(rule.id)
562
+ if (!stats) {
563
+ stats = ruleStats(rule, (r, c) => wb.getValue(wb.active, r, c), (r, c) => display(r, c).text)
564
+ cfStatsCache.set(rule.id, stats)
565
+ }
566
+ return stats
567
+ }
568
+ /** What the rules say about a cell, or null when none reaches it. */
569
+ function cfAt(r: number, c: number, value: CellValue): CfResult | null {
570
+ const rules = cfNow()
571
+ if (!rules.length) return null
572
+ return evaluateCf(rules, r, c, value, display(r, c).text, cfStatsFor, cfCtx)
573
+ }
574
+ let cfDialog = $state<{ preset: CfPreset; rule: CfStyledRule | null; replace?: (next: CfStyledRule) => void } | null>(null)
575
+ let manageRulesOpen = $state(false)
576
+ /** The rules and the selection as the Rules Manager sees them, live. */
577
+ const cfRules = $derived.by(() => { void version; return cfNow() })
578
+ const cfSelection = $derived.by(() => { void version; void selection; void active; return cfRects() })
579
+
580
+ // --- merged cells -----------------------------------------------------------
581
+ /**
582
+ * Excel's Merge & Center, Merge Across, Merge Cells and Unmerge Cells.
583
+ * The document keeps the merges per sheet as rectangles and moves them
584
+ * with an insert or delete; the grid draws them through `mergedCells`
585
+ * (one td with its spans, the covered cells not drawn, a selection that
586
+ * grows to whole merges, arrows that step over them). A merge keeps the
587
+ * top-left value and drops the rest, so a merge over values asks first,
588
+ * as Excel does; the clears, the centring and the merge are one undo.
589
+ */
590
+ const mergesNow = (): ReadonlyArray<Rect> => doc.get(wb.active).merges
591
+ const activeMerges = $derived.by(() => { void version; return toGridMerges(mergesNow()) })
592
+ /** A merge that would drop values, waiting for the user's OK. */
593
+ let mergeConfirm = $state<{ plan: MergePlan; rects: Rect[] } | null>(null)
594
+
595
+ /** Replace the sheet's merges, one undo. */
596
+ function setMerges(next: Rect[]) {
597
+ const sheet = wb.active
598
+ const before = doc.get(sheet).merges
599
+ const put = (merges: Rect[]) => {
600
+ doc.get(sheet).merges = merges
601
+ bump()
602
+ changed({ kind: 'merges' })
603
+ }
604
+ put(next)
605
+ cmdOf()?.recordUndo(() => put(before), () => put(next))
606
+ }
607
+
608
+ /** Whether (r, c) is inside a merge without being its top-left cell. */
609
+ function covered(r: number, c: number): boolean {
610
+ return isCoveredCell(mergesNow(), r, c)
611
+ }
612
+
613
+ /*
614
+ * The columns' live widths, one state property per letter. A cell reads
615
+ * only the letters it needs (its own, and the empty neighbours its text
616
+ * spills over), so a column being resized repaints its own cells and
617
+ * not the whole sheet; a single shared map would invalidate every cell
618
+ * on every frame of the drag. Filled from the grid once per change:
619
+ * `getColumnWidths()` builds a fresh map on every call, and the cells
620
+ * used to ask for it three times each.
621
+ */
622
+ const liveWidths = $state<Record<string, number>>({})
623
+ $effect(() => {
624
+ const next = api?.getColumnWidths() ?? {}
625
+ for (const [letter, px] of Object.entries(next)) {
626
+ if (untrack(() => liveWidths[letter]) !== px) liveWidths[letter] = px
627
+ }
628
+ })
629
+ const widthOf = (col: number): number => {
630
+ const letter = colToLetters(col)
631
+ return liveWidths[letter] ?? columnWidths?.[letter] ?? columnWidth
632
+ }
633
+
634
+ /** The width a merged origin draws across, or the column's own. */
635
+ function cellWidth(r: number, c: number): number {
636
+ const one = widthOf
637
+ const m = sheetMergeAt(mergesNow(), r, c)
638
+ if (!m || m[0] !== r || m[1] !== c) return one(c)
639
+ let total = 0
640
+ for (let col = m[1]; col <= m[3]; col += 1) total += one(col)
641
+ return total
642
+ }
643
+
644
+ /** Merge & Center / Merge Across / Merge Cells over the selection. */
645
+ function runMerge(kind: MergeKind, cmd: GridCommandContext) {
646
+ const rects = selectedRects(cmd).map(normalRect)
647
+ if (protectedNow() && rectsHaveLocked(storeFor(), lookup, rects)) { refuse(); return }
648
+ // Excel's Merge & Center on a merged selection unmerges it.
649
+ if (kind === 'center' && rects.length === 1 && mergesIn(mergesNow(), rects).some((m) => m.join() === rects[0]!.join())) {
650
+ runUnmerge(cmd)
651
+ return
652
+ }
653
+ const plan = mergePlan(mergesNow(), rects, kind, (r, c) => wb.getRaw(wb.active, r, c) === '')
654
+ if (plan.merges.length === mergesNow().length && plan.merges.every((m, i) => m === mergesNow()[i])) return
655
+ if (plan.clear.length) { mergeConfirm = { plan, rects }; return }
656
+ applyMerge(plan, cmd)
657
+ }
658
+
659
+ function applyMerge(plan: MergePlan, cmd: GridCommandContext) {
660
+ const target = getFormatTarget()
661
+ cmd.batch(() => {
662
+ for (const [r, c] of plan.clear) cmd.setCellValue(r, c, '')
663
+ if (plan.center && target && plan.origins.length) {
664
+ const rects = plan.origins.map(([r, c]) => [r, c, r, c] as const)
665
+ withFormatUndo(cmd, target, rects, () => target.store.set(rects, { align: 'center' }, target.lookup))
666
+ }
667
+ setMerges(plan.merges)
668
+ })
669
+ if (plan.origins[0]) {
670
+ cmd.setActiveCell(plan.origins[0][0], plan.origins[0][1])
671
+ cmd.setSelection(plan.origins[0][0], plan.origins[0][1])
672
+ }
673
+ focusSheet(cmd)
674
+ }
675
+
676
+ function runUnmerge(cmd: GridCommandContext) {
677
+ const rects = selectedRects(cmd).map(normalRect)
678
+ const next = unmergePlan(mergesNow(), rects)
679
+ if (next.length === mergesNow().length) return
680
+ cmd.batch(() => setMerges(next))
681
+ focusSheet(cmd)
682
+ }
683
+
684
+ /** Whether the selection holds a merge, for the menu's items. */
685
+ const selectionHasMerge = () => selectionMerged(mergesNow(), selectedRects(cmdOf()).map(normalRect))
686
+
687
+ // --- AutoFilter ---------------------------------------------------------------
688
+ /**
689
+ * Excel's AutoFilter: Ctrl+Shift+L (or Data > Filter) puts arrows on the
690
+ * header row of the current region; an arrow drops the column's values
691
+ * to tick and the conditions to set; the rows that fail fold away as
692
+ * collapsed rows. Those rows are the filter's, kept apart from the rows
693
+ * the user hid by hand (`filterHidden` vs `hidden`), so Unhide and the
694
+ * saved document see only the user's. The set is worked out again after
695
+ * every change, so a formula that drops out of a Number Filter folds
696
+ * away and one that comes back returns, as Excel's Reapply would.
697
+ */
698
+ const autoFilterNow = (): AutoFilterState | null => doc.get(wb.active).autoFilter
699
+ const activeFilter = $derived.by(() => { void version; return autoFilterNow() })
700
+ const displayOnActive = (r: number, c: number) => display(r, c).text
701
+ const valueOnActive = (r: number, c: number) => wb.getValue(wb.active, r, c)
702
+
703
+ /** What a cell of any sheet shows, for the filter over a sheet not on screen. */
704
+ function displayOn(name: string, r: number, c: number): string {
705
+ const value = wb.getValue(name, r, c)
706
+ if (isError(value)) return value.error
707
+ const entry = doc.get(name).formats.get(`r${r}`, colToLetters(c))
708
+ if (entry?.numFmt) return compileNumberFormat(entry.numFmt).format(value).text
709
+ if (value === '' || value == null) return ''
710
+ if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
711
+ return String(value)
712
+ }
713
+
714
+ /** Put the filter's rows on the grid: the delta against what it folds now. */
715
+ function settleFilterRows(next: ReadonlySet<number>) {
716
+ const state = doc.get(wb.active)
717
+ const before = state.filterHidden
718
+ if (api) {
719
+ for (const r of before) if (!next.has(r) && !state.hidden.rows.has(r)) api.setRowCollapsed(r, false)
720
+ for (const r of next) if (!before.has(r)) api.setRowCollapsed(r, true)
721
+ }
722
+ state.filterHidden = new Set(next)
723
+ }
724
+
725
+ /** Replace the sheet's AutoFilter, one undo, the rows following. */
726
+ function applyAutoFilter(next: AutoFilterState | null, cmd: GridCommandContext | null) {
727
+ const sheet = wb.active
728
+ const before = doc.get(sheet).autoFilter
729
+ const put = (state: AutoFilterState | null) => {
730
+ doc.get(sheet).autoFilter = state
731
+ settleFilterRows(hiddenRowsFor(state, valueOnActive, displayOnActive))
732
+ bump()
733
+ changed({ kind: 'filter' })
734
+ }
735
+ put(next)
736
+ cmd?.recordUndo(() => put(before), () => put(next))
737
+ }
738
+
739
+ /** Data > Filter / Ctrl+Shift+L: arrows on the current region, or off again. */
740
+ function toggleAutoFilter(cmd: GridCommandContext) {
741
+ if (autoFilterNow()) { applyAutoFilter(null, cmd); return }
742
+ const region = currentRegion(gridOf(cmd), { row: active.rowIndex, col: active.colIndex })
743
+ const last = selection[selection.length - 1]
744
+ const isRange = last && (last[0] !== last[2] || last[1] !== last[3])
745
+ const range: Rect = isRange ? normalRect(last) : region
746
+ applyAutoFilter({ range, filters: {} }, cmd)
747
+ }
748
+
749
+ /** Whether a column of the filtered region should show its arrow. */
750
+ function filterArrowAt(r: number, c: number): 'plain' | 'filtered' | null {
751
+ const af = activeFilter
752
+ if (!af || r !== af.range[0] || c < af.range[1] || c > af.range[3]) return null
753
+ return af.filters[c] ? 'filtered' : 'plain'
754
+ }
755
+
756
+ /** The menu for a column: its values, its filter, its kind. */
757
+ function filterMenuFor(col: number) {
758
+ const af = autoFilterNow()!
759
+ const others = hiddenRowsFor(withColumnFilter(af, col, null), valueOnActive, displayOnActive)
760
+ const values = distinctValues(af, col, valueOnActive, displayOnActive, (r) => !others.has(r))
761
+ const numeric = values.some((v) => v.numeric !== null) && values.every((v) => v.numeric !== null || v.text === '')
762
+ const header = display(af.range[0], col).text || colToLetters(col)
763
+ return { values, numeric, header, filter: af.filters[col] ?? null }
764
+ }
765
+
766
+ function openFilterMenu(col: number) {
767
+ const af = autoFilterNow()
768
+ const cmd = cmdOf()
769
+ if (!af || !cmd) return
770
+ if (cellPopover) closeCellPopover()
771
+ cellPopover = { kind: 'filter', r: af.range[0], c: col, ...filterMenuFor(col) }
772
+ void tick().then(() => requestAnimationFrame(measureAnchor))
773
+ }
774
+
775
+ /** Every repaint: the rows the filter folds may have changed with the values. */
776
+ function refreshAutoFilter() {
777
+ const state = doc.get(wb.active)
778
+ if (!state.autoFilter || !api) return
779
+ const next = hiddenRowsFor(state.autoFilter, valueOnActive, displayOnActive)
780
+ if (next.size === state.filterHidden.size && [...next].every((r) => state.filterHidden.has(r))) return
781
+ settleFilterRows(next)
782
+ }
783
+
784
+ /** "N of M records found", for the status bar while a filter is on. */
785
+ const filterSummary = $derived.by(() => {
786
+ void version
787
+ const af = autoFilterNow()
788
+ if (!isFiltering(af)) return null
789
+ const total = af!.range[2] - af!.range[0]
790
+ return `${total - doc.get(wb.active).filterHidden.size} of ${total} records found`
791
+ })
792
+
793
+ /** Replace the rules of the active sheet, one undo. */
794
+ function setCf(next: CfRule[]) {
795
+ const sheet = wb.active
796
+ const before = doc.get(sheet).conditionalFormats
797
+ const put = (rules: CfRule[]) => {
798
+ doc.get(sheet).conditionalFormats = rules
799
+ bump()
800
+ changed({ kind: 'conditional-formats' })
801
+ }
802
+ put(next)
803
+ cmdOf()?.recordUndo(() => put(before), () => put(next))
804
+ }
805
+
806
+ /** The selection as normalised rectangles, where a new rule applies. */
807
+ function cfRects(): Rect[] {
808
+ return selectedRects(cmdOf()).map(([r1, c1, r2, c2]) => [Math.min(r1, r2), Math.min(c1, c2), Math.max(r1, r2), Math.max(c1, c2)] as const)
809
+ }
810
+
811
+ /** A new rule over the selection, first in priority as Excel adds them. */
812
+ function addCf(body: CfRuleBody) {
813
+ const rule = { id: cfId(), rects: cfRects(), ...body } as CfRule
814
+ setCf([rule, ...cfNow()])
815
+ }
816
+
817
+ /** The small dialog came back: a new rule, or the edited one put back. */
818
+ function applyCfBody(body: CfBody) {
819
+ const dialog = cfDialog
820
+ cfDialog = null
821
+ if (dialog?.rule && dialog.replace) {
822
+ dialog.replace({ ...body, id: dialog.rule.id, rects: dialog.rule.rects, stopIfTrue: dialog.rule.stopIfTrue } as CfStyledRule)
823
+ return
824
+ }
825
+ addCf(body)
826
+ }
827
+
828
+ const CF_PRESETS: Partial<Record<RibbonActionId, CfPreset>> = {
829
+ 'cf-greater': 'greater', 'cf-less': 'less', 'cf-between': 'between', 'cf-equal': 'equal',
830
+ 'cf-text': 'text', 'cf-duplicates': 'duplicates', 'cf-top10': 'top10', 'cf-bottom10': 'bottom10',
831
+ 'cf-above-average': 'aboveAverage', 'cf-below-average': 'belowAverage',
832
+ }
833
+
834
+ /** The Conditional Formatting menu's entries. */
835
+ function cfAction(action: RibbonActionId, context: GridCommandContext): boolean {
836
+ const preset = CF_PRESETS[action]
837
+ if (preset) { cfDialog = { preset, rule: null }; return true }
838
+ switch (action) {
839
+ case 'cf-data-bar': addCf({ kind: 'dataBar', color: DATA_BAR_COLOR }); break
840
+ case 'cf-color-scale-3': addCf({ kind: 'colorScale', colors: COLOR_SCALES['green-yellow-red'] }); break
841
+ case 'cf-color-scale-2': addCf({ kind: 'colorScale', colors: COLOR_SCALES['green-white'] }); break
842
+ case 'cf-icon-set': addCf({ kind: 'iconSet', set: 'arrows' }); break
843
+ case 'cf-clear-selection': {
844
+ const next = removeCf(cfNow(), cfRects())
845
+ if (next.length !== cfNow().length || next.some((rule, i) => rule !== cfNow()[i])) setCf(next)
846
+ break
847
+ }
848
+ case 'cf-clear-sheet': if (cfNow().length) setCf([]); break
849
+ case 'cf-manage': manageRulesOpen = true; return true
850
+ default: return false
851
+ }
852
+ focusSheet(context)
853
+ return true
854
+ }
855
+
856
+ /** Whether `text` may land in (r, c); sets the alert when it may not. */
857
+ function admits(r: number, c: number, text: string): boolean {
858
+ const rule = ruleAt(rulesNow(), r, c)
859
+ if (!rule) return true
860
+ const verdict = checkEntry(rule, text, { row: r, col: c }, validationCtx)
861
+ if (verdict.ok) return true
862
+ pendingAlert = { r, c, text, verdict }
863
+ // The grid's Enter has already moved the cursor on by the time the
864
+ // alert opens; Excel keeps it on the cell that refused, and so does
865
+ // Retry, which reopens that cell.
866
+ const cmd = cmdOf()
867
+ if (cmd) void tick().then(() => { cmd.setActiveCell(r, c); cmd.setSelection(r, c) })
868
+ return false
869
+ }
870
+
871
+ /** Replace the rules of the active sheet, one undo. */
872
+ function setValidation(next: ValidationRule[]) {
873
+ const sheet = wb.active
874
+ const before = doc.get(sheet).validation
875
+ const put = (rules: ValidationRule[]) => {
876
+ doc.get(sheet).validation = rules
877
+ bump()
878
+ changed({ kind: 'validation' })
879
+ }
880
+ put(next)
881
+ cmdOf()?.recordUndo(() => put(before), () => put(next))
882
+ }
883
+
884
+ /** OK in the dialog: one rule over the selection, replacing what was there. */
885
+ function applyValidation(spec: ValidationSpec) {
886
+ const rects = selectedRects(cmdOf()).map(([r1, c1, r2, c2]) => [Math.min(r1, r2), Math.min(c1, c2), Math.max(r1, r2), Math.max(c1, c2)] as const)
887
+ const kept = removeValidation(rulesNow(), rects)
888
+ setValidation([...kept, { id: validationId(), rects, ...spec }])
889
+ }
890
+
891
+ /** Clear All in the dialog: the selection loses its validation. */
892
+ function clearValidation() {
893
+ const rects = selectedRects(cmdOf()).map(([r1, c1, r2, c2]) => [Math.min(r1, r2), Math.min(c1, c2), Math.max(r1, r2), Math.max(c1, c2)] as const)
894
+ const next = removeValidation(rulesNow(), rects)
895
+ if (next.length !== rulesNow().length || next.some((rule, i) => rule !== rulesNow()[i])) setValidation(next)
896
+ }
897
+
898
+ /** The list rule a cell offers a dropdown for, if any. */
899
+ function listRuleAt(r: number, c: number): ValidationRule | undefined {
900
+ const rule = ruleAt(rulesNow(), r, c)
901
+ return rule && rule.allow === 'list' && rule.inCellDropdown ? rule : undefined
902
+ }
903
+
904
+ /** Drop the list down under (r, c). */
905
+ function openListPicker(r: number, c: number) {
906
+ const rule = listRuleAt(r, c)
907
+ const cmd = cmdOf()
908
+ if (!rule || !cmd) return
909
+ if (cellPopover) closeCellPopover()
910
+ cellPopover = { kind: 'list', r, c, choices: listChoices(rule, validationCtx) }
911
+ void tick().then(() => requestAnimationFrame(measureAnchor))
912
+ }
913
+
914
+ /** A choice picked from the list: written as typed, one undo. */
915
+ function pickChoice(choice: string) {
916
+ const pop = cellPopover
917
+ closeCellPopover()
918
+ const cmd = cmdOf()
919
+ if (!pop || !cmd) return
920
+ if (locked(pop.r, pop.c)) { refuse(); return }
921
+ cmd.batch(() => cmd.setCellValue(pop.r, pop.c, choice))
922
+ }
923
+
924
+ /** The alert's Retry: the cell reopens with the entry, as in Excel. */
925
+ function retryEntry() {
926
+ const alert = pendingAlert
927
+ pendingAlert = null
928
+ const cmd = cmdOf()
929
+ if (!alert || !cmd) return
930
+ void tick().then(() => { cmd.startEditing(alert.r, alert.c, alert.text) })
931
+ }
932
+
933
+ /** A Warning's Yes: the entry lands after all. */
934
+ function acceptEntry() {
935
+ const alert = pendingAlert
936
+ pendingAlert = null
937
+ const cmd = cmdOf()
938
+ if (!alert || !cmd) return
939
+ cmd.batch(() => cmd.setCellValue(alert.r, alert.c, balanceParens(alert.text)))
940
+ focusSheet(cmd)
941
+ }
942
+
943
+ /** Protect or unprotect the active sheet, as one undo step. */
944
+ function setProtected(on: boolean, cmd: GridCommandContext) {
945
+ const sheet = wb.active
946
+ const before = doc.get(sheet).protected
947
+ if (before === on) return
948
+ const put = (value: boolean) => {
949
+ doc.get(sheet).protected = value
950
+ bump()
951
+ changed({ kind: 'protection' })
952
+ }
953
+ put(on)
954
+ cmd.recordUndo(() => put(before), () => put(on))
955
+ say(on ? 'Sheet protected. Locked cells can no longer be changed.' : 'Sheet unprotected.')
956
+ }
957
+
958
+ // --- the seams the keyboard layer and the ribbon both act through --------
959
+ /**
960
+ * The format and structure targets are bound to ONE sheet's store, so they
961
+ * are registered again whenever the active sheet changes. Everything else
962
+ * reads `wb.active` at call time and is registered once.
963
+ */
964
+ /**
965
+ * Column widths are per sheet in Excel and per grid here, so the shell
966
+ * keeps a set for each sheet and swaps them on a switch: the widths the
967
+ * user dragged on Orders are put away when Summary comes up and come back
968
+ * with Orders. A sheet never resized reads the `columnWidths` prop.
969
+ */
970
+ function stashWidths(name: string = wb.active) {
971
+ if (!api) return
972
+ doc.get(name).widths = { ...api.getColumnWidths() }
973
+ }
974
+ function applyWidths(name: string = wb.active) {
975
+ if (!api) return
976
+ const saved = doc.get(name).widths
977
+ for (let c = 0; c < colCount; c += 1) {
978
+ const letter = colToLetters(c)
979
+ api.setColumnWidth(letter, saved[letter] ?? columnWidths?.[letter] ?? columnWidth)
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Row heights, the same way: the grid keys a dragged height by row id,
985
+ * and a sheet's row ids are positions (`r4`), so a height dragged on
986
+ * Orders would show on Summary's fourth row. They are put away and
987
+ * brought back with the sheet, and an insert or delete moves them with
988
+ * their rows.
989
+ */
990
+ function ownRowHeights(keep: ReadonlyMap<number, number> = new Map()): Map<number, number> {
991
+ const out = new Map<number, number>()
992
+ if (!api) return out
993
+ for (let r = 0; r < rowCount; r += 1) {
994
+ // A hidden row reports 0, which is its hiding and not its height: the
995
+ // hidden set keeps that, and a 0 saved here would outlive an Unhide.
996
+ // The height it had before it was hidden is what comes back with it.
997
+ if (api.isRowCollapsed(r)) {
998
+ const before = keep.get(r)
999
+ if (before !== undefined) out.set(r, before)
1000
+ continue
1001
+ }
1002
+ const h = api.getRowHeight(r)
1003
+ if (h !== rowHeight) out.set(r, h)
1004
+ }
1005
+ return out
1006
+ }
1007
+ function stashHeights(name: string = wb.active) {
1008
+ if (!api) return
1009
+ doc.get(name).heights = ownRowHeights(doc.get(name).heights)
1010
+ }
1011
+ function applyHeights(name: string = wb.active) {
1012
+ if (!api) return
1013
+ const saved = doc.get(name).heights
1014
+ for (let r = 0; r < rowCount; r += 1) api.setRowHeight(r, saved.get(r) ?? null)
1015
+ }
1016
+
1017
+ /**
1018
+ * Hidden rows and columns, per sheet like the sizes. Excel hides a line
1019
+ * by folding it to nothing and the grid does the same (`setRowCollapsed`,
1020
+ * `setColumnCollapsed`): the line keeps its number or letter, every
1021
+ * reference to it holds, and it takes no room. What is hidden on Orders
1022
+ * is put away with Orders and moves with an insert or delete.
1023
+ */
1024
+ type Hidden = { rows: Set<number>; cols: Set<number> }
1025
+ function ownHidden(): Hidden {
1026
+ const out: Hidden = { rows: new Set(), cols: new Set() }
1027
+ if (!api) return out
1028
+ // The rows the AutoFilter folded are its own, not hidden lines.
1029
+ const filtered = doc.get(wb.active).filterHidden
1030
+ for (let r = 0; r < rowCount; r += 1) if (api.isRowCollapsed(r) && !filtered.has(r)) out.rows.add(r)
1031
+ for (let c = 0; c < colCount; c += 1) if (api.isColumnCollapsed(colToLetters(c))) out.cols.add(c)
1032
+ return out
1033
+ }
1034
+ function applyHidden(hidden: Hidden, filtered: ReadonlySet<number> = new Set()) {
1035
+ if (!api) return
1036
+ for (let r = 0; r < rowCount; r += 1) api.setRowCollapsed(r, hidden.rows.has(r) || filtered.has(r))
1037
+ for (let c = 0; c < colCount; c += 1) api.setColumnCollapsed(colToLetters(c), hidden.cols.has(c))
1038
+ }
1039
+ function stashHidden(name: string = wb.active) {
1040
+ if (!api) return
1041
+ doc.get(name).hidden = ownHidden()
1042
+ }
1043
+ /** The active sheet's sizes and hidden lines, read back from the grid into the document. */
1044
+ function stashLive(name: string = wb.active) {
1045
+ stashWidths(name)
1046
+ stashHeights(name)
1047
+ stashHidden(name)
1048
+ }
1049
+ /** The document's sizes and hidden lines for a sheet, put on the grid. */
1050
+ function applyLive(name: string = wb.active) {
1051
+ applyWidths(name)
1052
+ applyHeights(name)
1053
+ // The AutoFilter's rows are worked out afresh: a restore, a structural
1054
+ // edit or a sheet switch may have moved or changed them.
1055
+ const state = doc.get(name)
1056
+ state.filterHidden = hiddenRowsFor(state.autoFilter, (r, c) => wb.getValue(name, r, c), (r, c) => displayOn(name, r, c))
1057
+ applyHidden(state.hidden, state.filterHidden)
1058
+ }
1059
+
1060
+ /**
1061
+ * Hide or unhide the rows or columns the selection covers, as one undo
1062
+ * step. Unhide reveals what the selection spans, and, when it spans
1063
+ * nothing hidden, the hidden lines right next to it: a right-click on C
1064
+ * with B hidden is the natural way to ask for B back. The active cell on
1065
+ * a line just hidden steps to the next one that shows, as Excel's does.
1066
+ */
1067
+ function hideLines(axis: 'rows' | 'cols', hide: boolean, cmd: GridCommandContext) {
1068
+ if (!api) return
1069
+ if (protectedNow()) { refuse(); return }
1070
+ const count = axis === 'rows' ? rowCount : colCount
1071
+ const isHidden = (i: number) => axis === 'rows' ? api!.isRowCollapsed(i) : api!.isColumnCollapsed(colToLetters(i))
1072
+ const setHidden = (i: number, on: boolean) =>
1073
+ axis === 'rows' ? api!.setRowCollapsed(i, on) : api!.setColumnCollapsed(colToLetters(i), on)
1074
+ const rects = cmd.ranges.length
1075
+ ? cmd.ranges
1076
+ : cmd.activeCell ? [[cmd.activeCell.rowIndex, cmd.activeCell.colIndex, cmd.activeCell.rowIndex, cmd.activeCell.colIndex] as const] : []
1077
+ const targets = new Set<number>()
1078
+ let lo = count, hi = -1
1079
+ for (const [r1, c1, r2, c2] of rects) {
1080
+ const from = axis === 'rows' ? r1 : c1
1081
+ const to = Math.min(axis === 'rows' ? r2 : c2, count - 1)
1082
+ lo = Math.min(lo, from); hi = Math.max(hi, to)
1083
+ for (let i = from; i <= to; i += 1) if (isHidden(i) !== hide) targets.add(i)
1084
+ }
1085
+ if (!hide && targets.size === 0 && hi >= 0) {
1086
+ for (let i = lo - 1; i >= 0 && isHidden(i); i -= 1) targets.add(i)
1087
+ for (let i = hi + 1; i < count && isHidden(i); i += 1) targets.add(i)
1088
+ }
1089
+ // A row the AutoFilter folded is not a hidden row: Unhide leaves it to the filter.
1090
+ if (axis === 'rows' && !hide) for (const r of doc.get(wb.active).filterHidden) targets.delete(r)
1091
+ if (targets.size === 0) {
1092
+ say(hide ? `Nothing to hide` : `Nothing hidden in the selection`)
1093
+ return
1094
+ }
1095
+ const apply = (on: boolean) => { for (const i of targets) setHidden(i, on); stashHidden(); bump(); changed({ kind: 'hidden' }) }
1096
+ apply(hide)
1097
+ cmd.recordUndo(() => apply(!hide), () => apply(hide))
1098
+ if (hide && cmd.activeCell) {
1099
+ const at = axis === 'rows' ? cmd.activeCell.rowIndex : cmd.activeCell.colIndex
1100
+ if (isHidden(at)) {
1101
+ let next = hi + 1
1102
+ while (next < count && isHidden(next)) next += 1
1103
+ if (next >= count) { next = lo - 1; while (next >= 0 && isHidden(next)) next -= 1 }
1104
+ if (next >= 0) {
1105
+ const r = axis === 'rows' ? next : cmd.activeCell.rowIndex
1106
+ const c = axis === 'rows' ? cmd.activeCell.colIndex : next
1107
+ cmd.setActiveCell(r, c)
1108
+ cmd.setSelection(r, c)
1109
+ }
1110
+ }
1111
+ }
1112
+ say(`${targets.size} ${axis === 'rows' ? 'row' : 'column'}${targets.size === 1 ? '' : 's'} ${hide ? 'hidden' : 'shown'}`)
1113
+ }
1114
+
1115
+ let targetsBoundTo = ''
1116
+ function registerSheetTargets() {
1117
+ // Only a real switch swaps the per-sheet state. The mount effect calls
1118
+ // this too, and re-applying the freeze from there re-rendered the grid,
1119
+ // which repainted the shell, which ran the effect again: a loop. The
1120
+ // sheet going away has its live sizes and hidden lines read back into
1121
+ // the document first; the one coming up gets its own put on the grid.
1122
+ if (targetsBoundTo && targetsBoundTo !== wb.active) {
1123
+ if (doc.has(targetsBoundTo)) stashLive(targetsBoundTo)
1124
+ applyLive(wb.active)
1125
+ const cmd = cmdOf()
1126
+ if (cmd) applyFreeze(cmd, doc.get(wb.active).freeze)
1127
+ }
1128
+ targetsBoundTo = wb.active
1129
+ const store = storeFor()
1130
+ setFormatTarget({
1131
+ store, lookup,
1132
+ onChange: () => { bump(); fitWrappedRows(); changed({ kind: 'formats' }) },
1133
+ // Formats hold on a protected sheet where the selection has a locked
1134
+ // cell: Excel's Format Cells greys out there.
1135
+ guard: (rects) => !protectedNow() || !rectsHaveLocked(store, lookup, rects),
1136
+ refused: refuse,
1137
+ })
1138
+ setStructureTarget({
1139
+ // No insert or delete on a protected sheet, as in Excel.
1140
+ canApply: () => !protectedNow(),
1141
+ refused: refuse,
1142
+ getRaw: (r, c) => wb.getRaw(wb.active, r, c),
1143
+ setRaw: (r, c, text) => wb.setRaw(wb.active, r, c, text),
1144
+ // The workbook rewrites references across EVERY sheet, not just this
1145
+ // one, which is the whole reason the edit goes through it rather than
1146
+ // through the local formula rewriter.
1147
+ apply: (edit) => {
1148
+ // A sheet keys its formats, sizes, hidden lines and comments by
1149
+ // position, so they have to move with the cells: a band on row 5
1150
+ // belongs to the cells that were on row 5, which an insert above
1151
+ // has just made row 6. The document moves all of them in one place;
1152
+ // the grid then shows the active sheet's sizes from the document.
1153
+ const sheet = wb.active
1154
+ stashLive(sheet)
1155
+ doc.shift(sheet, edit)
1156
+ wb.applyStructuralEdit(sheet, edit)
1157
+ applyLive(sheet)
1158
+ marquee = null
1159
+ changed({ kind: 'structure', sheet, edit })
1160
+ },
1161
+ // The workbook rewrites every sheet's references (and the defined
1162
+ // names) in applyStructuralEdit; the command must not do it as well.
1163
+ rewritesReferences: true,
1164
+ names: wb.names,
1165
+ format: { store, lookup },
1166
+ onChange: bump,
1167
+ // The whole workbook and every sheet's formats: a structural edit
1168
+ // rewrites references on other sheets too, so nothing smaller is a
1169
+ // faithful "before".
1170
+ // Sizes and hidden lines are the sheet's too: an insert moves them
1171
+ // along with their rows and columns, so the undo has to move them back.
1172
+ // The whole document: a structural edit rewrites references on other
1173
+ // sheets too, so nothing smaller is a faithful "before".
1174
+ snapshot: () => getState(),
1175
+ restore: (state) => {
1176
+ doc.setState(state as SheetState)
1177
+ targetsBoundTo = wb.active
1178
+ registerSheetTargets()
1179
+ applyLive(wb.active)
1180
+ },
1181
+ })
1182
+ }
1183
+
1184
+ $effect(() => {
1185
+ setWorkbook(wb, () => {
1186
+ // A sheet switch moves the whole viewport, so the active cell goes home
1187
+ // rather than pointing at a cell that may not exist on the new sheet.
1188
+ active = { rowIndex: 0, colIndex: 0 }
1189
+ bump()
1190
+ })
1191
+ registerSheetTargets()
1192
+ setFindTarget({
1193
+ getRaw: (r, c) => wb.getRaw(wb.active, r, c),
1194
+ getDisplay: (r, c) => display(r, c).text,
1195
+ // Through the grid, so a replacement is in the history: Replace All
1196
+ // runs in one batch and comes back as one Ctrl+Z.
1197
+ setRaw: (r, c, text) => {
1198
+ const cmd = cmdOf()
1199
+ if (cmd) cmd.setCellValue(r, c, text)
1200
+ else wb.setRaw(wb.active, r, c, text)
1201
+ },
1202
+ // Replace skips a locked cell on a protected sheet; Find still finds it.
1203
+ isEditable: (r, c) => !locked(r, c),
1204
+ onChange: bump,
1205
+ })
1206
+ // Fill translates references the way dragging the handle does in Excel:
1207
+ // =B2 filled one row down becomes =B3. Only formulas move; a literal is
1208
+ // copied through untouched.
1209
+ setFillTranslator((value, delta) =>
1210
+ typeof value === 'string' && value.startsWith('=')
1211
+ ? translateFormula(value, delta.rows, delta.cols)
1212
+ : value,
1213
+ )
1214
+ setFindReplaceHandler((context) => delegate('find-replace', context))
1215
+ setFormatDialogHandler((context) => delegate('format-cells', context))
1216
+ setPasteSpecialHandler((context) => delegate('paste-special', context))
1217
+ // The keys the ribbon's tooltips promise. Ctrl+T falls through unless
1218
+ // the application answers Insert > Table.
1219
+ setRibbonActionHandler((action, context) => {
1220
+ if (action === 'insert-table' && !extras.includes('insert-table')) return false
1221
+ handleAction(action, context)
1222
+ return true
1223
+ })
1224
+ // So AutoSum measures its run against evaluated values: a column of
1225
+ // subtotals is a column of numbers, not a column of "=SUM(...)" strings.
1226
+ setSheetValueProbe((r, c) => wb.getValue(wb.active, r, c))
1227
+ return () => {
1228
+ setWorkbook(null)
1229
+ setFormatTarget(null)
1230
+ setStructureTarget(null)
1231
+ setFindTarget(null)
1232
+ setFillTranslator(null)
1233
+ setSheetValueProbe(null)
1234
+ setFindReplaceHandler(null)
1235
+ setFormatDialogHandler(null)
1236
+ setPasteSpecialHandler(null)
1237
+ setRibbonActionHandler(null)
1238
+ }
1239
+ })
1240
+
1241
+ // --- reading a cell -------------------------------------------------------
1242
+ function raw(r: number, c: number): string {
1243
+ void version
1244
+ return wb.getRaw(wb.active, r, c)
1245
+ }
1246
+
1247
+ function display(r: number, c: number, numFmt?: string): { text: string; color?: string } {
1248
+ void version
1249
+ const text = raw(r, c)
1250
+ if (showFormulas) return { text }
1251
+ const value: CellValue = wb.getValue(wb.active, r, c)
1252
+ if (isError(value)) return { text: value.error, color: 'var(--sg-danger, #dc2626)' }
1253
+ const entry = storeFor().get(`r${r}`, colToLetters(c))
1254
+ const fmt = numFmt ?? entry?.numFmt
1255
+ if (fmt) return compileNumberFormat(fmt).format(value)
1256
+ if (value === '' || value == null) return { text: '' }
1257
+ if (typeof value === 'boolean') return { text: value ? 'TRUE' : 'FALSE' }
1258
+ return { text: String(value) }
1259
+ }
1260
+
1261
+ /**
1262
+ * Excel never shows part of a number: a figure wider than its column
1263
+ * reads as a run of # signs, so a truncated 1,234,567 can never be taken
1264
+ * for 1,234. Text is clipped or spills, as it is in Excel. Measured with
1265
+ * the cell's own font, since a bold or larger figure takes more room.
1266
+ *
1267
+ * A General cell (no number format) shrinks first, as Excel's does: the
1268
+ * decimals go one at a time (5.333333 reads 5.3333, then 5.33), and a
1269
+ * whole part too wide for the column turns scientific (1.76E+04) before
1270
+ * the hashes come. A formatted number never rounds itself: its format
1271
+ * said what to show, so it hashes as soon as that does not fit.
1272
+ */
1273
+ let hashMeasure: CanvasRenderingContext2D | null = null
1274
+ function hashesFor(text: string, c: number, entry: CellFormatEntry | undefined, r = -1, general?: number): string | null {
1275
+ if (!text) return null
1276
+ hashMeasure ??= document.createElement('canvas').getContext('2d')
1277
+ if (!hashMeasure) return null
1278
+ const size = entry?.fontSize ?? 13
1279
+ const family = entry?.fontFamily || (root ? getComputedStyle(root).fontFamily : 'sans-serif')
1280
+ hashMeasure.font = `${entry?.bold ? 'bold ' : ''}${size}px ${family}`
1281
+ const width = (r >= 0 ? cellWidth(r, c) : widthOf(c)) - 9
1282
+ const fits = (s: string) => hashMeasure!.measureText(s).width <= width
1283
+ if (fits(text)) return null
1284
+ if (general !== undefined && Number.isFinite(general)) {
1285
+ const decimals = (text.split('.')[1] ?? '').length
1286
+ for (let d = decimals - 1; d >= 0; d -= 1) {
1287
+ const s = String(Number(general.toFixed(d)))
1288
+ if (fits(s)) return s
1289
+ }
1290
+ for (let m = 5; m >= 0; m -= 1) {
1291
+ const [mantissa, exp] = general.toExponential(m).split('e')
1292
+ const e = Number(exp)
1293
+ const s = `${mantissa}E${e < 0 ? '-' : '+'}${String(Math.abs(e)).padStart(2, '0')}`
1294
+ if (fits(s)) return s
1295
+ }
1296
+ }
1297
+ const hash = hashMeasure.measureText('#').width || 8
1298
+ return '#'.repeat(Math.max(1, Math.floor(width / hash)))
1299
+ }
1300
+
1301
+ const activeRaw = $derived.by(() => {
1302
+ void version
1303
+ return raw(active.rowIndex, active.colIndex)
1304
+ })
1305
+
1306
+ /**
1307
+ * How far a cell's text may run past its right edge, in px; 0 for none.
1308
+ *
1309
+ * Excel lets text spill over EMPTY neighbours and clips it at the first
1310
+ * cell that holds something, so a title in A1 reads in full and a label
1311
+ * next to a number does not. Numbers never spill. The neighbours' widths
1312
+ * come from the grid, so a column the user dragged wider counts at its
1313
+ * real size; before the grid has mounted the configured widths stand in.
1314
+ */
1315
+ function spillWidth(r: number, c: number, value: CellValue, entry: CellFormatEntry | undefined): number {
1316
+ if (typeof value !== 'string' || value === '' || entry?.wrap) return 0
1317
+ // A merged cell is as wide as its merge already; the text stays in it.
1318
+ if (sheetMergeAt(mergesNow(), r, c)) return 0
1319
+ if (entry?.align === 'right' || entry?.align === 'center') return 0
1320
+ // A fill stays inside its cell in Excel; letting the span grow would
1321
+ // drag the colour over the neighbours along with the text.
1322
+ if (entry?.fill) return 0
1323
+ let extra = 0
1324
+ for (let next = c + 1; next < colCount; next += 1) {
1325
+ if (wb.getRaw(wb.active, r, next) !== '') break
1326
+ extra += widthOf(next)
1327
+ }
1328
+ return extra
1329
+ }
1330
+
1331
+ /**
1332
+ * An entry from the formula bar goes through the grid, not straight into
1333
+ * the workbook: `setCellValue` records the write, so Ctrl+Z walks it back
1334
+ * like an in-cell edit, and reports it to `onCellWritten`, where the
1335
+ * workbook takes it. The bar names the cell the edit started in, which is
1336
+ * not the active cell once the user has clicked elsewhere to finish.
1337
+ */
1338
+ function commit(text: string, cell: { rowIndex: number; colIndex: number }, via: 'enter' | 'blur' = 'enter') {
1339
+ if (locked(cell.rowIndex, cell.colIndex)) { refuse(); return }
1340
+ if (!admits(cell.rowIndex, cell.colIndex, text)) return
1341
+ const cmd = cmdOf()
1342
+ if (cmd) {
1343
+ cmd.batch(() => cmd.setCellValue(cell.rowIndex, cell.colIndex, balanceParens(text)))
1344
+ // Enter in the bar hands the sheet back, the cursor one row down, as
1345
+ // Excel does; a blur was a click that has placed the cursor already.
1346
+ if (via === 'enter') {
1347
+ const next = Math.min(cell.rowIndex + 1, rowCount - 1)
1348
+ cmd.setActiveCell(next, cell.colIndex)
1349
+ cmd.setSelection(next, cell.colIndex)
1350
+ cmd.scrollIntoView(next, cell.colIndex)
1351
+ focusSheet(cmd)
1352
+ }
1353
+ return
1354
+ }
1355
+ wb.setRaw(wb.active, cell.rowIndex, cell.colIndex, balanceParens(text))
1356
+ bump()
1357
+ }
1358
+
1359
+ /**
1360
+ * The Name Box's address: B9 and Enter put the cursor on B9 and hand focus
1361
+ * back to the sheet, so the next keystroke edits it, as in Excel. The grid
1362
+ * is told, not the shell's own `active`, which only ever follows the grid;
1363
+ * setting it alone moved the Name Box's label and nothing else.
1364
+ */
1365
+ function goTo(cell: { rowIndex: number; colIndex: number }) {
1366
+ const cmd = cmdOf()
1367
+ if (!cmd) return
1368
+ cmd.setActiveCell(cell.rowIndex, cell.colIndex)
1369
+ cmd.setSelection(cell.rowIndex, cell.colIndex)
1370
+ cmd.scrollIntoView(cell.rowIndex, cell.colIndex)
1371
+ active = cell
1372
+ cmd.focus()
1373
+ }
1374
+
1375
+ /**
1376
+ * The Name Box picked a defined name: select what it refers to, switching
1377
+ * sheets first when it lives on another one. Excel does the same, and a
1378
+ * name that only worked on the sheet it was defined on would not be worth
1379
+ * listing.
1380
+ */
1381
+ async function jumpToName(name: string) {
1382
+ const node = wb.names.resolve(name)
1383
+ if (!node) return
1384
+ const from = node.k === 'ref' ? node.ref : node.k === 'range' ? node.from : null
1385
+ const to = node.k === 'range' ? node.to : from
1386
+ if (!from || from.row === null) return
1387
+ const target = from.sheet ?? wb.active
1388
+ if (target.toLowerCase() !== wb.active.toLowerCase()) {
1389
+ if (!wb.sheets.some((s) => s.toLowerCase() === target.toLowerCase())) return
1390
+ wb.setActive(target)
1391
+ bump()
1392
+ // The grid rebuilds its rows from the new sheet on the next render;
1393
+ // a selection set before that would land on the old ones.
1394
+ await tick()
1395
+ }
1396
+ const cmd = api?.getCommandContext()
1397
+ if (!cmd) return
1398
+ // Both: the selection is the highlighted range, the active cell is the
1399
+ // one the next keystroke edits and the formula bar shows. A jump that
1400
+ // set only the range left the grid's active cell where it was, so the
1401
+ // Name Box read B9 while typing went into A1.
1402
+ cmd.setActiveCell(from.row, from.col)
1403
+ cmd.setSelection(from.row, from.col)
1404
+ if (to && to.row !== null && (to.row !== from.row || to.col !== from.col)) {
1405
+ cmd.extendSelection(to.row, to.col)
1406
+ }
1407
+ cmd.scrollIntoView(from.row, from.col)
1408
+ active = { rowIndex: from.row, colIndex: from.col }
1409
+ // Focus was in the Name Box; typing now goes into the cell just reached.
1410
+ cmd.focus()
1411
+ }
1412
+
1413
+ // --- the status bar -------------------------------------------------------
1414
+ /**
1415
+ * Excel's selection aggregates. Only numbers count toward Sum and Average,
1416
+ * while Count counts every non-empty cell including text, which is exactly
1417
+ * how Excel's own status bar splits them.
1418
+ */
1419
+ const aggregate = $derived.by(() => {
1420
+ void version
1421
+ const rects = selection.length
1422
+ ? selection
1423
+ : [[active.rowIndex, active.colIndex, active.rowIndex, active.colIndex] as const]
1424
+ let count = 0
1425
+ let numeric = 0
1426
+ let sum = 0
1427
+ for (const [minRow, minCol, maxRow, maxCol] of rects) {
1428
+ for (let r = minRow; r <= maxRow; r += 1) {
1429
+ for (let c = minCol; c <= maxCol; c += 1) {
1430
+ const value = wb.getValue(wb.active, r, c)
1431
+ if (value === '' || value == null || isError(value)) continue
1432
+ count += 1
1433
+ if (typeof value === 'number') { numeric += 1; sum += value }
1434
+ }
1435
+ }
1436
+ }
1437
+ return { count, numeric, sum, average: numeric ? sum / numeric : null }
1438
+ })
1439
+
1440
+ /** Excel's status bar prints with thousands separators and two decimals at most. */
1441
+ const money = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 2 })
1442
+
1443
+ /**
1444
+ * Excel shades the header of every column and row the selection touches.
1445
+ * The row side is CSS (the row number cell can see its own row's cells);
1446
+ * the column headers cannot see the cells beneath them, so the letters
1447
+ * touched by the selection are marked here, after the grid has painted.
1448
+ */
1449
+ let root = $state<HTMLDivElement | null>(null)
1450
+ $effect(() => {
1451
+ void version
1452
+ const rects = selection.length
1453
+ ? selection
1454
+ : [[active.rowIndex, active.colIndex, active.rowIndex, active.colIndex] as const]
1455
+ const cols = new Set<string>()
1456
+ // The grid selects a merge whole, so the shading covers its columns too.
1457
+ for (const rect of rects) {
1458
+ const [, minCol, , maxCol] = expandToMerges(mergesNow(), [rect[0], rect[1], rect[2], rect[3]])
1459
+ for (let c = minCol; c <= maxCol; c += 1) cols.add(colToLetters(c))
1460
+ }
1461
+ const host = root
1462
+ if (!host) return
1463
+ // Excel paints the letter of a column selected whole (and the number of
1464
+ // a row selected whole) in the accent, white on green, and only tints
1465
+ // one the selection merely touches.
1466
+ const wholeCols = new Set<string>()
1467
+ const wholeRows = new Set<number>()
1468
+ for (const rect of rects) {
1469
+ const [r1, c1, r2, c2] = expandToMerges(mergesNow(), [rect[0], rect[1], rect[2], rect[3]])
1470
+ if (r1 === 0 && r2 >= rowCount - 1) for (let c = c1; c <= c2; c += 1) wholeCols.add(colToLetters(c))
1471
+ if (c1 === 0 && c2 >= colCount - 1) for (let r = r1; r <= r2; r += 1) wholeRows.add(r)
1472
+ }
1473
+ const paint = () => {
1474
+ for (const th of host.querySelectorAll<HTMLElement>('th[data-svgrid-header-col]')) {
1475
+ const letter = th.dataset.svgridHeaderCol ?? ''
1476
+ th.classList.toggle('sheet-col-selected', cols.has(letter))
1477
+ th.classList.toggle('sheet-col-whole', wholeCols.has(letter))
1478
+ }
1479
+ for (const td of host.querySelectorAll<HTMLElement>('td.sv-grid-row-number-cell')) {
1480
+ const row = td.parentElement?.querySelector<HTMLElement>('td[data-svgrid-row]')?.dataset.svgridRow
1481
+ td.classList.toggle('sheet-row-whole', row !== undefined && wholeRows.has(Number(row)))
1482
+ }
1483
+ }
1484
+ const frame = requestAnimationFrame(paint)
1485
+ return () => cancelAnimationFrame(frame)
1486
+ })
1487
+
1488
+ // --- the grid -------------------------------------------------------------
1489
+ const features = tableFeatures({})
1490
+
1491
+ const columns = $derived.by<GridColumns<SheetRow>>(() =>
1492
+ Array.from({ length: colCount }, (_, c) => ({
1493
+ id: colToLetters(c),
1494
+ // `field` is what makes the cell writable and readable by everything
1495
+ // else: the inline editor, the fill handle, the clipboard, and the
1496
+ // command context the shortcuts and the ribbon run against.
1497
+ field: colToLetters(c),
1498
+ header: colToLetters(c),
1499
+ width: columnWidths?.[colToLetters(c)] ?? columnWidth,
1500
+ headerAlign: 'center' as const,
1501
+ // A locked cell on a protected sheet is read-only to the grid's own
1502
+ // editor, fill handle, Delete and paste, and to every command that
1503
+ // asks `cmd.canEdit`.
1504
+ // A covered cell of a merge is not a cell to write into either.
1505
+ editable: (ctx: { row: { original: SheetRow } }) => !locked(ctx.row.original.index, c) && !covered(ctx.row.original.index, c),
1506
+ // Hovering a commented cell shows the comment, as Excel does.
1507
+ tooltip: (ctx: { row: { original: SheetRow } }) => commentAt(notesNow(), ctx.row.original.index, c) ?? null,
1508
+ // A typed entry is checked against the cell's validation rule before
1509
+ // it lands; handing the old value back is how the grid writes nothing.
1510
+ valueParser: (params: { newValue: unknown; oldValue: unknown; rawInput: string; data: SheetRow }) =>
1511
+ admits(params.data.index, c, params.rawInput) ? params.newValue : params.oldValue,
1512
+ // Editing a cell edits its FORMULA, the way F2 does in Excel, which is
1513
+ // why the editor is plain text over the raw value rather than a typed
1514
+ // editor over the computed one.
1515
+ editorType: 'text' as const,
1516
+ // Alt+Enter puts a line break in the cell, as in Excel; Enter still
1517
+ // commits.
1518
+ editorMultiline: true,
1519
+ cell: (ctx: { row: { original: SheetRow } }) =>
1520
+ renderSnippet(Cell, { r: ctx.row.original.index, c }),
1521
+ })),
1522
+ )
1523
+
1524
+ function cmdOf(): GridCommandContext | null {
1525
+ return api ? api.getCommandContext() : null
1526
+ }
1527
+
1528
+ /** Ribbon actions this shell can answer without a dialog. */
1529
+ function handleAction(action: RibbonActionId, context: GridCommandContext) {
1530
+ switch (action) {
1531
+ case 'recalculate':
1532
+ wb.recalculate()
1533
+ bump()
1534
+ return
1535
+ case 'toggle-formulas':
1536
+ showFormulas = !showFormulas
1537
+ bump()
1538
+ return
1539
+ case 'toggle-gridlines':
1540
+ if (onAction?.(action, context) === true) return
1541
+ gridlinesOn = !gridlinesOn
1542
+ focusSheet(context)
1543
+ return
1544
+ case 'toggle-formula-bar':
1545
+ if (onAction?.(action, context) === true) return
1546
+ formulaBarOn = !formulaBarOn
1547
+ focusSheet(context)
1548
+ return
1549
+ case 'toggle-headings':
1550
+ if (onAction?.(action, context) === true) return
1551
+ headingsOn = !headingsOn
1552
+ focusSheet(context)
1553
+ return
1554
+ case 'toggle-ribbon':
1555
+ if (onAction?.(action, context) === true) return
1556
+ ribbonCollapsed = !ribbonCollapsed
1557
+ focusSheet(context)
1558
+ return
1559
+ case 'toggle-filter':
1560
+ if (onAction?.(action, context) === true) return
1561
+ toggleAutoFilter(context)
1562
+ focusSheet(context)
1563
+ return
1564
+ case 'sort-asc':
1565
+ case 'sort-desc':
1566
+ if (onAction?.(action, context) === true) return
1567
+ sortRegion(action === 'sort-asc' ? 'asc' : 'desc')
1568
+ return
1569
+ case 'paste-values':
1570
+ case 'paste-formulas':
1571
+ case 'paste-formats':
1572
+ case 'paste-transpose':
1573
+ if (onAction?.(action, context) === true) return
1574
+ pasteSpecial(
1575
+ action === 'paste-transpose' ? { transpose: true } : { what: action.slice('paste-'.length) as PasteWhat },
1576
+ )
1577
+ return
1578
+ case 'format-painter':
1579
+ if (onAction?.(action, context) === true) return
1580
+ if (painter) painter = null
1581
+ else armPainter()
1582
+ focusSheet(context)
1583
+ return
1584
+ case 'row-height':
1585
+ case 'column-width':
1586
+ if (onAction?.(action, context) === true) return
1587
+ openSizeDialog(action === 'row-height' ? 'rows' : 'cols')
1588
+ return
1589
+ case 'protect-sheet':
1590
+ case 'unprotect-sheet':
1591
+ if (onAction?.(action, context) === true) return
1592
+ setProtected(action === 'protect-sheet', context)
1593
+ focusSheet(context)
1594
+ return
1595
+ case 'new-comment':
1596
+ case 'edit-comment':
1597
+ if (onAction?.(action, context) === true) return
1598
+ openCommentEditor(active.rowIndex, active.colIndex)
1599
+ return
1600
+ case 'delete-comment':
1601
+ if (onAction?.(action, context) === true) return
1602
+ if (cellPopover) closeCellPopover(false)
1603
+ if (commentAt(notesNow(), active.rowIndex, active.colIndex) !== undefined) setComment(active.rowIndex, active.colIndex, '')
1604
+ focusSheet(context)
1605
+ return
1606
+ case 'prev-comment':
1607
+ case 'next-comment':
1608
+ if (onAction?.(action, context) === true) return
1609
+ walkComments(action === 'next-comment' ? 1 : -1)
1610
+ return
1611
+ case 'toggle-comments':
1612
+ if (onAction?.(action, context) === true) return
1613
+ showComments = !showComments
1614
+ focusSheet(context)
1615
+ return
1616
+ case 'open-list':
1617
+ if (onAction?.(action, context) === true) return
1618
+ // Alt+Down on an AutoFilter header cell drops the filter menu, as in
1619
+ // Excel; anywhere else it drops the cell's validation list.
1620
+ if (filterArrowAt(active.rowIndex, active.colIndex)) { openFilterMenu(active.colIndex); return }
1621
+ openListPicker(active.rowIndex, active.colIndex)
1622
+ return
1623
+ case 'cf-greater': case 'cf-less': case 'cf-between': case 'cf-equal': case 'cf-text': case 'cf-duplicates':
1624
+ case 'cf-top10': case 'cf-bottom10': case 'cf-above-average': case 'cf-below-average':
1625
+ case 'cf-data-bar': case 'cf-color-scale-3': case 'cf-color-scale-2': case 'cf-icon-set':
1626
+ case 'cf-clear-selection': case 'cf-clear-sheet': case 'cf-manage':
1627
+ if (onAction?.(action, context) === true) return
1628
+ cfAction(action, context)
1629
+ return
1630
+ case 'merge-center':
1631
+ case 'merge-across':
1632
+ case 'merge-cells':
1633
+ if (onAction?.(action, context) === true) return
1634
+ runMerge(action === 'merge-center' ? 'center' : action === 'merge-across' ? 'across' : 'cells', context)
1635
+ return
1636
+ case 'unmerge-cells':
1637
+ if (onAction?.(action, context) === true) return
1638
+ runUnmerge(context)
1639
+ return
1640
+ case 'toggle-lock': {
1641
+ if (onAction?.(action, context) === true) return
1642
+ // Excel's Lock Cell: every cell of the selection takes the state
1643
+ // the active cell does not have. Refused on a protected sheet, as
1644
+ // any format is.
1645
+ const wasLocked = isLocked(storeFor().get(`r${active.rowIndex}`, colToLetters(active.colIndex)))
1646
+ applyFormat(context, { locked: wasLocked ? false : undefined })
1647
+ focusSheet(context)
1648
+ return
1649
+ }
1650
+ case 'autofit-rows':
1651
+ case 'autofit-columns':
1652
+ if (onAction?.(action, context) === true) return
1653
+ autofitLines(action === 'autofit-rows' ? 'rows' : 'cols', context)
1654
+ focusSheet(context)
1655
+ return
1656
+ case 'hide-rows':
1657
+ case 'hide-columns':
1658
+ case 'unhide-rows':
1659
+ case 'unhide-columns': {
1660
+ if (onAction?.(action, context) === true) return
1661
+ hideLines(action.endsWith('rows') ? 'rows' : 'cols', action.startsWith('hide'), context)
1662
+ focusSheet(context)
1663
+ return
1664
+ }
1665
+ case 'freeze-panes':
1666
+ case 'freeze-top-row':
1667
+ case 'freeze-first-column':
1668
+ case 'unfreeze-panes': {
1669
+ if (onAction?.(action, context) === true) return
1670
+ // Excel keeps the panes per sheet: what is frozen on Orders says
1671
+ // nothing about Summary, and comes back with Orders.
1672
+ const before = doc.get(wb.active).freeze
1673
+ const put = (state: FreezeState) => {
1674
+ applyFreeze(context, state)
1675
+ doc.get(wb.active).freeze = state
1676
+ changed({ kind: 'freeze' })
1677
+ }
1678
+ const state = action === 'freeze-panes'
1679
+ ? freezeAtActiveCell(context)
1680
+ : action === 'freeze-top-row' ? freezeTopRow(context)
1681
+ : action === 'freeze-first-column' ? freezeFirstColumn(context)
1682
+ : applyFreeze(context, { rows: 0, cols: 0 })
1683
+ doc.get(wb.active).freeze = state
1684
+ changed({ kind: 'freeze' })
1685
+ context.recordUndo(() => put(before), () => put(state))
1686
+ focusSheet(context)
1687
+ return
1688
+ }
1689
+ default:
1690
+ delegate(action, context)
1691
+ }
1692
+ }
1693
+
1694
+ /** The ribbon leaves off what nobody answers. */
1695
+ const without = $derived.by<RibbonActionId[]>(() => {
1696
+ const all: Array<'insert-table' | 'insert-chart'> = ['insert-table', 'insert-chart']
1697
+ const out: RibbonActionId[] = all.filter((a) => !extras.includes(a))
1698
+ // Protect Sheet and Unprotect Sheet share one slot on the Review tab.
1699
+ out.push(isProtected ? 'protect-sheet' : 'unprotect-sheet')
1700
+ return out
1701
+ })
1702
+
1703
+ // --- Sort A to Z / Z to A ---------------------------------------------------
1704
+ /**
1705
+ * Excel's Data > Sort A to Z: the rows of the current region, or of the
1706
+ * selection when it is a range, ordered by the active cell's column.
1707
+ * Numbers come before text and blanks stay last either way; a header row
1708
+ * (text over numbers) stays where it is. Values, formulas and formats
1709
+ * move together, as one undo. Formulas are moved as they are, which is
1710
+ * what Excel does too.
1711
+ */
1712
+ function sortRegion(direction: 'asc' | 'desc', over?: { rect: Rect; keyCol: number }) {
1713
+ const cmd = cmdOf()
1714
+ const target = getFormatTarget()
1715
+ if (!cmd || !cmd.activeCell || !target) return
1716
+ const last = selection[selection.length - 1]
1717
+ const isRange = last && (last[0] !== last[2] || last[1] !== last[3])
1718
+ const rect = over ? over.rect : isRange ? last : currentRegion(gridOf(cmd), { row: active.rowIndex, col: active.colIndex })
1719
+ const r1 = rect[0]
1720
+ const c1 = rect[1]
1721
+ const r2 = Math.min(rect[2], Math.max(wb.rowCount(wb.active) - 1, r1))
1722
+ const c2 = Math.min(rect[3], Math.max(wb.colCount(wb.active) - 1, c1))
1723
+ if (r2 <= r1) return
1724
+ if (protectedNow()) { refuse(); return }
1725
+ if (sortBlockedByMerges(mergesNow(), [r1, c1, r2, c2])) {
1726
+ say('To do this, all the merged cells need to be the same size.')
1727
+ return
1728
+ }
1729
+ const keyCol = Math.min(Math.max(over?.keyCol ?? active.colIndex, c1), c2)
1730
+ const valueAt = (r: number, c: number) => wb.getValue(wb.active, r, c)
1731
+ const isText = (v: unknown) => typeof v === 'string' && v !== ''
1732
+ // An AutoFilter's region has its header in row 1 by definition.
1733
+ const headerRow = over ? true : isText(valueAt(r1, keyCol)) && !isText(valueAt(r1 + 1, keyCol)) && !isBlankValue(valueAt(r1 + 1, keyCol))
1734
+ const start = headerRow ? r1 + 1 : r1
1735
+ if (r2 <= start) return
1736
+ const rows = Array.from({ length: r2 - start + 1 }, (_, i) => start + i)
1737
+ const rank = (v: unknown): [number, number | string] => {
1738
+ if (isBlankValue(v)) return [3, '']
1739
+ if (typeof v === 'number') return [0, v]
1740
+ if (typeof v === 'boolean') return [2, v ? 1 : 0]
1741
+ if (isError(v as CellValue)) return [2, 0]
1742
+ return [1, String(v).toLowerCase()]
1743
+ }
1744
+ const order = rows.slice().sort((a, b) => {
1745
+ const [ka, va] = rank(valueAt(a, keyCol))
1746
+ const [kb, vb] = rank(valueAt(b, keyCol))
1747
+ if (ka !== kb) return ka === 3 ? 1 : kb === 3 ? -1 : direction === 'asc' ? ka - kb : kb - ka
1748
+ const cmp = typeof va === 'number' && typeof vb === 'number' ? va - vb : String(va).localeCompare(String(vb))
1749
+ return direction === 'asc' ? cmp : -cmp
1750
+ })
1751
+ if (order.every((r, i) => r === rows[i])) return
1752
+ const store = target.store
1753
+ const before = rows.map((r) => ({
1754
+ raw: Array.from({ length: c2 - c1 + 1 }, (_, i) => wb.getRaw(wb.active, r, c1 + i)),
1755
+ formats: Array.from({ length: c2 - c1 + 1 }, (_, i) => store.get(`r${r}`, colToLetters(c1 + i))),
1756
+ }))
1757
+ cmd.batch(() => {
1758
+ withFormatUndo(cmd, target, [[start, c1, r2, c2]], () => {
1759
+ order.forEach((source, i) => {
1760
+ const row = start + i
1761
+ const from = before[source - start]!
1762
+ for (let k = 0; k <= c2 - c1; k += 1) {
1763
+ const c = c1 + k
1764
+ cmd.setCellValue(row, c, from.raw[k])
1765
+ const one = [[row, c, row, c] as const]
1766
+ store.clear(one, target.lookup)
1767
+ if (from.formats[k]) store.set(one, from.formats[k]!, target.lookup)
1768
+ }
1769
+ })
1770
+ })
1771
+ // A one-row merge in the region rides with its row.
1772
+ if (mergesIn(mergesNow(), [[start, c1, r2, c2]]).length) {
1773
+ const rowFor = (oldRow: number) => { const i = order.indexOf(oldRow); return i < 0 ? oldRow : start + i }
1774
+ setMerges(reorderMerges(mergesNow(), [start, c1, r2, c2], rowFor))
1775
+ }
1776
+ })
1777
+ cmd.setSelection(r1, c1)
1778
+ cmd.extendSelection(r2, c2)
1779
+ bump()
1780
+ fitWrappedRows()
1781
+ }
1782
+
1783
+ // --- what the Data tab's dialogs report -------------------------------------
1784
+ /** A sentence in the status bar's Ready slot for a few seconds, the way
1785
+ * Excel's dialogs end on one ("3 duplicate values found and removed"). */
1786
+ let statusMessage = $state<string | null>(null)
1787
+ let statusTimer: ReturnType<typeof setTimeout> | null = null
1788
+ function say(message: string) {
1789
+ statusMessage = message
1790
+ if (statusTimer) clearTimeout(statusTimer)
1791
+ statusTimer = setTimeout(() => (statusMessage = null), 8000)
1792
+ }
1793
+
1794
+ // --- the dialogs ----------------------------------------------------------
1795
+ /**
1796
+ * Find and Replace, Paste Special, Format Cells and Insert Function are the
1797
+ * shell's own. The consumer hears about every action first and can take
1798
+ * one over by returning true; otherwise the dialog opens here, so Ctrl+1
1799
+ * and the launcher in the corner of the Font group do what Excel's do
1800
+ * without the application writing a dialog.
1801
+ */
1802
+ let findOpen = $state(false)
1803
+ let pasteSpecialOpen = $state(false)
1804
+ let formatCellsOpen = $state(false)
1805
+ let insertFunctionOpen = $state(false)
1806
+ let nameManagerOpen = $state(false)
1807
+ let goalSeekOpen = $state(false)
1808
+ let textToColumnsOpen = $state(false)
1809
+ let removeDuplicatesOpen = $state(false)
1810
+
1811
+ function delegate(action: RibbonActionId, context: GridCommandContext) {
1812
+ if (onAction?.(action, context) === true) return
1813
+ switch (action) {
1814
+ case 'find-replace': findOpen = true; return
1815
+ case 'paste-special': pasteSpecialOpen = true; return
1816
+ case 'format-cells': formatCellsOpen = true; return
1817
+ case 'insert-function': insertFunctionOpen = true; return
1818
+ case 'name-manager': nameManagerOpen = true; return
1819
+ case 'goal-seek': goalSeekOpen = true; return
1820
+ case 'text-to-columns': textToColumnsOpen = true; return
1821
+ case 'remove-duplicates': removeDuplicatesOpen = true; return
1822
+ case 'data-validation': dataValidationOpen = true; return
1823
+ }
1824
+ }
1825
+
1826
+ /** A dialog wrote and closed: repaint, say what it did, take focus back. */
1827
+ function afterDialog(message?: string) {
1828
+ bump()
1829
+ if (message) say(message)
1830
+ const c = cmdOf()
1831
+ if (c) focusSheet(c)
1832
+ }
1833
+
1834
+ /** The active cell's format, what Format Cells opens on. */
1835
+ const activeEntry = $derived.by(() => {
1836
+ void version
1837
+ return storeFor().get(`r${active.rowIndex}`, colToLetters(active.colIndex))
1838
+ })
1839
+ const activeValue = $derived.by(() => {
1840
+ void version
1841
+ return wb.getValue(wb.active, active.rowIndex, active.colIndex)
1842
+ })
1843
+ /** The rule at the active cell, what Data Validation opens on. */
1844
+ const activeRule = $derived.by(() => { void version; return ruleAt(rulesNow(), active.rowIndex, active.colIndex) })
1845
+ /** The selection as A1, for the dialog's heading. */
1846
+ const selectionAddress = $derived.by(() => {
1847
+ void version
1848
+ void selection
1849
+ void active
1850
+ const rects = selectedRects()
1851
+ return rects.map(([r1, c1, r2, c2]) => {
1852
+ const a = `${colToLetters(Math.min(c1, c2))}${Math.min(r1, r2) + 1}`
1853
+ const b = `${colToLetters(Math.max(c1, c2))}${Math.max(r1, r2) + 1}`
1854
+ return a === b ? a : `${a}:${b}`
1855
+ }).join(', ')
1856
+ })
1857
+ /** Whether the selection mixes locked and unlocked cells, for the Protection tab. */
1858
+ const mixedLocked = $derived.by(() => {
1859
+ void version
1860
+ void selection
1861
+ void active
1862
+ return rectsMixLocked(storeFor(), lookup, selectedRects())
1863
+ })
1864
+
1865
+ function applyFormatCells(patch: CellFormatEntry, border: BorderPreset | null) {
1866
+ const cmd = cmdOf()
1867
+ if (!cmd) return
1868
+ cmd.batch(() => {
1869
+ if (Object.keys(patch).length) applyFormat(cmd, patch)
1870
+ if (border) applyBorders(cmd, border)
1871
+ })
1872
+ bump()
1873
+ focusSheet(cmd)
1874
+ }
1875
+
1876
+ /** Excel's Insert Function: the cell starts on =NAME( and the caret waits inside. */
1877
+ function insertFunction(name: string) {
1878
+ const cmd = cmdOf()
1879
+ if (!cmd) return
1880
+ const { rowIndex, colIndex } = active
1881
+ // After the dialog has closed and given focus back: an editor opened
1882
+ // before that would lose the focus to the dialog's restore.
1883
+ void tick().then(() => cmd.startEditing(rowIndex, colIndex, `=${name}(`))
1884
+ }
1885
+
1886
+ /**
1887
+ * Land a block of cells on the selection. Ctrl+V, Paste Special and a
1888
+ * paste from another application all come through here, so they agree:
1889
+ * a block that fits the selection a whole number of times is repeated
1890
+ * over it (one copied cell fills the range, as in Excel), otherwise it
1891
+ * lands at the selection's top-left corner and keeps its shape. `origin`
1892
+ * is where a copy came from, so its formulas move with the paste; a cut
1893
+ * block is moved rather than translated and a foreign one has nowhere to
1894
+ * have moved from, so both pass null. Values and formats are one undo.
1895
+ */
1896
+ function pasteBlock(grid: ClipboardGrid, origin: { row: number; col: number } | null, opts: PasteSpecialOptions): boolean {
1897
+ const cmd = cmdOf()
1898
+ const target = getFormatTarget()
1899
+ if (!cmd || !target) return false
1900
+ const last = selection[selection.length - 1]
1901
+ const dest = {
1902
+ row: last ? Math.min(last[0], last[2]) : active.rowIndex,
1903
+ col: last ? Math.min(last[1], last[3]) : active.colIndex,
1904
+ }
1905
+ const fill = last
1906
+ ? { rows: Math.abs(last[2] - last[0]) + 1, cols: Math.abs(last[3] - last[1]) + 1 }
1907
+ : undefined
1908
+ const plan = planPaste(grid, dest, opts, origin, fill)
1909
+ .filter((entry) => entry.row < rowCount && entry.col < colCount)
1910
+ if (!plan.length) return false
1911
+ // A protected sheet refuses the whole paste when it reaches a locked
1912
+ // cell, as Excel does; handled, so the grid does not paste the text.
1913
+ if (protectedNow() && plan.some((entry) => locked(entry.row, entry.col))) { refuse(); return true }
1914
+ const landing = plan.filter((entry) => !covered(entry.row, entry.col))
1915
+ const rects: Array<readonly [number, number, number, number]> = []
1916
+ for (const entry of landing) rects.push([entry.row, entry.col, entry.row, entry.col])
1917
+ pasting = true
1918
+ try {
1919
+ cmd.batch(() => {
1920
+ withFormatUndo(cmd, target, rects, () => {
1921
+ for (const entry of landing) {
1922
+ const at = { row: entry.row, col: entry.col }
1923
+ const decision = resolvePasteCell(entry.source, wb.getValue(wb.active, entry.row, entry.col), opts, entry.offset, at)
1924
+ if (decision.kind === 'skip') continue
1925
+ const one = [[entry.row, entry.col, entry.row, entry.col] as const]
1926
+ if (decision.kind === 'value' || decision.kind === 'both') cmd.setCellValue(entry.row, entry.col, decision.value)
1927
+ if (decision.kind === 'format' || decision.kind === 'both') {
1928
+ if (decision.format) target.store.set(one, decision.format, target.lookup)
1929
+ else target.store.clear(one, target.lookup)
1930
+ }
1931
+ }
1932
+ })
1933
+ })
1934
+ } finally {
1935
+ pasting = false
1936
+ }
1937
+ // A cut is moved once; Excel's ants go with it.
1938
+ if (origin === null) marquee = null
1939
+ bump()
1940
+ return true
1941
+ }
1942
+
1943
+ function pasteSpecial(opts: PasteSpecialOptions) {
1944
+ const cmd = cmdOf()
1945
+ const block = copied
1946
+ if (!cmd || !block) return
1947
+ if (pasteBlock(copiedGrid(block, 'plain'), block.cut ? null : block.origin, opts)) focusSheet(cmd)
1948
+ }
1949
+
1950
+ /** A dialog took focus; the sheet gets it back once the dialog has gone. */
1951
+ function focusSheet(cmd: GridCommandContext) {
1952
+ void tick().then(() => cmd.focus())
1953
+ }
1954
+
1955
+ const activeActions = $derived.by<ReadonlyArray<RibbonActionId>>(() => {
1956
+ // Under `version`: the filter and the lock live in the document, which
1957
+ // is not state of its own, and a derived that only reads another derived
1958
+ // (activeEntry) is not re-run while that one's value stays the same.
1959
+ // Under `selection` too: the ribbon re-reads every toggle (Bold on a
1960
+ // range that is all bold) when this array changes, and a Shift+Arrow
1961
+ // moves the range without moving the active cell.
1962
+ void version
1963
+ void selection
1964
+ const on: RibbonActionId[] = []
1965
+ if (showFormulas) on.push('toggle-formulas')
1966
+ if (autoFilterNow()) on.push('toggle-filter')
1967
+ if (painter) on.push('format-painter')
1968
+ // Lock Cell lights while the active cell is locked, which every cell is
1969
+ // until unlocked: Excel's menu reads the same way.
1970
+ if (isLocked(activeEntry)) on.push('toggle-lock')
1971
+ if (showComments) on.push('toggle-comments')
1972
+ if (gridlinesOn) on.push('toggle-gridlines')
1973
+ if (formulaBarOn) on.push('toggle-formula-bar')
1974
+ if (headingsOn) on.push('toggle-headings')
1975
+ // Merge & Center lights while the active cell is merged, as in Excel.
1976
+ if (sheetMergeAt(mergesNow(), active.rowIndex, active.colIndex)) on.push('merge-center')
1977
+ return on
1978
+ })
1979
+
1980
+ // --- Format Painter ---------------------------------------------------------
1981
+ /**
1982
+ * Excel's brush: the formats of the selection are picked up, the cursor
1983
+ * becomes a brush, and the next selection takes them: a single cell takes
1984
+ * the whole block from that corner, a range has the block tiled over it,
1985
+ * and a destination cell whose source had no format loses its own. One
1986
+ * undo. Escape, or the button again, puts the brush down.
1987
+ */
1988
+ type Painter = { rows: number; cols: number; entries: Array<Array<CellFormatEntry | undefined>> }
1989
+ let painter = $state<Painter | null>(null)
1990
+ function armPainter() {
1991
+ const target = getFormatTarget()
1992
+ if (!target) return
1993
+ const last = selection[selection.length - 1]
1994
+ const [r1, c1, r2, c2] = last ?? [active.rowIndex, active.colIndex, active.rowIndex, active.colIndex]
1995
+ const entries: Painter['entries'] = []
1996
+ for (let r = r1; r <= r2; r += 1) {
1997
+ const line: Array<CellFormatEntry | undefined> = []
1998
+ for (let c = c1; c <= c2; c += 1) {
1999
+ const rowId = target.lookup.rowIdAt(r)
2000
+ const columnId = target.lookup.columnIdAt(c)
2001
+ line.push(rowId != null && columnId != null ? target.store.get(rowId, columnId) : undefined)
2002
+ }
2003
+ entries.push(line)
2004
+ }
2005
+ painter = { rows: r2 - r1 + 1, cols: c2 - c1 + 1, entries }
2006
+ say('Select where to paste the format, or press Esc')
2007
+ }
2008
+ function paintFormats() {
2009
+ const src = painter
2010
+ const cmd = cmdOf()
2011
+ const target = getFormatTarget()
2012
+ painter = null
2013
+ if (!src || !cmd || !target) return
2014
+ const last = selection[selection.length - 1]
2015
+ let [r1, c1, r2, c2] = last ?? [active.rowIndex, active.colIndex, active.rowIndex, active.colIndex]
2016
+ if (r1 === r2 && c1 === c2) {
2017
+ r2 = Math.min(r1 + src.rows - 1, rowCount - 1)
2018
+ c2 = Math.min(c1 + src.cols - 1, colCount - 1)
2019
+ }
2020
+ const rect = [r1, c1, r2, c2] as const
2021
+ withFormatUndo(cmd, target, [rect], () => {
2022
+ target.store.clear([rect], target.lookup)
2023
+ for (let r = r1; r <= r2; r += 1) {
2024
+ for (let c = c1; c <= c2; c += 1) {
2025
+ const entry = src.entries[(r - r1) % src.rows]?.[(c - c1) % src.cols]
2026
+ if (entry) target.store.set([[r, c, r, c]], entry, target.lookup)
2027
+ }
2028
+ }
2029
+ })
2030
+ bump()
2031
+ fitWrappedRows()
2032
+ statusMessage = null
2033
+ }
2034
+ /** The brush lands on the release of the selecting click or drag. */
2035
+ function onSheetPointerUp(event: PointerEvent) {
2036
+ if (!painter) return
2037
+ const el = event.target as HTMLElement | null
2038
+ if (!el?.closest('td.sv-grid-cell')) return
2039
+ // After the grid has taken the click: the selection it sets is what
2040
+ // the brush paints.
2041
+ setTimeout(paintFormats, 0)
2042
+ }
2043
+ function onSheetKeyDownCapture(event: KeyboardEvent) {
2044
+ acceptSuggestion(event)
2045
+ if (protectedNow() && !editorOf(event.target) && wouldEdit(event) && locked(active.rowIndex, active.colIndex)) refuse()
2046
+ if (event.key === 'Escape' && marquee && !editorOf(event.target)) marquee = null
2047
+ // Excel's Enter while the ants are up: paste the block once, here, and
2048
+ // leave copy mode. Ctrl+V pastes and keeps the ants for the next paste;
2049
+ // Enter is the key that pastes and drops them.
2050
+ if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
2051
+ && marquee && marquee.sheet === wb.active && copied && !editorOf(event.target) && !painter) {
2052
+ event.preventDefault()
2053
+ event.stopPropagation()
2054
+ const block = copied
2055
+ pasteBlock(copiedGrid(block, 'plain'), block.cut ? null : block.origin, { what: 'all' })
2056
+ marquee = null
2057
+ return
2058
+ }
2059
+ if (!painter) return
2060
+ if (event.key === 'Escape') { painter = null; statusMessage = null; event.preventDefault(); return }
2061
+ if (event.key === 'Enter' && !editorOf(event.target)) { event.preventDefault(); paintFormats() }
2062
+ }
2063
+
2064
+ /**
2065
+ * One funnel for every write into the grid, whoever made it.
2066
+ *
2067
+ * Inline edits, the fill handle, paste and each sheet command all land in
2068
+ * the grid's own `writeCellRaw`, which reports here. Catching them in one
2069
+ * place is what keeps the workbook authoritative: without it a fill would
2070
+ * update the projection and leave the engine holding the old values, so
2071
+ * the totals would silently stop matching the cells they add up.
2072
+ */
2073
+ function onCellWritten(change: { rowIndex: number; columnId: string; oldValue?: unknown; newValue: unknown }) {
2074
+ // An entry the validation refused comes through with the old value as
2075
+ // the new one; nothing changed and nothing follows from it.
2076
+ if (change.oldValue !== undefined && change.oldValue === change.newValue) return
2077
+ const ref = parseA1(`${change.columnId}1`)
2078
+ if (!ref) return
2079
+ const text = balanceParens(change.newValue == null ? '' : String(change.newValue))
2080
+ if (copied?.fresh && text === '' && !copied.cut) {
2081
+ const dr = change.rowIndex - copied.origin.row
2082
+ const dc = ref.col - copied.origin.col
2083
+ if (copied.cells[dr]?.[dc]) copied.cut = true
2084
+ } else if (!pasting) {
2085
+ marquee = null
2086
+ }
2087
+ formulaDraft = null
2088
+ const r = change.rowIndex
2089
+ const c = ref.col
2090
+ // A typed 12% is the number 0.12 shown as a percentage, as it is in
2091
+ // Excel: the value lands as a number and the cell takes the format the
2092
+ // entry implied, unless it already has one of its own. A line break
2093
+ // typed with Alt+Enter turns on Wrap Text, as Excel's does.
2094
+ let stored = text
2095
+ const target = getFormatTarget()
2096
+ const cmd = cmdOf()
2097
+ const entry = storeFor().get(`r${r}`, colToLetters(c))
2098
+ const patch: CellFormatEntry = {}
2099
+ const parsed = parseEntry(text)
2100
+ if (parsed) {
2101
+ stored = parsed.value
2102
+ if (!entry?.numFmt) patch.numFmt = parsed.numFmt
2103
+ }
2104
+ if (text.includes('\n') && !entry?.wrap) patch.wrap = true
2105
+ if (Object.keys(patch).length && target && cmd) {
2106
+ // Inside the grid's own history group for this write, so the value and
2107
+ // the format it implied come back together on one Ctrl+Z.
2108
+ withFormatUndo(cmd, target, [[r, c, r, c]], () => target.store.set([[r, c, r, c]], patch, target.lookup))
2109
+ }
2110
+ if (wb.getRaw(wb.active, r, c) === stored) {
2111
+ if (patch.wrap) { bump(); fitWrappedRows() }
2112
+ return
2113
+ }
2114
+ wb.setRaw(wb.active, r, c, stored)
2115
+ bump()
2116
+ changed({ kind: 'cells' })
2117
+ if (patch.wrap || entry?.wrap) fitWrappedRows()
2118
+ }
2119
+
2120
+ /**
2121
+ * Rows holding wrapped text grow to show it, as Excel's do. The wrapped
2122
+ * cells on screen are measured after the repaint; a row whose tallest
2123
+ * wrapped cell no longer needs the extra height gives it back, and a row
2124
+ * the user dragged is left alone.
2125
+ */
2126
+ function fitWrappedRows() {
2127
+ void tick().then(() => {
2128
+ if (!api || !root) return
2129
+ const tallest = new Map<number, number>()
2130
+ for (const span of root.querySelectorAll<HTMLElement>('.sheet-cell.wrap')) {
2131
+ const td = span.closest<HTMLElement>('td')
2132
+ const r = Number(td?.dataset.svgridRow)
2133
+ if (!Number.isInteger(r)) continue
2134
+ const needed = span.scrollHeight + 2
2135
+ if (needed > (tallest.get(r) ?? 0)) tallest.set(r, needed)
2136
+ }
2137
+ for (const [r, needed] of tallest) {
2138
+ const current = api.getRowHeight(r)
2139
+ if (needed > current) api.setRowHeight(r, needed)
2140
+ }
2141
+ })
2142
+ }
2143
+
2144
+ // --- the clipboard --------------------------------------------------------
2145
+ /**
2146
+ * What Ctrl+C took, kept here as well as on the system clipboard. The
2147
+ * system clipboard gets the text each cell SHOWS, which is what another
2148
+ * application expects to receive; a paste back into the sheet wants the
2149
+ * formula, moved by the distance it travelled, and the cell's format,
2150
+ * which is what Excel pastes. The two are matched on paste: a clipboard
2151
+ * whose text is not what the copy showed came from somewhere else, and
2152
+ * is pasted for what it carries (see `pasteFromSystem`).
2153
+ *
2154
+ * A cut is a copy the grid clears straight after, in the same keystroke.
2155
+ * Excel moves a cut formula without translating it, so the clear that
2156
+ * lands while the copy is still fresh marks it as one.
2157
+ */
2158
+ type CopiedCell = { shown: string; raw: string; value: string; format: CellFormatEntry | undefined }
2159
+ type Copied = {
2160
+ origin: { row: number; col: number }
2161
+ cells: CopiedCell[][]
2162
+ fresh: boolean
2163
+ cut: boolean
2164
+ }
2165
+ // State, so Paste Special knows there is something to paste as soon as
2166
+ // Ctrl+C has run.
2167
+ let copied = $state<Copied | null>(null)
2168
+ /**
2169
+ * Excel's marching ants: the block Ctrl+C or Ctrl+X took, outlined by a
2170
+ * walking dashed line until Escape, a write into a cell, a structural
2171
+ * edit, the paste of a cut, or the next copy. A paste of a copy keeps it,
2172
+ * as Excel does, so the block can be pasted again. The block belongs to
2173
+ * the sheet it was copied on: another sheet shows nothing, and coming
2174
+ * back shows it again, as Excel's does.
2175
+ */
2176
+ let marquee = $state<{ sheet: string; rect: readonly [number, number, number, number] } | null>(null)
2177
+ /** The edges of the marquee a cell sits on, as class names; null off it. */
2178
+ function marqueeEdges(r: number, c: number): string | null {
2179
+ // The active sheet is not state; the switch bumps the version.
2180
+ void version
2181
+ const m = marquee
2182
+ if (!m || m.sheet !== wb.active) return null
2183
+ const [r1, c1, r2, c2] = m.rect
2184
+ if (r < r1 || r > r2 || c < c1 || c > c2) return null
2185
+ const edges: string[] = []
2186
+ if (r === r1) edges.push('top')
2187
+ if (r === r2) edges.push('bottom')
2188
+ if (c === c1) edges.push('left')
2189
+ if (c === c2) edges.push('right')
2190
+ return edges.length ? edges.join(' ') : null
2191
+ }
2192
+ /** Writes made by a paste keep the marquee; every other write clears it. */
2193
+ let pasting = false
2194
+
2195
+ function toClipboard(params: { rowIndex: number; columnId: string }): unknown {
2196
+ const col = lettersToCol(params.columnId)
2197
+ const r = params.rowIndex
2198
+ if (col < 0) return ''
2199
+ const shown = display(r, col).text
2200
+ const rects = selection.length ? selection : [[r, col, r, col] as const]
2201
+ // The grid walks each rectangle top-left to bottom-right; the first
2202
+ // cell of the first one starts a new copy. Only the first rectangle is
2203
+ // kept, as Excel keeps only one.
2204
+ const [minRow, minCol, maxRow, maxCol] = rects[0]!
2205
+ if (r === minRow && col === minCol) {
2206
+ copied = { origin: { row: r, col }, cells: [], fresh: true, cut: false }
2207
+ queueMicrotask(() => { if (copied) copied.fresh = false })
2208
+ // A whole column or row is selected to Infinity; the ants stop at
2209
+ // the sheet's last line.
2210
+ marquee = { sheet: wb.active, rect: [minRow, minCol, Math.min(maxRow, rowCount - 1), Math.min(maxCol, colCount - 1)] }
2211
+ }
2212
+ if (copied && !copied.cut) {
2213
+ const dr = r - copied.origin.row
2214
+ const dc = col - copied.origin.col
2215
+ if (dr >= 0 && dc >= 0) {
2216
+ // Read back after the write: $state hands out a proxy of what was
2217
+ // assigned, and writing into the raw array would fill nothing.
2218
+ if (!copied.cells[dr]) copied.cells[dr] = []
2219
+ const line = copied.cells[dr]!
2220
+ line[dc] = { shown, raw: raw(r, col), value: plainValue(r, col), format: storeFor().get(`r${r}`, params.columnId) }
2221
+ }
2222
+ }
2223
+ return shown
2224
+ }
2225
+
2226
+ /** The value behind a cell as text, unformatted: what Paste Values writes. */
2227
+ function plainValue(r: number, c: number): string {
2228
+ const value = wb.getValue(wb.active, r, c)
2229
+ if (isError(value)) return value.error
2230
+ if (value === '' || value == null) return ''
2231
+ return String(value)
2232
+ }
2233
+
2234
+ const EMPTY_COPIED: CopiedCell = { shown: '', raw: '', value: '', format: undefined }
2235
+
2236
+ /**
2237
+ * The copied block as clipboard cells. `plain` is what a paste writes:
2238
+ * the raw text of a constant, the value of a formula (Paste Values), the
2239
+ * formula itself beside it. `shown` is what another application gets:
2240
+ * the display text, with the number behind it for a spreadsheet.
2241
+ */
2242
+ function copiedGrid(block: Copied, text: 'plain' | 'shown'): ClipboardGrid {
2243
+ const width = block.cells.reduce((max, line) => Math.max(max, line.length), 0)
2244
+ return block.cells.map((line) =>
2245
+ Array.from({ length: width }, (_, i) => {
2246
+ const cell = line[i] ?? EMPTY_COPIED
2247
+ const formula = cell.raw.startsWith('=')
2248
+ const numeric = cell.value !== '' && Number.isFinite(Number(cell.value))
2249
+ return {
2250
+ text: text === 'shown' ? cell.shown : formula ? cell.value : cell.raw,
2251
+ ...(text === 'shown' && numeric ? { value: cell.value } : {}),
2252
+ ...(formula ? { formula: cell.raw } : {}),
2253
+ ...(cell.format ? { format: cell.format } : {}),
2254
+ }
2255
+ }),
2256
+ )
2257
+ }
2258
+
2259
+ /** The text the grid put on the clipboard for this block. */
2260
+ function copiedText(block: Copied): string {
2261
+ return copiedGrid(block, 'shown').map((line) => line.map((cell) => cell.text).join('\t')).join('\n')
2262
+ }
2263
+
2264
+ /**
2265
+ * The HTML half of a copy, built once the grid has walked the block and
2266
+ * `toClipboard` has filled it: what Excel and Sheets read our formats
2267
+ * and numbers from, and what a sheet in another tab reads the formulas
2268
+ * from.
2269
+ */
2270
+ function copiedHtml(): string | null {
2271
+ const block = copied
2272
+ if (!block || !block.cells.length) return null
2273
+ return buildClipboardPayload(copiedGrid(block, 'shown'), block.origin).html
2274
+ }
2275
+
2276
+ /**
2277
+ * Ctrl+V. A clipboard still holding our last copy pastes the copied
2278
+ * block itself: formulas translated (or moved, after a cut), formats
2279
+ * with them. Anything else is read for what it carries: a table from
2280
+ * Excel, Sheets or another sheet brings its values, formulas and formats;
2281
+ * plain text is left to the grid, which lands it as typed.
2282
+ */
2283
+ function pasteFromSystem(payload: { text: string; html: string | null }): boolean | void {
2284
+ const block = copied
2285
+ const text = payload.text.replace(/\r\n/g, '\n').replace(/\n$/, '')
2286
+ if (block && block.cells.length && copiedText(block) === text) {
2287
+ return pasteBlock(copiedGrid(block, 'plain'), block.cut ? null : block.origin, { what: 'all' })
2288
+ }
2289
+ const grid = payload.html ? parseClipboardHtml(payload.html) : null
2290
+ if (!grid) return undefined
2291
+ const origin = readClipboardOrigin(payload.html!)
2292
+ // Our own marker says where the block came from; a foreign block's
2293
+ // formulas are kept only where they can be placed (see the helper).
2294
+ if (origin) return pasteBlock(grid, origin, { what: 'all' })
2295
+ const foreign = anchorForeignFormulas(grid)
2296
+ return pasteBlock(foreign.grid, foreign.origin, { what: 'all' })
2297
+ }
2298
+
2299
+ // --- Excel's cell menu ----------------------------------------------------
2300
+ function withCmd(fn: (cmd: GridCommandContext) => void) {
2301
+ const c = cmdOf()
2302
+ if (!c) return
2303
+ fn(c)
2304
+ // The menu took focus; the sheet gets it back so Ctrl+Z is the next
2305
+ // keystroke that works, as after a ribbon click.
2306
+ queueMicrotask(() => c.focus())
2307
+ }
2308
+
2309
+ /**
2310
+ * Right-click on a cell. The grid's own row items would splice its data
2311
+ * behind the workbook's back, so structure goes through the sheet's
2312
+ * insert / delete, which rewrite every formula the change reaches; Clear
2313
+ * Contents writes blanks as one undo step, as the keyboard's Delete does.
2314
+ */
2315
+ /** Whether the active cell carries a comment, for the menu's items. */
2316
+ const hasComment = () => commentAt(notesNow(), active.rowIndex, active.colIndex) !== undefined
2317
+ /** Whole rows, whole columns, or a block: what the menu's items are for. */
2318
+ const axis = (): 'rows' | 'cols' | 'ambiguous' => {
2319
+ const c = cmdOf()
2320
+ if (!c || !c.ranges.length) return 'ambiguous'
2321
+ return axisForSelection(c)
2322
+ }
2323
+ /** The menu's glyphs are the ribbon's, so the two read as one set. */
2324
+ const glyph = (name: RibbonIconName): ContextMenuIcon => ({ paths: RIBBON_ICONS[name] })
2325
+ const contextMenu: ContextMenuItem<SheetRow>[] = [
2326
+ // The grid's own Cut / Copy / Paste, with the ribbon's icons on them.
2327
+ { key: 'cut', icon: glyph('cut') },
2328
+ { key: 'copy', icon: glyph('copy') },
2329
+ { key: 'paste', icon: glyph('paste') },
2330
+ { key: 'paste-special', label: 'Paste Special...', icon: glyph('paste-special'), action: () => withCmd((c) => delegate('paste-special', c)) },
2331
+ 'separator',
2332
+ // A column letter's menu says Insert and Delete, as Excel's does; a
2333
+ // cell's spells both axes out.
2334
+ { key: 'insert-rows', label: 'Insert Rows', icon: glyph('insert-cells'), hidden: () => axis() === 'cols', action: () => withCmd((c) => insertRows(c)) },
2335
+ { key: 'insert-columns', label: 'Insert Columns', icon: glyph('insert-cells'), hidden: () => axis() === 'rows', action: () => withCmd((c) => insertColumns(c)) },
2336
+ { key: 'delete-rows', label: 'Delete Rows', icon: glyph('delete-cells'), hidden: () => axis() === 'cols', action: () => withCmd((c) => deleteRows(c)) },
2337
+ { key: 'delete-columns', label: 'Delete Columns', icon: glyph('delete-cells'), hidden: () => axis() === 'rows', action: () => withCmd((c) => deleteColumns(c)) },
2338
+ 'separator',
2339
+ {
2340
+ key: 'clear-contents', label: 'Clear Contents', icon: glyph('clear'),
2341
+ action: () => withCmd((c) => {
2342
+ const rects = c.ranges.length
2343
+ ? c.ranges
2344
+ : c.activeCell ? [[c.activeCell.rowIndex, c.activeCell.colIndex, c.activeCell.rowIndex, c.activeCell.colIndex] as const] : []
2345
+ if (protectedNow() && rectsHaveLocked(storeFor(), lookup, rects)) { refuse(); return }
2346
+ c.batch(() => {
2347
+ for (const [r1, c1, r2, c2] of rects) {
2348
+ for (let r = r1; r <= r2; r += 1) for (let col = c1; col <= c2; col += 1) c.setCellValue(r, col, '')
2349
+ }
2350
+ })
2351
+ }),
2352
+ },
2353
+ { key: 'clear-formats', label: 'Clear Formats', icon: glyph('clear-formats'), action: () => withCmd((c) => clearFormats(c)) },
2354
+ 'separator',
2355
+ { key: 'merge-center', label: 'Merge & Center', icon: glyph('merge'), hidden: () => axis() !== 'ambiguous' || selectionHasMerge(), action: () => withCmd((c) => handleAction('merge-center', c)) },
2356
+ { key: 'unmerge-cells', label: 'Unmerge Cells', icon: glyph('unmerge'), hidden: () => axis() !== 'ambiguous' || !selectionHasMerge(), action: () => withCmd((c) => handleAction('unmerge-cells', c)) },
2357
+ 'separator',
2358
+ // Excel's New Comment / Edit Comment / Delete Comment, one pair shown.
2359
+ { key: 'new-comment', label: 'New Comment', icon: glyph('comment'), hidden: () => axis() !== 'ambiguous' || hasComment(), action: () => withCmd((c) => handleAction('new-comment', c)) },
2360
+ { key: 'edit-comment', label: 'Edit Comment', icon: glyph('comment'), hidden: () => axis() !== 'ambiguous' || !hasComment(), action: () => withCmd((c) => handleAction('edit-comment', c)) },
2361
+ { key: 'delete-comment', label: 'Delete Comment', icon: glyph('comment-delete'), hidden: () => axis() !== 'ambiguous' || !hasComment(), action: () => withCmd((c) => handleAction('delete-comment', c)) },
2362
+ 'separator',
2363
+ { key: 'format-cells', label: 'Format Cells...', icon: glyph('format-cells'), action: () => withCmd((c) => delegate('format-cells', c)) },
2364
+ { key: 'column-width', label: 'Column Width...', icon: glyph('column-width'), hidden: () => axis() !== 'cols', action: () => openSizeDialog('cols') },
2365
+ { key: 'row-height', label: 'Row Height...', icon: glyph('row-height'), hidden: () => axis() !== 'rows', action: () => openSizeDialog('rows') },
2366
+ { key: 'hide-columns', label: 'Hide', icon: glyph('hide'), hidden: () => axis() !== 'cols', action: () => withCmd((c) => handleAction('hide-columns', c)) },
2367
+ { key: 'unhide-columns', label: 'Unhide', icon: glyph('unhide'), hidden: () => axis() !== 'cols', action: () => withCmd((c) => handleAction('unhide-columns', c)) },
2368
+ { key: 'hide-rows', label: 'Hide', icon: glyph('hide'), hidden: () => axis() !== 'rows', action: () => withCmd((c) => handleAction('hide-rows', c)) },
2369
+ { key: 'unhide-rows', label: 'Unhide', icon: glyph('unhide'), hidden: () => axis() !== 'rows', action: () => withCmd((c) => handleAction('unhide-rows', c)) },
2370
+ ]
2371
+
2372
+ // --- Column Width... and Row Height... --------------------------------------
2373
+ let sizeDialog = $state<'cols' | 'rows' | null>(null)
2374
+ const sizeDialogOpen = $derived(sizeDialog !== null)
2375
+ /** The columns or rows the size applies to: what the selection spans. */
2376
+ function sizeTargets(): number[] {
2377
+ return lineTargets(sizeDialog === 'cols' ? 'cols' : 'rows')
2378
+ }
2379
+ function lineTargets(axis: 'rows' | 'cols'): number[] {
2380
+ const last = selection[selection.length - 1]
2381
+ if (!last) return [axis === 'cols' ? active.colIndex : active.rowIndex]
2382
+ const [r1, c1, r2, c2] = last
2383
+ const from = axis === 'cols' ? c1 : r1
2384
+ const to = Math.min(axis === 'cols' ? c2 : r2, (axis === 'cols' ? colCount : rowCount) - 1)
2385
+ return Array.from({ length: to - from + 1 }, (_, i) => from + i)
2386
+ }
2387
+
2388
+ /**
2389
+ * AutoFit from the Format menu: the selected columns snap to their widest
2390
+ * text, the selected rows to their wrapped text, as one undo, the same
2391
+ * fit a double-click on a header edge gives one line.
2392
+ */
2393
+ function autofitLines(axis: 'rows' | 'cols', cmd: GridCommandContext) {
2394
+ if (!api) return
2395
+ if (protectedNow()) { refuse(); return }
2396
+ const targets = lineTargets(axis)
2397
+ if (axis === 'cols') {
2398
+ const widths = () => targets.map((c) => [c, api!.getColumnWidths()[colToLetters(c)] ?? columnWidth] as const)
2399
+ const before = widths()
2400
+ for (const c of targets) api.autosizeColumn(colToLetters(c))
2401
+ const after = widths()
2402
+ const put = (list: ReadonlyArray<readonly [number, number]>) => {
2403
+ for (const [c, w] of list) api!.setColumnWidth(colToLetters(c), w)
2404
+ stashWidths()
2405
+ changed({ kind: 'sizes' })
2406
+ }
2407
+ cmd.recordUndo(() => put(before), () => put(after))
2408
+ } else {
2409
+ const before = targets.map((r) => [r, api!.getRowHeight(r)] as const)
2410
+ const fit = () => { for (const r of targets) api!.setRowHeight(r, null); fitWrappedRows(); changed({ kind: 'sizes' }) }
2411
+ fit()
2412
+ cmd.recordUndo(() => { for (const [r, h] of before) api!.setRowHeight(r, h === rowHeight ? null : h); stashHeights(); changed({ kind: 'sizes' }) }, fit)
2413
+ }
2414
+ stashLive()
2415
+ bump()
2416
+ }
2417
+ const sizeCurrent = $derived.by(() => {
2418
+ void version
2419
+ void selection
2420
+ void active
2421
+ if (!api || !sizeDialog) return 0
2422
+ const first = sizeTargets()[0] ?? 0
2423
+ return sizeDialog === 'cols'
2424
+ ? (api.getColumnWidths()[colToLetters(first)] ?? columnWidths?.[colToLetters(first)] ?? columnWidth)
2425
+ : api.getRowHeight(first)
2426
+ })
2427
+ /** Column Width... / Row Height...: not on a protected sheet. */
2428
+ function openSizeDialog(kind: 'cols' | 'rows') {
2429
+ if (protectedNow()) { refuse(); return }
2430
+ sizeDialog = kind
2431
+ }
2432
+ function applySize(px: number) {
2433
+ if (!api) return
2434
+ if (protectedNow()) { refuse(); return }
2435
+ const kind = sizeDialog
2436
+ const targets = sizeTargets()
2437
+ const read = () => targets.map((i) => [i, kind === 'cols' ? api!.getColumnWidths()[colToLetters(i)] ?? columnWidth : api!.getRowHeight(i)] as const)
2438
+ const put = (list: ReadonlyArray<readonly [number, number]>) => {
2439
+ for (const [i, size] of list) {
2440
+ if (kind === 'cols') api!.setColumnWidth(colToLetters(i), size)
2441
+ else api!.setRowHeight(i, size === rowHeight ? null : size)
2442
+ }
2443
+ stashLive()
2444
+ bump()
2445
+ changed({ kind: 'sizes' })
2446
+ }
2447
+ const before = read()
2448
+ put(targets.map((i) => [i, px] as const))
2449
+ const after = read()
2450
+ const c = cmdOf()
2451
+ c?.recordUndo(() => put(before), () => put(after))
2452
+ if (c) focusSheet(c)
2453
+ }
2454
+
2455
+ /**
2456
+ * Excel moves the selection to the right-clicked cell unless that cell is
2457
+ * already inside it, so "Insert Rows" from the menu acts where the pointer
2458
+ * was, not where the last click was. Capture phase, so the selection has
2459
+ * moved before the grid opens its menu.
2460
+ */
2461
+ function onSheetContextMenu(event: MouseEvent) {
2462
+ const el = event.target as HTMLElement
2463
+ const th = el.closest<HTMLElement>('th[data-svgrid-header-col]')
2464
+ const gutter = el.closest<HTMLElement>('td.sv-grid-row-number-cell')
2465
+ if (th || gutter) {
2466
+ // A column letter or a row number: Excel selects the whole column or
2467
+ // row unless it is already inside the selection, then opens the cell
2468
+ // menu for it. The grid has no menu on its headers of its own.
2469
+ event.preventDefault()
2470
+ if (!api) return
2471
+ const rects = selection
2472
+ if (th) {
2473
+ const col = lettersToCol(th.dataset.svgridHeaderCol ?? '')
2474
+ if (col < 0) return
2475
+ const inside = rects.some(([, c1, r2, c2]) => c1 <= col && col <= c2 && r2 >= rowCount - 1)
2476
+ if (!inside) selectColumns(col, col, { active: 'place' })
2477
+ api.openContextMenu(event, 0, col)
2478
+ return
2479
+ }
2480
+ const row = rowOfGutter(gutter!)
2481
+ if (row === null) return
2482
+ const inside = rects.some(([r1, , r2, c2]) => r1 <= row && row <= r2 && c2 >= colCount - 1)
2483
+ if (!inside) selectRows(row, row, { active: 'place' })
2484
+ api.openContextMenu(event, row, 0)
2485
+ return
2486
+ }
2487
+ const td = el.closest<HTMLElement>('td[data-svgrid-row][data-svgrid-col]')
2488
+ if (!td) return
2489
+ const r = Number(td.dataset.svgridRow)
2490
+ const c = Number(td.dataset.svgridCol)
2491
+ const rects = selection.length
2492
+ ? selection
2493
+ : [[active.rowIndex, active.colIndex, active.rowIndex, active.colIndex] as const]
2494
+ const inside = rects.some(([r1, c1, r2, c2]) => r >= r1 && r <= r2 && c >= c1 && c <= c2)
2495
+ if (inside) return
2496
+ const cmd = cmdOf()
2497
+ if (!cmd) return
2498
+ cmd.setActiveCell(r, c)
2499
+ cmd.setSelection(r, c)
2500
+ }
2501
+
2502
+ // --- the letters, the numbers and the corner ------------------------------
2503
+ /*
2504
+ * Excel's rules for a click on a column letter or a row number:
2505
+ *
2506
+ * click the whole line; the active cell goes to the first
2507
+ * VISIBLE row of the column (or column of the row), so
2508
+ * a click on C with row 40 at the top lands on C40
2509
+ * drag a run of lines from the pressed one to the one under
2510
+ * the pointer, the cells below the band counting too
2511
+ * Shift+click the run from the active cell's line to the clicked one;
2512
+ * the active cell stays
2513
+ * Ctrl+click another whole line beside the selection
2514
+ * the corner everything; the active cell stays put
2515
+ *
2516
+ * and, after any of them, the keyboard is still on the sheet: an arrow
2517
+ * collapses to the active cell, Shift+Arrow grows the run, typing lands
2518
+ * in the active cell. The grid's own headers sort on click, so the sheet
2519
+ * takes these gestures on the pointer, before the grid sees them.
2520
+ */
2521
+ type HeaderHit = { axis: 'col' | 'row'; index: number } | { axis: 'all' }
2522
+ let headerDrag: { axis: 'col' | 'row'; from: number; keep: ReadonlyArray<readonly [number, number, number, number]> } | null = null
2523
+
2524
+ function rowOfGutter(gutter: HTMLElement): number | null {
2525
+ const rowAttr = gutter.parentElement?.querySelector<HTMLElement>('td[data-svgrid-row]')?.dataset.svgridRow
2526
+ const row = rowAttr === undefined ? Number(gutter.textContent) - 1 : Number(rowAttr)
2527
+ return Number.isInteger(row) && row >= 0 ? row : null
2528
+ }
2529
+ function headerHit(el: HTMLElement | null, dragging: 'col' | 'row' | null = null): HeaderHit | null {
2530
+ if (!el) return null
2531
+ if (el.closest('.sv-grid-resize-handle, .sv-grid-row-resize-handle')) return null
2532
+ if (el.closest('th.sv-grid-row-number-column')) return { axis: 'all' }
2533
+ const th = el.closest<HTMLElement>('th[data-svgrid-header-col]')
2534
+ if (th) {
2535
+ const col = lettersToCol(th.dataset.svgridHeaderCol ?? '')
2536
+ return col < 0 ? null : { axis: 'col', index: col }
2537
+ }
2538
+ const gutter = el.closest<HTMLElement>('td.sv-grid-row-number-cell')
2539
+ if (gutter) {
2540
+ const row = rowOfGutter(gutter)
2541
+ return row === null ? null : { axis: 'row', index: row }
2542
+ }
2543
+ // A drag that has left the band: the cell under the pointer says which
2544
+ // line the run reaches, as it does in Excel.
2545
+ if (dragging) {
2546
+ const td = el.closest<HTMLElement>('td[data-svgrid-row][data-svgrid-col]')
2547
+ if (td) return { axis: dragging, index: Number(dragging === 'col' ? td.dataset.svgridCol : td.dataset.svgridRow) }
2548
+ }
2549
+ return null
2550
+ }
2551
+
2552
+ /** The first row (or column) in view, where Excel puts the active cell of a clicked line. */
2553
+ function firstVisible(axis: 'row' | 'col'): number {
2554
+ const host = gridHost?.querySelector<HTMLElement>('.sv-grid-container')
2555
+ if (!host) return 0
2556
+ const box = host.getBoundingClientRect()
2557
+ const headH = host.querySelector('thead')?.getBoundingClientRect().height ?? 0
2558
+ const gutterW = host.querySelector('td.sv-grid-row-number-cell')?.getBoundingClientRect().width ?? 0
2559
+ let best: number | null = null
2560
+ for (const td of host.querySelectorAll<HTMLElement>('td[data-svgrid-row][data-svgrid-col]')) {
2561
+ if (td.closest('tr')?.classList.contains('sv-grid-row-collapsed')) continue
2562
+ const r = td.getBoundingClientRect()
2563
+ if (r.width === 0 || r.height === 0) continue
2564
+ const index = Number(axis === 'row' ? td.dataset.svgridRow : td.dataset.svgridCol)
2565
+ const edge = axis === 'row' ? r.top - (box.top + headH) : r.left - (box.left + gutterW)
2566
+ if (edge < -1) continue
2567
+ if (best === null || index < best) best = index
2568
+ }
2569
+ return best ?? 0
2570
+ }
2571
+
2572
+ type LinePlacement = { active: 'place' | 'keep'; keep?: ReadonlyArray<readonly [number, number, number, number]> }
2573
+ /** Whole columns a..b (plus `keep`, the ranges a Ctrl+click adds to). */
2574
+ function selectColumns(a: number, b: number, how: LinePlacement) {
2575
+ const cmd = cmdOf()
2576
+ if (!api || !cmd) return
2577
+ const was = cmd.activeCell
2578
+ api.selectCells([...(how.keep ?? []), [0, Math.min(a, b), Number.POSITIVE_INFINITY, Math.max(a, b)]])
2579
+ if (how.active === 'place') cmd.setActiveCell(firstVisible('row'), a)
2580
+ else if (was) cmd.setActiveCell(was.rowIndex, was.colIndex)
2581
+ }
2582
+ /** Whole rows a..b, the same way. */
2583
+ function selectRows(a: number, b: number, how: LinePlacement) {
2584
+ const cmd = cmdOf()
2585
+ if (!api || !cmd) return
2586
+ const was = cmd.activeCell
2587
+ api.selectCells([...(how.keep ?? []), [Math.min(a, b), 0, Math.max(a, b), Number.POSITIVE_INFINITY]])
2588
+ if (how.active === 'place') cmd.setActiveCell(a, firstVisible('col'))
2589
+ else if (was) cmd.setActiveCell(was.rowIndex, was.colIndex)
2590
+ }
2591
+ function selectLine(hit: { axis: 'col' | 'row'; index: number }, from: number, how: LinePlacement) {
2592
+ if (hit.axis === 'col') selectColumns(from, hit.index, how)
2593
+ else selectRows(from, hit.index, how)
2594
+ }
2595
+
2596
+ /*
2597
+ * Excel's resize tip: while a column or row border is dragged, a small
2598
+ * box beside the pointer reads "Width: 8.43 (64 pixels)" or "Height:
2599
+ * 15.00 (20 pixels)". The grid moves the border and commits the size on
2600
+ * every frame; the tip reads the live size back from the header (or the
2601
+ * row) under it. Excel's width unit is characters of the default font,
2602
+ * (pixels - 5) / 7 for its 11pt default, which a 13px sheet font matches
2603
+ * closely; its height unit is points, three quarters of a pixel.
2604
+ */
2605
+ let resizeTip = $state<{ x: number; y: number; text: string } | null>(null)
2606
+ /** Excel's dotted guide across the sheet at the border being dragged. */
2607
+ let resizeGuide = $state<{ axis: 'col' | 'row'; at: number } | null>(null)
2608
+ /*
2609
+ * The drag as it began: where the pointer was, how big the line was and
2610
+ * where its edge sat in the sheet. Each move adds the pointer's travel to
2611
+ * those, the same arithmetic the grid's own resize uses, so the tip and
2612
+ * the guide land on the frame of the move; reading the size back from
2613
+ * the DOM waited for the grid's commit and trailed the pointer.
2614
+ */
2615
+ let resizing: { axis: 'col' | 'row'; key: string; startX: number; startY: number; startSize: number; startEdge: number; min: number } | null = null
2616
+
2617
+ /*
2618
+ * Two more of Excel's drag readouts. While a range is dragged out the
2619
+ * Name Box reads "3R x 2C" instead of the address; while the fill handle
2620
+ * is dragged the same tip box says what the cell under the pointer will
2621
+ * get (Feb, 7, 2026-09-18), worked out with the grid's own series.
2622
+ */
2623
+ let cellDrag = $state(false)
2624
+ let fillDrag: { rect: readonly [number, number, number, number] } | null = null
2625
+ const dragLabel = $derived.by(() => {
2626
+ if (!cellDrag) return null
2627
+ const rect = selection[selection.length - 1]
2628
+ if (!rect) return null
2629
+ const rows = Math.abs(rect[2] - rect[0]) + 1
2630
+ const cols = Math.abs(rect[3] - rect[1]) + 1
2631
+ return rows * cols > 1 ? `${rows}R x ${cols}C` : null
2632
+ })
2633
+ function fillTipText(under: { r: number; c: number }): string | null {
2634
+ const src = fillDrag?.rect
2635
+ if (!src) return null
2636
+ const [r1, c1, r2, c2] = src
2637
+ const { r, c } = under
2638
+ const inside = r >= r1 && r <= r2 && c >= c1 && c <= c2
2639
+ if (inside) return null
2640
+ // Down or up when the pointer left the block vertically, else sideways;
2641
+ // the series runs along that axis from the block's edge.
2642
+ const vertical = r < r1 || r > r2
2643
+ if (vertical) {
2644
+ if (c < c1 || c > c2) return null
2645
+ const down = r > r2
2646
+ const sources = Array.from({ length: r2 - r1 + 1 }, (_, i) => raw(down ? r1 + i : r2 - i, c))
2647
+ const count = down ? r - r2 : r1 - r
2648
+ return fillTipValue(buildFillPattern(sources, count)[count - 1], down ? r2 : r1, c)
2649
+ }
2650
+ if (r < r1 || r > r2) return null
2651
+ const right = c > c2
2652
+ const sources = Array.from({ length: c2 - c1 + 1 }, (_, i) => raw(r, right ? c1 + i : c2 - i))
2653
+ const count = right ? c - c2 : c1 - c
2654
+ return fillTipValue(buildFillPattern(sources, count)[count - 1], r, right ? c2 : c1)
2655
+ }
2656
+ /** The tip's text: a number the way the source cell formats it, as Excel shows it. */
2657
+ function fillTipValue(value: unknown, sr: number, sc: number): string | null {
2658
+ if (value === undefined || value === null || value === '') return null
2659
+ const n = typeof value === 'number' ? value : Number(value)
2660
+ if (typeof value !== 'boolean' && Number.isFinite(n) && String(value).trim() !== '') {
2661
+ const fmt = storeFor().get(`r${sr}`, colToLetters(sc))?.numFmt
2662
+ return fmt ? compileNumberFormat(fmt).format(n).text : String(Number(n.toPrecision(10)))
2663
+ }
2664
+ return String(value)
2665
+ }
2666
+ function placeFillTip(event: PointerEvent) {
2667
+ const host = gridHost
2668
+ if (!host || !fillDrag) return
2669
+ const td = (document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null)?.closest<HTMLElement>('td[data-svgrid-row][data-svgrid-col]')
2670
+ const text = td ? fillTipText({ r: Number(td.dataset.svgridRow), c: Number(td.dataset.svgridCol) }) : null
2671
+ if (text === null || text === '') { resizeTip = null; return }
2672
+ const box = host.getBoundingClientRect()
2673
+ resizeTip = { x: event.clientX - box.left + 14, y: event.clientY - box.top + 18, text }
2674
+ }
2675
+ function resizeTipText(px: number): string {
2676
+ if (!resizing) return ''
2677
+ return resizing.axis === 'col'
2678
+ ? `Width: ${Math.max(0, (px - 5) / 7).toFixed(2)} (${px} pixels)`
2679
+ : `Height: ${(px * 0.75).toFixed(2)} (${px} pixels)`
2680
+ }
2681
+ function placeResizeTip(event: PointerEvent) {
2682
+ const host = gridHost
2683
+ const drag = resizing
2684
+ if (!host || !drag) return
2685
+ const box = host.getBoundingClientRect()
2686
+ const travel = drag.axis === 'col' ? event.clientX - drag.startX : event.clientY - drag.startY
2687
+ const px = Math.round(Math.max(drag.min, drag.startSize + travel))
2688
+ // Beside the pointer, kept inside the sheet at its right edge.
2689
+ const x = Math.min(event.clientX - box.left + 14, box.width - 160)
2690
+ resizeTip = { x: Math.max(0, x), y: event.clientY - box.top - 30, text: resizeTipText(px) }
2691
+ resizeGuide = { axis: drag.axis, at: drag.startEdge + (px - drag.startSize) }
2692
+ }
2693
+ function startResizeTip(handle: HTMLElement, event: PointerEvent) {
2694
+ const host = gridHost
2695
+ if (!host) return
2696
+ const box = host.getBoundingClientRect()
2697
+ if (handle.classList.contains('sv-grid-row-resize-handle')) {
2698
+ const tr = handle.closest('tr')
2699
+ const td = tr?.querySelector<HTMLElement>('td[data-svgrid-row]')
2700
+ const row = td?.dataset.svgridRow
2701
+ if (!tr || !td || row === undefined) return
2702
+ const r = td.getBoundingClientRect()
2703
+ // The grid's own minimum row height, so the tip stops where the row does.
2704
+ resizing = { axis: 'row', key: row, startX: event.clientX, startY: event.clientY, startSize: Math.round(r.height), startEdge: r.bottom - box.top, min: 20 }
2705
+ } else {
2706
+ const th = handle.closest<HTMLElement>('[data-svgrid-header-col]')
2707
+ const col = th?.dataset.svgridHeaderCol
2708
+ if (!th || !col) return
2709
+ const r = th.getBoundingClientRect()
2710
+ resizing = { axis: 'col', key: col, startX: event.clientX, startY: event.clientY, startSize: Math.round(r.width), startEdge: r.right - box.left, min: 40 }
2711
+ }
2712
+ placeResizeTip(event)
2713
+ }
2714
+ function endResizeTip() {
2715
+ resizing = null
2716
+ resizeTip = null
2717
+ resizeGuide = null
2718
+ }
2719
+
2720
+ function onSheetPointerDown(event: PointerEvent) {
2721
+ if (event.button !== 0) return
2722
+ const target = event.target as HTMLElement | null
2723
+ const handle = target?.closest<HTMLElement>('.sv-grid-resize-handle, .sv-grid-row-resize-handle')
2724
+ if (handle) { startResizeTip(handle, event); return }
2725
+ if (target?.closest('.sv-grid-fill-handle')) {
2726
+ const rect = selection[selection.length - 1] ?? [active.rowIndex, active.colIndex, active.rowIndex, active.colIndex]
2727
+ fillDrag = { rect: [Math.min(rect[0], rect[2]), Math.min(rect[1], rect[3]), Math.max(rect[0], rect[2]), Math.max(rect[1], rect[3])] }
2728
+ return
2729
+ }
2730
+ const hit = headerHit(target)
2731
+ if (!hit) {
2732
+ // A press on a cell may become a range drag: the Name Box counts it.
2733
+ if (target?.closest('td[data-svgrid-row][data-svgrid-col]')) cellDrag = true
2734
+ return
2735
+ }
2736
+ const cmd = cmdOf()
2737
+ if (!api || !cmd) return
2738
+ // The grid's header would sort, and the pointer would start a resize
2739
+ // or a cell drag; this gesture is the sheet's.
2740
+ event.preventDefault()
2741
+ event.stopPropagation()
2742
+ if (hit.axis === 'all') {
2743
+ const was = cmd.activeCell
2744
+ api.selectCells([[0, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]])
2745
+ if (was) cmd.setActiveCell(was.rowIndex, was.colIndex)
2746
+ headerDrag = null
2747
+ } else if (event.shiftKey) {
2748
+ const anchor = cmd.activeCell
2749
+ const from = anchor ? (hit.axis === 'col' ? anchor.colIndex : anchor.rowIndex) : hit.index
2750
+ selectLine(hit, from, { active: 'keep' })
2751
+ headerDrag = null
2752
+ } else {
2753
+ const keep = event.ctrlKey || event.metaKey ? cmd.ranges : []
2754
+ selectLine(hit, hit.index, { active: 'place', keep })
2755
+ headerDrag = { axis: hit.axis, from: hit.index, keep }
2756
+ }
2757
+ // Excel leaves the keyboard on the sheet after a header click.
2758
+ cmd.focus()
2759
+ }
2760
+ function onSheetPointerMove(event: PointerEvent) {
2761
+ if (fillDrag) { placeFillTip(event); return }
2762
+ if (resizing) { placeResizeTip(event); return }
2763
+ const drag = headerDrag
2764
+ if (!drag) return
2765
+ const hit = headerHit(document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null, drag.axis)
2766
+ if (!hit || hit.axis !== drag.axis) return
2767
+ const cmd = cmdOf()
2768
+ if (!cmd) return
2769
+ // The run grows from the pressed line; the active cell stays on it.
2770
+ const was = cmd.activeCell
2771
+ if (drag.axis === 'col') api?.selectCells([...drag.keep, [0, Math.min(drag.from, hit.index), Number.POSITIVE_INFINITY, Math.max(drag.from, hit.index)]])
2772
+ else api?.selectCells([...drag.keep, [Math.min(drag.from, hit.index), 0, Math.max(drag.from, hit.index), Number.POSITIVE_INFINITY]])
2773
+ if (was) cmd.setActiveCell(was.rowIndex, was.colIndex)
2774
+ }
2775
+ function endHeaderDrag() {
2776
+ headerDrag = null
2777
+ cellDrag = false
2778
+ fillDrag = null
2779
+ endResizeTip()
2780
+ }
2781
+ $effect(() => {
2782
+ // The resize handle captures the pointer, so its moves come through
2783
+ // the window rather than the sheet.
2784
+ window.addEventListener('pointerup', endHeaderDrag)
2785
+ window.addEventListener('pointercancel', endHeaderDrag)
2786
+ window.addEventListener('pointermove', onSheetPointerMove)
2787
+ return () => {
2788
+ window.removeEventListener('pointerup', endHeaderDrag)
2789
+ window.removeEventListener('pointercancel', endHeaderDrag)
2790
+ window.removeEventListener('pointermove', onSheetPointerMove)
2791
+ }
2792
+ })
2793
+
2794
+ // --- the in-cell editor: caret at the end, and room to grow ---------------
2795
+ /**
2796
+ * Excel's in-cell editor opens with the caret after the text rather than
2797
+ * with the text selected, and it widens over the cells to its right as a
2798
+ * long formula is typed instead of scrolling inside the cell. The grid's
2799
+ * editor does neither on its own, so both are done here, from the events
2800
+ * that bubble out of it.
2801
+ */
2802
+ type Editor = HTMLInputElement | HTMLTextAreaElement
2803
+ /** A key that would start or make an edit on the active cell. */
2804
+ function wouldEdit(event: KeyboardEvent): boolean {
2805
+ if (event.ctrlKey || event.metaKey || event.altKey) return false
2806
+ const el = event.target as HTMLElement | null
2807
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return false
2808
+ if (event.key === 'F2' || event.key === 'Delete' || event.key === 'Backspace') return true
2809
+ return event.key.length === 1
2810
+ }
2811
+
2812
+ function editorOf(target: EventTarget | null): Editor | null {
2813
+ const el = target as HTMLElement | null
2814
+ return (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) &&
2815
+ el.classList.contains('sv-grid-cell-editor') ? el : null
2816
+ }
2817
+
2818
+ let measure: CanvasRenderingContext2D | null = null
2819
+ function growEditor(input: Editor) {
2820
+ const td = input.closest<HTMLElement>('td')
2821
+ if (!td) return
2822
+ measure ??= document.createElement('canvas').getContext('2d')
2823
+ if (!measure) return
2824
+ measure.font = getComputedStyle(input).font
2825
+ // The widest line decides the width; the number of lines the height.
2826
+ const lines = input.value.split('\n')
2827
+ const widest = Math.max(...lines.map((line) => measure!.measureText(line).width))
2828
+ const needed = Math.ceil(widest) + 18
2829
+ input.style.width = needed > td.clientWidth ? `${needed}px` : ''
2830
+ if (input instanceof HTMLTextAreaElement) {
2831
+ const lineHeight = parseFloat(getComputedStyle(input).lineHeight) || 17
2832
+ const tall = Math.ceil(lines.length * lineHeight) + 4
2833
+ input.style.height = tall > td.clientHeight ? `${tall}px` : ''
2834
+ }
2835
+ }
2836
+
2837
+ /**
2838
+ * What is being typed in the cell, mirrored in the formula bar as it is
2839
+ * typed, the way Excel's bar follows an in-cell edit; null between edits,
2840
+ * when the bar shows the cell's stored text.
2841
+ */
2842
+ let editingText = $state<string | null>(null)
2843
+ /** What is being typed in the formula bar, shown in the active cell as it
2844
+ * is typed, the way Excel's cell follows the bar; null between edits. */
2845
+ let barDraft = $state<string | null>(null)
2846
+
2847
+ function onEditorFocusIn(event: FocusEvent) {
2848
+ const input = editorOf(event.target)
2849
+ if (!input) return
2850
+ editingText = input.value
2851
+ // The grid selects the text as the editor mounts, synchronously after
2852
+ // this event; the caret goes to the end once that has happened.
2853
+ queueMicrotask(() => {
2854
+ const end = input.value.length
2855
+ try { input.setSelectionRange(end, end) } catch { /* not a text input */ }
2856
+ })
2857
+ growEditor(input)
2858
+ if (input.value.startsWith('=')) formulaDraft = input.value
2859
+ }
2860
+
2861
+ function onEditorInput(event: Event) {
2862
+ const input = editorOf(event.target)
2863
+ if (!input) return
2864
+ // Typed characters complete; a deletion, a paste or the completion's
2865
+ // own input event leave the text as it is, so Backspace takes the
2866
+ // suggestion away rather than bringing it straight back.
2867
+ if (event instanceof InputEvent && event.inputType === 'insertText') autoComplete(input)
2868
+ growEditor(input)
2869
+ editingText = input.value
2870
+ formulaDraft = input.value.startsWith('=') ? input.value : null
2871
+ }
2872
+
2873
+ /**
2874
+ * Excel's AutoComplete: the text entries in the column's run around the
2875
+ * cell are offered as the cell is typed into, and when what was typed
2876
+ * fits exactly one of them the cell shows the rest of it, selected, so
2877
+ * that the next keystroke overwrites it and Backspace takes it away. What
2878
+ * was typed stays as typed until Enter or Tab accepts the suggestion,
2879
+ * when the cell takes the entry's own case ("jo" becomes "John Smith").
2880
+ */
2881
+ let suggested: string | null = null
2882
+ function autoComplete(input: HTMLInputElement | HTMLTextAreaElement) {
2883
+ suggested = null
2884
+ const typed = input.value
2885
+ if (input.selectionStart !== typed.length || input.selectionEnd !== typed.length) return
2886
+ const { rowIndex, colIndex } = active
2887
+ const entry = completeEntry(typed, columnRunEntries(rowIndex, colIndex))
2888
+ if (entry === null || entry === typed) return
2889
+ suggested = entry
2890
+ input.value = typed + entry.slice(typed.length)
2891
+ input.setSelectionRange(typed.length, entry.length)
2892
+ // The grid keeps its own copy of the draft and reads it from input events.
2893
+ input.dispatchEvent(new Event('input', { bubbles: true }))
2894
+ }
2895
+ /** Enter or Tab on a standing suggestion: the entry, in its own case. */
2896
+ function acceptSuggestion(event: KeyboardEvent) {
2897
+ if (suggested === null || (event.key !== 'Enter' && event.key !== 'Tab') || event.altKey) return
2898
+ const input = editorOf(event.target)
2899
+ if (!input) return
2900
+ const entry = suggested
2901
+ suggested = null
2902
+ const tail = input.selectionStart ?? input.value.length
2903
+ if (input.selectionEnd !== input.value.length || tail >= input.value.length) return
2904
+ if (input.value.toLowerCase() !== entry.toLowerCase()) return
2905
+ input.value = entry
2906
+ input.dispatchEvent(new Event('input', { bubbles: true }))
2907
+ }
2908
+
2909
+ /** The distinct text entries in the column, contiguous with the cell. */
2910
+ function columnRunEntries(rowIndex: number, colIndex: number): Set<string> {
2911
+ const out = new Set<string>()
2912
+ // null ends the run (a blank); '' keeps it going without offering
2913
+ // anything (a number, a formula, a boolean).
2914
+ const textAt = (r: number): string | null => {
2915
+ const raw = wb.getRaw(wb.active, r, colIndex)
2916
+ if (raw === '') return null
2917
+ if (raw.startsWith('=')) return ''
2918
+ return typeof wb.getValue(wb.active, r, colIndex) === 'string' ? raw : ''
2919
+ }
2920
+ for (let r = rowIndex - 1; r >= 0; r -= 1) {
2921
+ const t = textAt(r)
2922
+ if (t === null) break
2923
+ if (t) out.add(t)
2924
+ }
2925
+ for (let r = rowIndex + 1; r < rowCount; r += 1) {
2926
+ const t = textAt(r)
2927
+ if (t === null) break
2928
+ if (t) out.add(t)
2929
+ }
2930
+ return out
2931
+ }
2932
+
2933
+ function onEditorFocusOut(event: FocusEvent) {
2934
+ if (!editorOf(event.target)) return
2935
+ formulaDraft = null
2936
+ editingText = null
2937
+ }
2938
+
2939
+ // --- Excel's reference colours -------------------------------------------
2940
+ /**
2941
+ * While a formula is being typed, every cell it refers to is outlined in
2942
+ * its own colour and the reference in the text shares it, which is how a
2943
+ * spreadsheet user checks a formula without reading it. Only the active
2944
+ * sheet's cells can be painted, so references into other sheets are left
2945
+ * to the text.
2946
+ */
2947
+ let formulaDraft = $state<string | null>(null)
2948
+
2949
+ /** The distinct ranges a formula names, first occurrence first. */
2950
+ function referencesIn(text: string): Array<{ key: string; colour: string; rect: readonly [number, number, number, number] }> {
2951
+ const seen = new Set<string>()
2952
+ return referenceSpans(text).filter((span) => !seen.has(span.key) && (seen.add(span.key), true))
2953
+ }
2954
+
2955
+ let painted: HTMLElement[] = []
2956
+ function paintReferences() {
2957
+ for (const el of painted) {
2958
+ delete el.dataset.sheetRef
2959
+ el.style.removeProperty('--sheet-ref')
2960
+ el.style.removeProperty('--sheet-ref-edges')
2961
+ }
2962
+ painted = []
2963
+ const host = root
2964
+ if (!host || formulaDraft === null) { clearEditorMirror(); return }
2965
+ for (const { rect, colour } of referencesIn(formulaDraft)) {
2966
+ const [r1, c1, r2, c2] = rect
2967
+ // Only what is rendered: the rows are virtualised, and a reference to
2968
+ // row 5000 has nothing on screen to paint. A range is one rectangle,
2969
+ // as Excel draws it: each cell carries only the edges it sits on.
2970
+ for (const td of host.querySelectorAll<HTMLElement>('td[data-svgrid-row][data-col-id]')) {
2971
+ const r = Number(td.dataset.svgridRow)
2972
+ const c = lettersToCol(td.dataset.colId ?? '')
2973
+ if (r < r1 || r > r2 || c < c1 || c > c2) continue
2974
+ const edges: string[] = []
2975
+ if (r === r1) edges.push('inset 0 1.5px 0 var(--sheet-ref)')
2976
+ if (r === r2) edges.push('inset 0 -1.5px 0 var(--sheet-ref)')
2977
+ if (c === c1) edges.push('inset 1.5px 0 0 var(--sheet-ref)')
2978
+ if (c === c2) edges.push('inset -1.5px 0 0 var(--sheet-ref)')
2979
+ td.dataset.sheetRef = ''
2980
+ td.style.setProperty('--sheet-ref', colour)
2981
+ td.style.setProperty('--sheet-ref-edges', edges.length ? edges.join(', ') : 'none')
2982
+ painted.push(td)
2983
+ }
2984
+ }
2985
+ paintEditorText()
2986
+ }
2987
+ $effect(() => {
2988
+ void formulaDraft
2989
+ void version
2990
+ const frame = requestAnimationFrame(paintReferences)
2991
+ return () => cancelAnimationFrame(frame)
2992
+ })
2993
+
2994
+ /**
2995
+ * The formula's own text, each reference in its range's colour. An input
2996
+ * cannot colour a run of its text, so a mirror of the text is laid over
2997
+ * the editor with the references in colour, and the editor's text goes
2998
+ * transparent under it; the caret keeps its colour. The mirror copies the
2999
+ * editor's box and type so the glyphs land on their own. Gone with the
3000
+ * edit, or the moment the draft stops being a formula.
3001
+ */
3002
+ let mirror: HTMLElement | null = null
3003
+ function clearEditorMirror() {
3004
+ const input = mirror?.parentElement?.querySelector<HTMLElement>('.sv-grid-cell-editor')
3005
+ input?.classList.remove('sheet-ref-text')
3006
+ mirror?.remove()
3007
+ mirror = null
3008
+ }
3009
+ function paintEditorText() {
3010
+ const input = root?.querySelector<HTMLInputElement | HTMLTextAreaElement>('td.sv-grid-cell-editing .sv-grid-cell-editor') ?? null
3011
+ const spans = formulaDraft !== null && input ? referenceSpans(input.value) : []
3012
+ if (!input || spans.length === 0) { clearEditorMirror(); return }
3013
+ const td = input.closest<HTMLElement>('td')
3014
+ if (!td) { clearEditorMirror(); return }
3015
+ if (!mirror || mirror.parentElement !== td) {
3016
+ clearEditorMirror()
3017
+ mirror = document.createElement('div')
3018
+ mirror.className = 'sheet-ref-mirror'
3019
+ mirror.setAttribute('aria-hidden', 'true')
3020
+ td.appendChild(mirror)
3021
+ }
3022
+ const cs = getComputedStyle(input)
3023
+ Object.assign(mirror.style, {
3024
+ left: `${input.offsetLeft}px`, top: `${input.offsetTop}px`,
3025
+ width: `${input.offsetWidth}px`, height: `${input.offsetHeight}px`,
3026
+ font: cs.font, lineHeight: cs.lineHeight, letterSpacing: cs.letterSpacing,
3027
+ padding: cs.padding, textAlign: cs.textAlign, boxSizing: cs.boxSizing,
3028
+ borderWidth: cs.borderWidth, borderStyle: 'solid', borderColor: 'transparent',
3029
+ })
3030
+ fillMirror(mirror, input.value, spans)
3031
+ mirror.scrollLeft = input.scrollLeft
3032
+ mirror.scrollTop = input.scrollTop
3033
+ input.classList.add('sheet-ref-text')
3034
+ }
3035
+ /** The text as runs: plain, then a reference in its colour, and so on. */
3036
+ function fillMirror(host: HTMLElement, text: string, spans: ReferenceSpan[]) {
3037
+ host.replaceChildren()
3038
+ let at = 0
3039
+ for (const span of spans) {
3040
+ if (span.start > at) host.appendChild(document.createTextNode(text.slice(at, span.start)))
3041
+ const run = document.createElement('span')
3042
+ run.style.color = span.colour
3043
+ run.textContent = text.slice(span.start, span.end)
3044
+ host.appendChild(run)
3045
+ at = span.end
3046
+ }
3047
+ if (at < text.length) host.appendChild(document.createTextNode(text.slice(at)))
3048
+ }
3049
+ </script>
3050
+
3051
+ <!--
3052
+ A plain span, deliberately not a button.
3053
+
3054
+ An interactive element here competes with the grid for the pointer: it
3055
+ takes focus on mousedown, so the first double-click on a cell that was not
3056
+ already active never reached the grid's own dblclick handler and the edit
3057
+ was silently dropped. The grid owns click-to-activate, double-click-to-edit
3058
+ and drag-to-select; this only paints.
3059
+ -->
3060
+ <!--
3061
+ Excel's General alignment: numbers to the right, text to the left, errors
3062
+ and booleans centred, unless the cell says otherwise. That single rule is
3063
+ most of what makes a column of figures read as a spreadsheet.
3064
+ -->
3065
+ {#snippet Cell(props: { r: number; c: number })}
3066
+ {@const typing = barDraft !== null && props.r === active.rowIndex && props.c === active.colIndex}
3067
+ {@const entry = storeFor().get(`r${props.r}`, colToLetters(props.c))}
3068
+ {@const value = wb.getValue(wb.active, props.r, props.c)}
3069
+ {@const cf = showFormulas || typing ? null : cfAt(props.r, props.c, value)}
3070
+ {@const shown = typing ? { text: barDraft! } : display(props.r, props.c, cf?.style?.numFmt)}
3071
+ {@const spill = showFormulas ? 0 : spillWidth(props.r, props.c, value, entry)}
3072
+ {@const align = typing ? 'left' : entry?.align ?? (showFormulas ? 'left' : typeof value === 'number' ? 'right' : typeof value === 'boolean' || isError(value) ? 'center' : 'left')}
3073
+ {@const hashes = typeof value === 'number' && !showFormulas && !typing ? hashesFor(shown.text, props.c, entry, props.r, entry?.numFmt || cf?.style?.numFmt ? undefined : value) : null}
3074
+ {#if cf?.dataBar}
3075
+ <!-- Excel's data bar: behind the text, the value's share of the cell. -->
3076
+ <span class="sheet-databar" style:width="{Math.round(cf.dataBar.ratio * 100)}%" style:background={cf.dataBar.color}></span>
3077
+ {/if}
3078
+ {@const ants = marqueeEdges(props.r, props.c)}
3079
+ {#if ants}
3080
+ <!-- Excel's marching ants around the copied block: each cell on the
3081
+ outline draws the edges it sits on, so a block half scrolled out
3082
+ of view still shows the part of the outline that is in it. -->
3083
+ <span class="sheet-ants {ants}" aria-hidden="true"></span>
3084
+ {/if}
3085
+ <span
3086
+ class="sheet-cell"
3087
+ class:formula={showFormulas && shown.text.startsWith('=')}
3088
+ class:spill={spill > 0}
3089
+ class:wrap={!!entry?.wrap}
3090
+ class:has-icon={!!cf?.icon}
3091
+ style={`text-align:${align};${entryToStyle(entry)}${cf?.style ? `;${entryToStyle(cf.style)}` : ''}${shown.color ? `;color:${shown.color}` : ''}${spill > 0 ? `;max-width:calc(100% + ${spill}px)` : ''}`}
3092
+ title={hashes ? shown.text : raw(props.r, props.c)}
3093
+ >{#if cf?.icon}{@render cfIcon(cf.icon.set, cf.icon.index)}{/if}{hashes ?? shown.text}</span>
3094
+ {@const arrow = filterArrowAt(props.r, props.c)}
3095
+ {#if arrow}
3096
+ <!-- Excel's AutoFilter arrow on the region's header row: a funnel once
3097
+ the column is filtered. The pointer stops here so the click opens
3098
+ the menu rather than starting a selection drag. -->
3099
+ <button
3100
+ type="button"
3101
+ class="sheet-filter-arrow"
3102
+ class:filtered={arrow === 'filtered'}
3103
+ aria-label={arrow === 'filtered' ? 'Filter (filtered)' : 'Filter'}
3104
+ onpointerdown={(event) => event.stopPropagation()}
3105
+ onclick={(event) => { event.stopPropagation(); openFilterMenu(props.c) }}
3106
+ >{#if arrow === 'filtered'}<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M1 1.5h8L6 5.5v3L4 9.5v-4z" fill="currentColor" /></svg>{:else}<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M2 3.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" /></svg>{/if}</button>
3107
+ {/if}
3108
+ {#if props.r === active.rowIndex && props.c === active.colIndex && listRuleAt(props.r, props.c)}
3109
+ <!-- Excel's in-cell dropdown arrow, on the active cell of a list rule.
3110
+ The pointer stops here so the click opens the list rather than
3111
+ starting a selection drag. -->
3112
+ <button
3113
+ type="button"
3114
+ class="sheet-dropdown-arrow"
3115
+ aria-label="Open the list"
3116
+ onpointerdown={(event) => event.stopPropagation()}
3117
+ onclick={(event) => { event.stopPropagation(); openListPicker(props.r, props.c) }}
3118
+ ><svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M2 3.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" /></svg></button>
3119
+ {/if}
3120
+ {/snippet}
3121
+
3122
+ <!-- Excel's icon sets, three glyphs each: the top, the middle, the bottom third. -->
3123
+ {#snippet cfIcon(set: 'arrows' | 'traffic' | 'flags' | 'symbols', index: 0 | 1 | 2)}
3124
+ {@const colour = ['#3c9b3a', '#e0b000', '#d03a2c'][index]}
3125
+ <svg class="sheet-cf-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
3126
+ {#if set === 'arrows'}
3127
+ {#if index === 0}<path d="M6 10.5V2M2.5 5.5L6 2l3.5 3.5" fill="none" stroke={colour} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
3128
+ {:else if index === 1}<path d="M1.5 6h9M7 2.5L10.5 6 7 9.5" fill="none" stroke={colour} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
3129
+ {:else}<path d="M6 1.5V10M2.5 6.5L6 10l3.5-3.5" fill="none" stroke={colour} stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />{/if}
3130
+ {:else if set === 'traffic'}
3131
+ <circle cx="6" cy="6" r="4.5" fill={colour} />
3132
+ {:else if set === 'flags'}
3133
+ <path d="M2.5 11V1.5h6.5L7.5 4l1.5 2.5H2.5" fill={colour} stroke={colour} stroke-width="1" stroke-linejoin="round" />
3134
+ {:else}
3135
+ {#if index === 0}<circle cx="6" cy="6" r="5" fill={colour} /><path d="M3.5 6.2l1.8 1.8L8.6 4.5" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
3136
+ {:else if index === 1}<path d="M6 1l5.2 9.5H.8z" fill={colour} /><path d="M6 4.5v3M6 9.2v.1" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" />
3137
+ {:else}<circle cx="6" cy="6" r="5" fill={colour} /><path d="M4 4l4 4M8 4l-4 4" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" />{/if}
3138
+ {/if}
3139
+ </svg>
3140
+ {/snippet}
3141
+
3142
+ <!-- Excel's Select All corner: an empty cell with a small triangle. -->
3143
+ {#snippet corner()}
3144
+ <span class="corner" aria-hidden="true"></span>
3145
+ {/snippet}
3146
+
3147
+ <div class="sv-sheet" class:fill={height === '100%'} class:look-excel={look === 'excel'} class:no-gridlines={!gridlinesOn} class:no-headings={!headingsOn} bind:this={root}>
3148
+ {#if showRibbon}
3149
+ <SvSheetRibbon
3150
+ cmd={cmdOf}
3151
+ onChange={bump}
3152
+ onAction={handleAction}
3153
+ {activeActions}
3154
+ {without}
3155
+ bind:collapsed={ribbonCollapsed}
3156
+ />
3157
+ {/if}
3158
+
3159
+ {#if showFormulaBar && formulaBarOn}
3160
+ <SvFormulaBar
3161
+ active={active}
3162
+ value={editingText ?? activeRaw}
3163
+ onCommit={commit}
3164
+ onNavigate={goTo}
3165
+ onSelectName={jumpToName}
3166
+ onInsertFunction={() => { const c = cmdOf(); if (c) delegate('insert-function', c) }}
3167
+ onDraft={(text) => { formulaDraft = text; barDraft = text }}
3168
+ label={dragLabel}
3169
+ highlight={formulaDraft !== null ? referenceSpans : undefined}
3170
+ names={definedNames}
3171
+ />
3172
+ {/if}
3173
+
3174
+ {#if showComments}
3175
+ <!-- Show All Comments: the honest form over a virtualised sheet is a
3176
+ list, each entry a jump to its cell. -->
3177
+ <div class="sheet-comments" aria-label="Comments on this sheet">
3178
+ {#if allComments.length === 0}
3179
+ <span class="none">No comments on this sheet.</span>
3180
+ {:else}
3181
+ {#each allComments as entry (`${entry.row}:${entry.col}`)}
3182
+ <button type="button" class="entry" onclick={() => openCommentEditor(entry.row, entry.col)}>
3183
+ <span class="addr">{colToLetters(entry.col)}{entry.row + 1}</span>
3184
+ <span class="text">{entry.text}</span>
3185
+ </button>
3186
+ {/each}
3187
+ {/if}
3188
+ </div>
3189
+ {/if}
3190
+
3191
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
3192
+ <div
3193
+ class="sheet-grid"
3194
+ class:fill={height === '100%'}
3195
+ bind:this={gridHost}
3196
+ onpointerdowncapture={onSheetPointerDown}
3197
+ oncontextmenucapture={onSheetContextMenu}
3198
+ onfocusin={onEditorFocusIn}
3199
+ onfocusout={onEditorFocusOut}
3200
+ oninput={onEditorInput}
3201
+ onkeydowncapture={onSheetKeyDownCapture}
3202
+ onpointerupcapture={onSheetPointerUp}
3203
+ class:painting={painter !== null}
3204
+ onscrollcapture={() => { if (formulaDraft !== null) paintReferences(); if (cellPopover) measureAnchor() }}
3205
+ >
3206
+ {#if resizeGuide}
3207
+ <div class="sheet-resize-guide" class:col={resizeGuide.axis === 'col'} class:row={resizeGuide.axis === 'row'} aria-hidden="true" style:left={resizeGuide.axis === 'col' ? `${resizeGuide.at}px` : '0'} style:top={resizeGuide.axis === 'row' ? `${resizeGuide.at}px` : '0'}></div>
3208
+ {/if}
3209
+ {#if resizeTip}
3210
+ <div class="sheet-resize-tip" role="status" style:left={`${resizeTip.x}px`} style:top={`${resizeTip.y}px`}>{resizeTip.text}</div>
3211
+ {/if}
3212
+ {#if cellPopover && anchorRect}
3213
+ <!-- An invisible box over the cell for the note to point at. -->
3214
+ <div class="sheet-cell-anchor" style:left="{anchorRect.left}px" style:top="{anchorRect.top}px">
3215
+ <SvPopover
3216
+ trigger="manual"
3217
+ open={true}
3218
+ onOpenChange={(open) => { if (!open) closeCellPopover() }}
3219
+ placement={cellPopover.kind === 'comment' ? 'right-start' : 'bottom-start'}
3220
+ offset={cellPopover.kind === 'comment' ? 6 : 2}
3221
+ arrow={cellPopover.kind === 'comment'}
3222
+ ariaLabel={cellPopover.kind === 'list' ? 'Choices' : cellPopover.kind === 'filter' ? 'Filter' : 'Comment'}
3223
+ >
3224
+ {#snippet anchor()}<span class="box" style:width="{anchorRect!.width}px" style:height="{anchorRect!.height}px"></span>{/snippet}
3225
+ {#if cellPopover.kind === 'comment'}
3226
+ <SvSheetComment
3227
+ text={cellPopover.initial}
3228
+ address={`${colToLetters(cellPopover.c)}${cellPopover.r + 1}`}
3229
+ onDraft={(text) => { if (cellPopover?.kind === 'comment') cellPopover.draft = text }}
3230
+ onSave={(text) => { if (cellPopover?.kind === 'comment') cellPopover.draft = text; closeCellPopover() }}
3231
+ onDelete={() => { const pop = cellPopover; closeCellPopover(false); if (pop) setComment(pop.r, pop.c, '') }}
3232
+ />
3233
+ {:else if cellPopover.kind === 'list'}
3234
+ <SvSheetListPicker
3235
+ choices={cellPopover.choices}
3236
+ value={raw(cellPopover.r, cellPopover.c)}
3237
+ onPick={pickChoice}
3238
+ onCancel={() => closeCellPopover()}
3239
+ />
3240
+ {:else}
3241
+ <SvSheetFilterMenu
3242
+ header={cellPopover.header}
3243
+ values={cellPopover.values}
3244
+ filter={cellPopover.filter}
3245
+ numeric={cellPopover.numeric}
3246
+ onSort={(direction) => { const pop = cellPopover; closeCellPopover(); if (pop) { const af = autoFilterNow(); if (af) sortRegion(direction, { rect: af.range, keyCol: pop.c }) } }}
3247
+ onApply={(filter) => { const pop = cellPopover; closeCellPopover(); const af = autoFilterNow(); if (pop && af) applyAutoFilter(withColumnFilter(af, pop.c, filter), cmdOf()) }}
3248
+ onCancel={() => closeCellPopover()}
3249
+ />
3250
+ {/if}
3251
+ </SvPopover>
3252
+ </div>
3253
+ {/if}
3254
+ <SvGrid
3255
+ data={gridRows}
3256
+ {columns}
3257
+ {features}
3258
+ selectionMode="cell"
3259
+ enableCellSelection={true}
3260
+ showRowNumbers={headingsOn}
3261
+ rowNumberWidth={40}
3262
+ {rowHeight}
3263
+ icons={{ 'row-number': corner }}
3264
+ columnResize={!isProtected}
3265
+ rowResize={!isProtected}
3266
+ {contextMenu}
3267
+ notes={activeNotes}
3268
+ mergedCells={activeMerges}
3269
+ rowClass={({ rowIndex }: { rowIndex: number }) =>
3270
+ activeFilter && isFiltering(activeFilter) && rowIndex > activeFilter.range[0] && rowIndex <= activeFilter.range[2] ? 'sheet-filtered' : undefined}
3271
+ containerHeight={height}
3272
+ enableInlineEditing={true}
3273
+ editOnSecondClick={false}
3274
+ onApiReady={(next: SheetApi) => {
3275
+ api = next
3276
+ // A document the consumer built (createSheetDocument({ state }), or
3277
+ // one seeded with widths, hidden lines, a filter, frozen panes) shows
3278
+ // its state from the first paint: the grid has just come up, so its
3279
+ // live parts are put on it the way a sheet switch would.
3280
+ applyLive(wb.active)
3281
+ const cmd = cmdOf()
3282
+ if (cmd) applyFreeze(cmd, doc.get(wb.active).freeze)
3283
+ // The filter's rows were just worked out: the status bar and the
3284
+ // rest of what reads them repaint once.
3285
+ bump()
3286
+ onReady?.(next, doc)
3287
+ }}
3288
+ onActiveCellChange={(cell: { rowIndex: number; colIndex: number }) => {
3289
+ // The grid is the single source of truth for where the cursor is, so
3290
+ // the formula bar and the status bar follow it rather than keeping a
3291
+ // second copy that can disagree after a keyboard move.
3292
+ active = { rowIndex: cell.rowIndex, colIndex: cell.colIndex }
3293
+ }}
3294
+ onCellValueChange={onCellWritten}
3295
+ onColumnResize={() => { stashWidths(); changed({ kind: 'sizes' }) }}
3296
+ onRowResize={(e) => { if (e.height === null) fitWrappedRows(); stashHeights(); changed({ kind: 'sizes' }) }}
3297
+ processCellForFill={({ value, delta }) =>
3298
+ typeof value === 'string' && value.startsWith('=') ? translateFormula(value, delta.rows, delta.cols) : undefined}
3299
+ processCellForClipboard={toClipboard}
3300
+ clipboardHtml={copiedHtml}
3301
+ onPasteClipboard={pasteFromSystem}
3302
+ onCellSelectionChange={(ranges: Array<[number, number, number, number]>) => {
3303
+ // Only the selection: the cells did not change, so nothing that
3304
+ // reads `version` (every cell's display, its rules, its width) needs
3305
+ // to run again. What follows the selection reads `selection` and
3306
+ // `active` instead; bumping here repainted the whole sheet on every
3307
+ // arrow key.
3308
+ selection = ranges
3309
+ }}
3310
+ />
3311
+ </div>
3312
+
3313
+ {#if showTabs}
3314
+ <SvSheetTabs
3315
+ workbook={wb}
3316
+ {version}
3317
+ onChange={bump}
3318
+ onRename={(from, to) => {
3319
+ // The document's entry follows the sheet, or renaming a sheet would
3320
+ // strip its formats.
3321
+ doc.rename(from, to)
3322
+ targetsBoundTo = to
3323
+ registerSheetTargets()
3324
+ }}
3325
+ onRemove={(name) => doc.remove(name)}
3326
+ />
3327
+ {/if}
3328
+
3329
+ {#if showStatusBar}
3330
+ <!-- Excel's status bar: Ready on the left, the selection's Average / Count / Sum on the right. -->
3331
+ <div class="status" role="status" aria-live="off">
3332
+ <span class="mode">{statusMessage ?? filterSummary ?? 'Ready'}</span>
3333
+ <span class="grow"></span>
3334
+ {#if aggregate.count > 1}
3335
+ {#if aggregate.numeric > 0}
3336
+ <span>Average: <b>{money(aggregate.average ?? 0)}</b></span>
3337
+ {/if}
3338
+ <span>Count: <b>{aggregate.count}</b></span>
3339
+ {#if aggregate.numeric > 0}
3340
+ <span>Sum: <b>{money(aggregate.sum)}</b></span>
3341
+ {/if}
3342
+ {/if}
3343
+ </div>
3344
+ {/if}
3345
+
3346
+ <SvSheetFindReplace bind:open={findOpen} cmd={cmdOf} onClose={() => { const c = cmdOf(); if (c) focusSheet(c) }} />
3347
+ <SvSheetPasteSpecial bind:open={pasteSpecialOpen} hasClipboard={copied !== null} onPaste={pasteSpecial} onClose={() => { const c = cmdOf(); if (c) focusSheet(c) }} />
3348
+ <SvSheetFormatCells bind:open={formatCellsOpen} entry={activeEntry} sample={activeValue} mixedLocked={mixedLocked} onApply={applyFormatCells} onClose={() => { const c = cmdOf(); if (c) focusSheet(c) }} />
3349
+ <SvSheetInsertFunction bind:open={insertFunctionOpen} onPick={insertFunction} />
3350
+ <SvSheetNameManager bind:open={nameManagerOpen} workbook={wb} onChange={() => { wb.recalculate(); bump() }} onClose={() => afterDialog()} />
3351
+ <SvSheetGoalSeek bind:open={goalSeekOpen} workbook={wb} cmd={cmdOf} onClose={() => afterDialog()} />
3352
+ <SvSheetTextToColumns bind:open={textToColumnsOpen} workbook={wb} cmd={cmdOf} onDone={say} onClose={() => afterDialog()} />
3353
+ <SvSheetRemoveDuplicates bind:open={removeDuplicatesOpen} workbook={wb} cmd={cmdOf} onDone={say} onClose={() => afterDialog()} />
3354
+ {#if cfDialog}
3355
+ <SvSheetConditionalFormat
3356
+ open={true}
3357
+ preset={cfDialog.preset}
3358
+ rule={cfDialog.rule}
3359
+ address={selectionAddress}
3360
+ onApply={applyCfBody}
3361
+ onClose={() => { cfDialog = null; afterDialog() }}
3362
+ />
3363
+ {/if}
3364
+ <SvSheetManageRules
3365
+ bind:open={manageRulesOpen}
3366
+ rules={cfRules}
3367
+ selection={cfSelection}
3368
+ onApply={(rules) => setCf(rules)}
3369
+ onEdit={(rule, replace) => {
3370
+ const preset: CfPreset =
3371
+ rule.kind === 'cellIs' ? (rule.operator === 'between' ? 'between' : rule.operator === 'less' ? 'less' : rule.operator === 'equal' ? 'equal' : 'greater')
3372
+ : rule.kind === 'text' ? 'text'
3373
+ : rule.kind === 'duplicates' ? 'duplicates'
3374
+ : rule.kind === 'topBottom' ? (rule.top ? 'top10' : 'bottom10')
3375
+ : rule.above ? 'aboveAverage' : 'belowAverage'
3376
+ cfDialog = { preset, rule, replace }
3377
+ }}
3378
+ onClose={() => afterDialog()}
3379
+ />
3380
+ <SvSheetDataValidation
3381
+ bind:open={dataValidationOpen}
3382
+ rule={activeRule}
3383
+ address={selectionAddress}
3384
+ onApply={applyValidation}
3385
+ onClear={clearValidation}
3386
+ onClose={() => afterDialog()}
3387
+ />
3388
+ <SvSheetValidationAlert
3389
+ open={pendingAlert !== null}
3390
+ alert={pendingAlert?.verdict ?? null}
3391
+ onRetry={retryEntry}
3392
+ onAccept={acceptEntry}
3393
+ onCancel={() => { pendingAlert = null; const c = cmdOf(); if (c) focusSheet(c) }}
3394
+ />
3395
+ <SvModal open={mergeConfirm !== null} title="Merge cells" size="sm" onClose={() => { mergeConfirm = null; const c = cmdOf(); if (c) focusSheet(c) }}>
3396
+ <div class="sv-sheet-dialog">
3397
+ <p class="note">Merging cells only keeps the upper-left cell value and discards the other values.</p>
3398
+ </div>
3399
+ {#snippet footer()}
3400
+ <div class="sv-sheet-dialog-buttons">
3401
+ <button type="button" class="btn primary" onclick={() => { const pending = mergeConfirm; mergeConfirm = null; const c = cmdOf(); if (pending && c) applyMerge(pending.plan, c) }}>OK</button>
3402
+ <button type="button" class="btn" onclick={() => { mergeConfirm = null; const c = cmdOf(); if (c) focusSheet(c) }}>Cancel</button>
3403
+ </div>
3404
+ {/snippet}
3405
+ </SvModal>
3406
+ <SvSheetSizeDialog
3407
+ open={sizeDialogOpen}
3408
+ title={sizeDialog === 'rows' ? 'Row Height' : 'Column Width'}
3409
+ label={sizeDialog === 'rows' ? 'Row height (px):' : 'Column width (px):'}
3410
+ value={sizeCurrent}
3411
+ onApply={applySize}
3412
+ onClose={() => (sizeDialog = null)}
3413
+ />
3414
+ </div>
3415
+
3416
+ <style>
3417
+ /* ---- the dialogs --------------------------------------------------- */
3418
+ /* Portalled to <body>, so these rules are global: one look for Find and
3419
+ Replace, Paste Special, Format Cells and Insert Function. */
3420
+ :global(.sv-sheet-dialog) {
3421
+ display: flex;
3422
+ flex-direction: column;
3423
+ gap: 10px;
3424
+ font-family: var(--sg-font, "Segoe UI", system-ui, sans-serif);
3425
+ font-size: 13px;
3426
+ color: var(--sg-fg, #242424);
3427
+ }
3428
+ :global(.sv-sheet-dialog .field) {
3429
+ display: grid;
3430
+ grid-template-columns: 110px 1fr;
3431
+ align-items: center;
3432
+ gap: 8px;
3433
+ }
3434
+ :global(.sv-sheet-dialog .field.auto) { grid-template-columns: auto 1fr; }
3435
+ :global(.sv-sheet-dialog input[type="text"]),
3436
+ :global(.sv-sheet-dialog input[type="number"]),
3437
+ :global(.sv-sheet-dialog select) {
3438
+ height: 26px;
3439
+ padding: 0 6px;
3440
+ font: inherit;
3441
+ color: inherit;
3442
+ background: var(--sg-bg, #fff);
3443
+ border: 1px solid var(--sg-border, #d1d1d1);
3444
+ border-radius: var(--sg-radius, 3px);
3445
+ }
3446
+ :global(.sv-sheet-dialog input[type="number"]) { width: 72px; }
3447
+ :global(.sv-sheet-dialog input[type="text"]:focus),
3448
+ :global(.sv-sheet-dialog input[type="number"]:focus),
3449
+ :global(.sv-sheet-dialog select:focus) {
3450
+ outline: none;
3451
+ border-color: var(--sg-accent, #217346);
3452
+ box-shadow: 0 0 0 1px var(--sg-accent, #217346);
3453
+ }
3454
+ :global(.sv-sheet-dialog .checks) {
3455
+ display: flex;
3456
+ flex-wrap: wrap;
3457
+ align-items: center;
3458
+ gap: 6px 16px;
3459
+ }
3460
+ :global(.sv-sheet-dialog .check) {
3461
+ display: inline-flex;
3462
+ align-items: center;
3463
+ gap: 6px;
3464
+ cursor: default;
3465
+ }
3466
+ :global(.sv-sheet-dialog .status) {
3467
+ min-height: 18px;
3468
+ margin: 0;
3469
+ color: var(--sg-muted, #616161);
3470
+ }
3471
+ :global(.sv-sheet-dialog .note) {
3472
+ margin: 0;
3473
+ line-height: 1.5;
3474
+ }
3475
+ :global(.sv-sheet-dialog-buttons) {
3476
+ display: flex;
3477
+ flex-wrap: wrap;
3478
+ justify-content: flex-end;
3479
+ gap: 6px;
3480
+ }
3481
+ :global(.sv-sheet-dialog-buttons .btn) {
3482
+ height: 26px;
3483
+ padding: 0 14px;
3484
+ font-family: var(--sg-font, "Segoe UI", system-ui, sans-serif);
3485
+ font-size: 13px;
3486
+ color: var(--sg-fg, #242424);
3487
+ background: var(--sg-bg, #fff);
3488
+ border: 1px solid var(--sg-border, #d1d1d1);
3489
+ border-radius: var(--sg-radius, 3px);
3490
+ cursor: pointer;
3491
+ }
3492
+ :global(.sv-sheet-dialog-buttons .btn:hover:not(:disabled)) { background: var(--sg-row-hover-bg, #f5f5f5); }
3493
+ :global(.sv-sheet-dialog-buttons .btn:disabled) { opacity: 0.5; cursor: default; }
3494
+ :global(.sv-sheet-dialog-buttons .btn.primary) {
3495
+ color: var(--sg-on-accent, #fff);
3496
+ background: var(--sg-accent, #217346);
3497
+ border-color: var(--sg-accent, #217346);
3498
+ }
3499
+ :global(.sv-sheet-dialog-buttons .btn.primary:hover:not(:disabled)) {
3500
+ background: var(--sg-accent, #217346);
3501
+ filter: brightness(1.08);
3502
+ }
3503
+
3504
+ .sv-sheet {
3505
+ display: flex;
3506
+ flex-direction: column;
3507
+ gap: 0;
3508
+ /* A comment's mark is red on a spreadsheet whatever the theme, as the
3509
+ fill handle is the accent: the theme's danger colour, Excel's red
3510
+ where a theme has none. The grid's own amber is a data-grid
3511
+ convention, not a sheet's. */
3512
+ --sg-note-corner: var(--sg-danger, #c42b1c);
3513
+ font-family: var(--sg-font, "Segoe UI", system-ui, sans-serif);
3514
+ color: var(--sg-fg, #242424);
3515
+ background: var(--sg-bg-subtle, var(--sg-header-bg, #f3f3f3));
3516
+ /* A sheet's headers are 22px whatever a preset asks of a data grid's
3517
+ (Ember's are 48px), and its text is set solid: a host page's prose
3518
+ line-height must not stretch the rows. */
3519
+ --sg-header-min-height: 0px;
3520
+ line-height: 1.3;
3521
+ }
3522
+ /* height="100%": the shell is a flex item and the grid takes what the
3523
+ chrome leaves. min-height: 0 on both, or the flex item refuses to shrink
3524
+ below the grid's content and overflows exactly as a fixed height did. */
3525
+ .sv-sheet.fill {
3526
+ flex: 1 1 0;
3527
+ min-height: 0;
3528
+ height: 100%;
3529
+ }
3530
+ .sheet-grid {
3531
+ display: flex;
3532
+ flex-direction: column;
3533
+ position: relative;
3534
+ border-top: 1px solid var(--sg-border, #d9d9d9);
3535
+ }
3536
+ /* The comment editor's anchor: an invisible box laid over the cell,
3537
+ inert to the pointer so the cell under it still takes clicks. */
3538
+ /* Excel's dotted guide at the border being dragged, across the sheet. */
3539
+ /* Above the grid's sticky header (z-index 30), as Excel draws it through
3540
+ the header band too; the tip above everything the sheet draws. */
3541
+ .sheet-resize-guide {
3542
+ position: absolute;
3543
+ z-index: 40;
3544
+ pointer-events: none;
3545
+ }
3546
+ .sheet-resize-guide.col { top: 0; bottom: 0; width: 0; border-left: 1px dotted var(--sg-fg, #242424); }
3547
+ .sheet-resize-guide.row { left: 0; right: 0; height: 0; border-top: 1px dotted var(--sg-fg, #242424); }
3548
+ /* Excel's resize tip, beside the pointer while a border is dragged. */
3549
+ .sheet-resize-tip {
3550
+ position: absolute;
3551
+ z-index: 1000;
3552
+ padding: 2px 6px;
3553
+ font-size: 12px;
3554
+ line-height: 1.4;
3555
+ white-space: nowrap;
3556
+ pointer-events: none;
3557
+ color: var(--sg-fg, #242424);
3558
+ background: var(--sg-bg, #fff);
3559
+ border: 1px solid var(--sg-border, #d1d1d1);
3560
+ border-radius: 3px;
3561
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
3562
+ }
3563
+ .sheet-cell-anchor {
3564
+ position: absolute;
3565
+ z-index: 7;
3566
+ pointer-events: none;
3567
+ }
3568
+ .sheet-cell-anchor .box { display: inline-block; }
3569
+ /* The in-cell dropdown arrow: a small button at the cell's right edge,
3570
+ above the cell text like the note corner. */
3571
+ .sheet-dropdown-arrow {
3572
+ position: absolute;
3573
+ top: 50%;
3574
+ right: 1px;
3575
+ transform: translateY(-50%);
3576
+ width: 16px;
3577
+ height: 16px;
3578
+ display: grid;
3579
+ place-items: center;
3580
+ padding: 0;
3581
+ color: var(--sg-fg, #242424);
3582
+ background: var(--sg-bg, #fff);
3583
+ border: 1px solid var(--sg-border, #d1d1d1);
3584
+ border-radius: 2px;
3585
+ cursor: pointer;
3586
+ z-index: 6;
3587
+ }
3588
+ .sheet-dropdown-arrow:hover { background: var(--sg-row-hover-bg, #f0f0f0); }
3589
+ /* The AutoFilter arrow on a header cell, a funnel once filtered. */
3590
+ .sheet-filter-arrow {
3591
+ position: absolute;
3592
+ top: 50%;
3593
+ right: 1px;
3594
+ transform: translateY(-50%);
3595
+ width: 16px;
3596
+ height: 16px;
3597
+ display: grid;
3598
+ place-items: center;
3599
+ padding: 0;
3600
+ color: var(--sg-muted, #616161);
3601
+ background: var(--sg-bg, #fff);
3602
+ border: 1px solid var(--sg-border, #d1d1d1);
3603
+ border-radius: 2px;
3604
+ cursor: pointer;
3605
+ z-index: 6;
3606
+ }
3607
+ .sheet-filter-arrow:hover { background: var(--sg-row-hover-bg, #f0f0f0); }
3608
+ .sheet-filter-arrow.filtered { color: var(--sg-info, #0f6cbd); }
3609
+ /* Excel turns the row numbers of a filtered region blue. */
3610
+ .sv-sheet :global(tr.sheet-filtered > td.sv-grid-row-number-cell) { color: var(--sg-info, #0f6cbd); }
3611
+ /* A data bar behind the cell text, a third of the height off the edges
3612
+ as Excel draws it. */
3613
+ .sheet-databar {
3614
+ position: absolute;
3615
+ left: 1px;
3616
+ top: 2px;
3617
+ bottom: 2px;
3618
+ border-radius: 1px;
3619
+ opacity: 0.85;
3620
+ pointer-events: none;
3621
+ }
3622
+ /* Excel's marching ants: a dashed line on the cell's outline edges, the
3623
+ dashes walking clockwise. Drawn as background layers, one per edge, so
3624
+ it costs the compositor and nothing else; above the cell text and the
3625
+ data bar, under the editor and the note corner (z-index 6). Each layer
3626
+ is one 8px tile of the dash repeated along its edge, so sliding it a
3627
+ tile per cycle is seamless: a single full-width stretch slid the same
3628
+ way opened a gap at one end and closed it at the other, which read as
3629
+ a flicker rather than a walk. The walk is the signal that the sheet is
3630
+ in copy mode, as it is in Excel, so it is not switched off for
3631
+ prefers-reduced-motion: two pixels of dash, nothing else moves. */
3632
+ .sheet-ants {
3633
+ position: absolute;
3634
+ inset: 0;
3635
+ z-index: 5;
3636
+ pointer-events: none;
3637
+ --ants-on: var(--sg-fg, #242424);
3638
+ --ants-off: var(--sg-bg, #fff);
3639
+ --ants-top: none;
3640
+ --ants-bottom: none;
3641
+ --ants-left: none;
3642
+ --ants-right: none;
3643
+ background-image: var(--ants-top), var(--ants-bottom), var(--ants-left), var(--ants-right);
3644
+ background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
3645
+ background-size: 8px 2px, 8px 2px, 2px 8px, 2px 8px;
3646
+ background-position: 0 0, 0 100%, 0 0, 100% 0;
3647
+ animation: sheet-ants 0.35s linear infinite;
3648
+ }
3649
+ .sheet-ants.top { --ants-top: linear-gradient(to right, var(--ants-on) 0 4px, var(--ants-off) 4px 8px); }
3650
+ .sheet-ants.bottom { --ants-bottom: linear-gradient(to right, var(--ants-on) 0 4px, var(--ants-off) 4px 8px); }
3651
+ .sheet-ants.left { --ants-left: linear-gradient(to bottom, var(--ants-on) 0 4px, var(--ants-off) 4px 8px); }
3652
+ .sheet-ants.right { --ants-right: linear-gradient(to bottom, var(--ants-on) 0 4px, var(--ants-off) 4px 8px); }
3653
+ @keyframes sheet-ants {
3654
+ to { background-position: 8px 0, -8px 100%, 0 -8px, 100% 8px; }
3655
+ }
3656
+ /* An icon at the left of the cell; the text keeps its own alignment. */
3657
+ .sheet-cell.has-icon { padding-left: 20px; }
3658
+ .sheet-cf-icon {
3659
+ position: absolute;
3660
+ left: 4px;
3661
+ top: 50%;
3662
+ transform: translateY(-50%);
3663
+ }
3664
+ /* Show All Comments: a strip under the formula bar. */
3665
+ .sheet-comments {
3666
+ display: flex;
3667
+ flex-wrap: wrap;
3668
+ gap: 4px 8px;
3669
+ max-height: 96px;
3670
+ overflow-y: auto;
3671
+ padding: 4px 8px;
3672
+ font-size: 12px;
3673
+ border-top: 1px solid var(--sg-border, #d9d9d9);
3674
+ background: var(--sg-bg-subtle, #f3f3f3);
3675
+ }
3676
+ .sheet-comments .none { color: var(--sg-muted, #616161); }
3677
+ .sheet-comments .entry {
3678
+ display: inline-flex;
3679
+ gap: 6px;
3680
+ max-width: 320px;
3681
+ padding: 2px 8px;
3682
+ font: inherit;
3683
+ color: inherit;
3684
+ background: var(--sg-bg, #fff);
3685
+ border: 1px solid var(--sg-border, #d9d9d9);
3686
+ border-radius: 3px;
3687
+ cursor: pointer;
3688
+ }
3689
+ .sheet-comments .entry:hover { background: var(--sg-row-hover-bg, #f0f0f0); }
3690
+ .sheet-comments .addr { font-weight: 600; color: var(--sg-muted, #616161); }
3691
+ .sheet-comments .text {
3692
+ overflow: hidden;
3693
+ white-space: nowrap;
3694
+ text-overflow: ellipsis;
3695
+ }
3696
+ .sheet-grid.fill {
3697
+ flex: 1 1 0;
3698
+ min-height: 0;
3699
+ }
3700
+ /* The formula bar sits on the ribbon's grey ground, as it does in Excel. */
3701
+ .sv-sheet > :global(.sv-formula-bar) {
3702
+ margin: 4px 6px;
3703
+ }
3704
+ .sv-sheet > :global(.sv-ribbon) {
3705
+ border-left: 0;
3706
+ border-right: 0;
3707
+ border-top: 0;
3708
+ border-radius: 0;
3709
+ }
3710
+
3711
+ /*
3712
+ * Excel's own palette, light and dark with the page, for `look="excel"`.
3713
+ * Tokens, so every part underneath - the grid included - picks them up.
3714
+ * The default look leaves every token to the host's theme.
3715
+ */
3716
+ .sv-sheet.look-excel {
3717
+ /* Excel's note mark is a red triangle. */
3718
+ --sg-note-corner: #c42b1c;
3719
+ --sg-bg: #ffffff;
3720
+ --sg-fg: #242424;
3721
+ --sg-muted: #616161;
3722
+ --sg-border: #d9d9d9;
3723
+ --sg-header-bg: #f3f3f3;
3724
+ --sg-header-fg: #444444;
3725
+ --sg-bg-subtle: #f3f3f3;
3726
+ --sg-row-alt-bg: #ffffff;
3727
+ --sg-row-hover-bg: #f0f0f0;
3728
+ --sg-muted-bg: #e6e6e6;
3729
+ --sg-selection-bg: rgba(16, 124, 65, 0.12);
3730
+ --sg-input-bg: #ffffff;
3731
+ --sg-input-border: #c8c6c4;
3732
+ --sg-accent: #107c41;
3733
+ --sg-on-accent: #ffffff;
3734
+ --sg-focus-ring: #107c41;
3735
+ --sg-success: #107c41;
3736
+ --sg-info: #0f6cbd;
3737
+ --sg-warning: #f2c811;
3738
+ --sg-danger: #c42b1c;
3739
+ --sg-radius: 0px;
3740
+ --sg-radius-lg: 6px;
3741
+ --sg-font: "Aptos", "Aptos Narrow", "Segoe UI", Calibri, Arial, sans-serif;
3742
+ }
3743
+ :global([data-theme="dark"]) .sv-sheet.look-excel {
3744
+ --sg-bg: #1f1f1f;
3745
+ --sg-fg: #e6e6e6;
3746
+ --sg-muted: #a6a6a6;
3747
+ --sg-border: #3d3d3d;
3748
+ --sg-header-bg: #2b2b2b;
3749
+ --sg-header-fg: #d0d0d0;
3750
+ --sg-bg-subtle: #292929;
3751
+ --sg-row-alt-bg: #1f1f1f;
3752
+ --sg-row-hover-bg: #363636;
3753
+ --sg-muted-bg: #404040;
3754
+ --sg-selection-bg: rgba(63, 166, 110, 0.2);
3755
+ --sg-input-bg: #262626;
3756
+ --sg-input-border: #4a4a4a;
3757
+ --sg-accent: #3fa66e;
3758
+ --sg-on-accent: #ffffff;
3759
+ --sg-focus-ring: #3fa66e;
3760
+ --sg-success: #3fa66e;
3761
+ --sg-info: #4cc2ff;
3762
+ --sg-warning: #f2c811;
3763
+ --sg-danger: #f1707b;
3764
+ }
3765
+ @media (prefers-color-scheme: dark) {
3766
+ :global(:root:not([data-theme="light"])) .sv-sheet.look-excel {
3767
+ --sg-bg: #1f1f1f;
3768
+ --sg-fg: #e6e6e6;
3769
+ --sg-muted: #a6a6a6;
3770
+ --sg-border: #3d3d3d;
3771
+ --sg-header-bg: #2b2b2b;
3772
+ --sg-header-fg: #d0d0d0;
3773
+ --sg-bg-subtle: #292929;
3774
+ --sg-row-alt-bg: #1f1f1f;
3775
+ --sg-row-hover-bg: #363636;
3776
+ --sg-muted-bg: #404040;
3777
+ --sg-selection-bg: rgba(63, 166, 110, 0.2);
3778
+ --sg-input-bg: #262626;
3779
+ --sg-input-border: #4a4a4a;
3780
+ --sg-accent: #3fa66e;
3781
+ --sg-on-accent: #ffffff;
3782
+ --sg-focus-ring: #3fa66e;
3783
+ --sg-success: #3fa66e;
3784
+ --sg-info: #4cc2ff;
3785
+ --sg-warning: #f2c811;
3786
+ --sg-danger: #f1707b;
3787
+ }
3788
+ }
3789
+
3790
+ /* ---- the sheet's geometry ------------------------------------------ */
3791
+ /* Column letters: 22px, centred, plain weight; the gutter to match. */
3792
+ .sv-sheet :global(.sv-grid-head .sv-grid-column) {
3793
+ height: 22px;
3794
+ padding: 0;
3795
+ font-size: 12px;
3796
+ font-weight: 400;
3797
+ color: var(--sg-header-fg, var(--sg-muted, #444));
3798
+ }
3799
+ .sv-sheet :global(.sv-grid-head .sv-grid-column .sv-grid-header-cell) {
3800
+ justify-content: center;
3801
+ }
3802
+ .sv-sheet :global(.sv-grid-head .sv-grid-column .sv-grid-header-sort) {
3803
+ justify-content: center;
3804
+ }
3805
+ /* Row numbers are centred in the gutter, as Excel's are. */
3806
+ .sv-sheet :global(.sv-grid-row-number-cell) {
3807
+ font-size: 12px;
3808
+ padding: 0 4px;
3809
+ line-height: 1.3;
3810
+ text-align: center;
3811
+ }
3812
+ .sv-sheet :global(.sv-grid-head .sv-grid-column),
3813
+ .sv-sheet :global(td.sv-grid-cell) {
3814
+ line-height: 1.3;
3815
+ }
3816
+ /*
3817
+ * Header borders, as Excel draws them: a hairline between every column
3818
+ * letter and under the header row, a hairline between every row number
3819
+ * and to the right of the gutter, and the Select All corner framed by
3820
+ * both. The grid fuses the gutter into the header band by painting its
3821
+ * right edge in the header colour; a sheet wants the line.
3822
+ */
3823
+ .sv-sheet :global(.sv-grid-head .sv-grid-column) {
3824
+ border-right: 1px solid var(--sg-border, #d9d9d9);
3825
+ border-bottom: 1px solid var(--sg-border, #d9d9d9);
3826
+ }
3827
+ /* A hidden column's letter takes no room at all: the hairline above
3828
+ would keep it a pixel wide. Its neighbours' lines meet at the seam,
3829
+ which is the doubled line Excel draws where a column is hidden. */
3830
+ .sv-sheet :global(.sv-grid-head .sv-grid-column.sv-grid-column-collapsed) {
3831
+ border-right-width: 0;
3832
+ }
3833
+ /*
3834
+ * Where a column or a row is hidden, Excel thickens the header line at
3835
+ * the seam so the gap in the letters or numbers has a mark. The letter
3836
+ * after the fold carries a 2px left edge (a border, since the header's
3837
+ * selection shading uses its box-shadow); the number after hidden rows
3838
+ * a 2px top edge painted on a pseudo-element, since a border there would
3839
+ * push the row a pixel taller than the virtualizer laid it out.
3840
+ */
3841
+ .sv-sheet :global(.sv-grid-head .sv-grid-column.sv-grid-column-collapsed + .sv-grid-column:not(.sv-grid-column-collapsed)) {
3842
+ border-left: 2px solid var(--sg-muted, #8a8a8a);
3843
+ }
3844
+ .sv-sheet :global(tr.sv-grid-row-collapsed + tr:not(.sv-grid-row-collapsed) > td.sv-grid-row-number-cell::before) {
3845
+ content: "";
3846
+ position: absolute;
3847
+ left: 0;
3848
+ right: 0;
3849
+ top: 0;
3850
+ height: 2px;
3851
+ background: var(--sg-muted, #8a8a8a);
3852
+ pointer-events: none;
3853
+ }
3854
+ .sv-sheet :global(.sv-grid-row-number-column),
3855
+ .sv-sheet :global(.sv-grid-row-number-cell) {
3856
+ border-right: 1px solid var(--sg-border, #d9d9d9);
3857
+ }
3858
+ /*
3859
+ * Excel shades the headers the selection touches - a step darker than the
3860
+ * header band, mixed from the text colour so it lands right on a dark
3861
+ * theme too - and marks them with a 2px line in the accent along the edge
3862
+ * that faces the cells. The line sits over the hairline, as Excel's does.
3863
+ */
3864
+ /* A column or row selected whole: the accent, white on green, as Excel's. */
3865
+ .sv-sheet :global(th.sheet-col-whole),
3866
+ .sv-sheet :global(th.sheet-col-whole .sv-grid-header-cell),
3867
+ .sv-sheet :global(td.sv-grid-row-number-cell.sheet-row-whole) {
3868
+ background: var(--sg-accent, #217346) !important;
3869
+ color: var(--sg-on-accent, #fff) !important;
3870
+ }
3871
+ .sv-sheet :global(th.sheet-col-selected) {
3872
+ background: color-mix(in srgb, var(--sg-fg, #242424) 13%, var(--sg-header-bg, #f3f3f3) 87%);
3873
+ color: var(--sg-fg, #242424);
3874
+ box-shadow: inset 0 -2px 0 0 var(--sg-accent, #107c41);
3875
+ }
3876
+ .sv-sheet :global(tr:has(> td.sv-grid-cell-active) > td.sv-grid-row-number-cell),
3877
+ .sv-sheet :global(tr:has(> td[data-selected-range="true"]) > td.sv-grid-row-number-cell) {
3878
+ background: color-mix(in srgb, var(--sg-fg, #242424) 13%, var(--sg-header-bg, #f3f3f3) 87%);
3879
+ color: var(--sg-fg, #242424);
3880
+ box-shadow: inset -2px 0 0 0 var(--sg-accent, #107c41);
3881
+ }
3882
+ /* No hover wash on cells: a sheet does not light rows up under the mouse. */
3883
+ .sv-sheet :global(tbody tr:hover > .sv-grid-cell:not([data-selected-range="true"]):not(.sv-grid-row-number-cell)) {
3884
+ background-color: var(--sg-bg, #fff);
3885
+ }
3886
+ .sv-sheet :global(.sv-grid-cell) {
3887
+ font-size: 13px;
3888
+ }
3889
+
3890
+ /*
3891
+ * The selection rectangle, as Excel draws it: the range tinted and framed
3892
+ * by a 2px line in the accent, the active cell inside it left unshaded
3893
+ * with no ring of its own (the frame is the ring), and the fill handle a
3894
+ * small square on the bottom-right corner, ringed in the sheet colour so
3895
+ * it reads against the frame. A lone active cell is a one-cell range and
3896
+ * gets the same frame, white inside.
3897
+ *
3898
+ * Painted on a pseudo-element ABOVE the cell's content rather than on the
3899
+ * td itself. The grid draws the tint as the td's background and the frame
3900
+ * as inset shadows, both of which sit under the td's children - and the
3901
+ * sheet's cell span now covers the whole td, so on a filled cell the
3902
+ * selection vanished under the fill. The overlay is translucent, so a
3903
+ * fill inside a selection reads a shade darker, which is what Excel shows.
3904
+ *
3905
+ * The tint is the theme's accent at a low alpha, not `--sg-selection-bg`:
3906
+ * that token is an opaque surface in most themes (Ember's is #ffece3),
3907
+ * and an opaque overlay above the content hid the text of every selected
3908
+ * cell but the active one. Excel's own tint is its green at 12%.
3909
+ */
3910
+ .sv-sheet :global(td.sv-grid-cell[data-selected-range="true"]),
3911
+ .sv-sheet :global(td.sv-grid-cell-active) {
3912
+ background: var(--sg-bg, #fff);
3913
+ box-shadow: none;
3914
+ }
3915
+ .sv-sheet :global(td.sv-grid-cell[data-selected-range="true"]::after),
3916
+ .sv-sheet :global(td.sv-grid-cell-active::after) {
3917
+ content: "";
3918
+ position: absolute;
3919
+ inset: 0;
3920
+ z-index: 2;
3921
+ pointer-events: none;
3922
+ background: color-mix(in srgb, var(--sg-accent, #107c41) 14%, transparent);
3923
+ box-shadow: var(--sv-range-top), var(--sv-range-bottom), var(--sv-range-left), var(--sv-range-right);
3924
+ }
3925
+ /* The active cell: no tint, and a frame of its own only when it is not
3926
+ inside a range, whose frame it otherwise shares. Written out with the
3927
+ range attribute so it outranks the tint rule above; with a shorter
3928
+ selector the active cell inside a range was tinted like the rest, and
3929
+ Excel leaves it white. */
3930
+ .sv-sheet :global(td.sv-grid-cell-active::after),
3931
+ .sv-sheet :global(td.sv-grid-cell.sv-grid-cell-active[data-selected-range="true"]::after) {
3932
+ background: transparent;
3933
+ }
3934
+ .sv-sheet :global(td.sv-grid-cell-active:not([data-selected-range="true"])::after) {
3935
+ box-shadow: inset 0 0 0 2px var(--sg-accent, #107c41);
3936
+ }
3937
+ /* Nothing over the editor while a cell is being typed into. */
3938
+ .sv-sheet :global(td.sv-grid-cell-editing::after) {
3939
+ display: none;
3940
+ }
3941
+ .sv-sheet :global(.sv-grid-fill-handle) {
3942
+ width: 8px;
3943
+ height: 8px;
3944
+ right: -1px;
3945
+ bottom: -1px;
3946
+ border: 1px solid var(--sg-bg, #fff);
3947
+ border-radius: 0;
3948
+ }
3949
+ .sv-sheet :global(.sv-grid-fill-handle:hover) {
3950
+ transform: none;
3951
+ }
3952
+
3953
+ /* Headers respond to the pointer the way Excel's do: a shade darker under
3954
+ it, and the cursor says what a click will select. */
3955
+ .sv-sheet :global(.sv-grid-head .sv-grid-column:hover),
3956
+ .sv-sheet :global(td.sv-grid-row-number-cell:hover) {
3957
+ background: color-mix(in srgb, var(--sg-fg, #242424) 7%, var(--sg-header-bg, #f3f3f3) 93%);
3958
+ }
3959
+ /*
3960
+ * Excel's cursors: the white cross over the cells, a black arrow pointing
3961
+ * down the column over a letter and along the row over a number (drawn,
3962
+ * since no CSS cursor is one; the hotspot is the tip), the plain arrow
3963
+ * over the corner, and the double-headed arrows on the borders. The
3964
+ * fill handle's crosshair and the selection border's move cursor are
3965
+ * the grid's own.
3966
+ */
3967
+ .sv-sheet :global(td.sv-grid-cell:not(.sv-grid-row-number-cell):not(.sv-grid-cell-editing)) { cursor: cell; }
3968
+ .sv-sheet :global(.sv-grid-head .sv-grid-column[data-svgrid-header-col]),
3969
+ .sv-sheet :global(.sv-grid-head .sv-grid-column[data-svgrid-header-col] .sv-grid-header-sort),
3970
+ .sv-sheet :global(.sv-grid-head .sv-grid-column[data-svgrid-header-col] .sv-grid-header-cell) { cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M8 14.5L2.2 8.2h3.6V1.5h4.4v6.7h3.6z' fill='%23000' stroke='%23fff' stroke-width='1.2' stroke-linejoin='round'/></svg>") 8 15, pointer; }
3971
+ .sv-sheet :global(td.sv-grid-row-number-cell) { cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M14.5 8L8.2 13.8v-3.6H1.5V5.8h6.7V2.2z' fill='%23000' stroke='%23fff' stroke-width='1.2' stroke-linejoin='round'/></svg>") 15 8, pointer; }
3972
+ .sv-sheet :global(th.sv-grid-row-number-column) { cursor: default; }
3973
+ .sv-sheet :global(.sv-grid-resize-handle) { cursor: col-resize; }
3974
+ .sv-sheet :global(.sv-grid-row-resize-handle) { cursor: row-resize; }
3975
+
3976
+ /* The in-cell editor, widened over the neighbours while a long formula
3977
+ is typed: the cell it sits in is already lifted above the row. */
3978
+ .sv-sheet :global(td.sv-grid-cell-editing) { overflow: visible; }
3979
+ .sv-sheet :global(td.sv-grid-cell-editing .sv-grid-cell-editor) {
3980
+ position: relative;
3981
+ z-index: 2;
3982
+ max-width: none;
3983
+ background: var(--sg-bg, #fff);
3984
+ }
3985
+
3986
+ /* A cell a formula in progress refers to, in that reference's colour.
3987
+ An overlay too, for the same reason as the selection's: a filled cell
3988
+ would otherwise hide it. Translucent, so the fill shows through. */
3989
+ .sv-sheet :global(td[data-sheet-ref]::before) {
3990
+ content: "";
3991
+ position: absolute;
3992
+ inset: 0;
3993
+ z-index: 2;
3994
+ pointer-events: none;
3995
+ box-shadow: var(--sheet-ref-edges, none);
3996
+ background: color-mix(in srgb, var(--sheet-ref) 12%, transparent);
3997
+ }
3998
+ /* The editor's text under the coloured mirror: only the caret shows. */
3999
+ .sv-sheet :global(.sv-grid-cell-editor.sheet-ref-text) {
4000
+ color: transparent;
4001
+ caret-color: var(--sg-fg, #242424);
4002
+ }
4003
+ /* Format Painter armed: a brush for a pointer, as Excel shows. */
4004
+ .sheet-grid.painting :global(td.sv-grid-cell) {
4005
+ cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><path d='M10.2 2.4l5.4 5.4-3.6 3.6-5.4-5.4z' fill='%23fff' stroke='%23222' stroke-width='1.4' stroke-linejoin='round'/><path d='M6.6 6L3.2 9.4a1.8 1.8 0 0 0 0 2.6l2.8 2.8a1.8 1.8 0 0 0 2.6 0L12 11.4' fill='none' stroke='%23222' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/></svg>") 3 15, cell;
4006
+ }
4007
+ .sv-sheet :global(.sheet-ref-mirror) {
4008
+ position: absolute;
4009
+ z-index: 21;
4010
+ overflow: hidden;
4011
+ white-space: pre-wrap;
4012
+ overflow-wrap: break-word;
4013
+ color: var(--sg-fg, #242424);
4014
+ pointer-events: none;
4015
+ background: transparent;
4016
+ }
4017
+
4018
+ /* The Select All corner: a triangle pointing into the sheet. */
4019
+ .corner {
4020
+ display: block;
4021
+ width: 100%;
4022
+ height: 100%;
4023
+ position: relative;
4024
+ }
4025
+ .corner::after {
4026
+ content: "";
4027
+ position: absolute;
4028
+ right: 3px;
4029
+ bottom: 3px;
4030
+ border-style: solid;
4031
+ border-width: 0 0 9px 9px;
4032
+ border-color: transparent transparent var(--sg-muted, #b0b0b0) transparent;
4033
+ }
4034
+
4035
+ /*
4036
+ * A sheet cell fills its td edge to edge, so a fill colour reaches the
4037
+ * gridlines the way it does in Excel rather than leaving a halo of cell
4038
+ * background around it.
4039
+ *
4040
+ * The grid pads a left-aligned td by 12px and a block child only rises to
4041
+ * its own line height, so a fill used to stop short on the left and leave
4042
+ * a hairline top and bottom. The td gives up its padding and the span is
4043
+ * laid over the td's whole box instead, and does its own padding. A grid
4044
+ * container rather than a block so the text centres vertically at any row
4045
+ * height, including one the user has dragged taller. Text that does not
4046
+ * fit is cut at the edge, as Excel cuts it, not ellipsised.
4047
+ */
4048
+ .sv-sheet :global(td.sv-grid-cell:not(.sv-grid-row-number-cell)) {
4049
+ padding: 0;
4050
+ position: relative;
4051
+ }
4052
+ .sheet-cell {
4053
+ position: absolute;
4054
+ inset: 0;
4055
+ display: grid;
4056
+ align-items: center;
4057
+ padding: 0 4px;
4058
+ line-height: 1.3;
4059
+ white-space: nowrap;
4060
+ overflow: hidden;
4061
+ }
4062
+ .sheet-cell.formula {
4063
+ font-family: ui-monospace, Menlo, monospace;
4064
+ color: var(--sg-accent, #107c41);
4065
+ }
4066
+
4067
+ /*
4068
+ * Text spilling over empty neighbours, as in Excel. The grid's td clips
4069
+ * its content; for a spilling cell it is opened up, and the span grows to
4070
+ * its text (capped at the run of empty cells) and paints its own
4071
+ * background over the neighbours, which also hides the gridlines under
4072
+ * the text the way Excel does. z-index lifts it above the later tds in
4073
+ * the row, which would otherwise paint over it in DOM order.
4074
+ */
4075
+ .sv-sheet :global(td.sv-grid-cell:has(> .sheet-cell.spill)) {
4076
+ overflow: visible;
4077
+ }
4078
+ .sheet-cell.spill {
4079
+ right: auto;
4080
+ z-index: 1;
4081
+ width: max-content;
4082
+ min-width: 100%;
4083
+ background: var(--sg-bg, #fff);
4084
+ }
4085
+
4086
+ /* ---- View > Show --------------------------------------------------- */
4087
+ /* Gridlines off keeps the cell geometry (the borders go transparent, so
4088
+ nothing shifts) and leaves the borders a cell has of its own. */
4089
+ .sv-sheet.no-gridlines :global(td.sv-grid-cell) { border-color: transparent; }
4090
+ .sv-sheet.no-headings :global(.sv-grid-head) { display: none; }
4091
+
4092
+ /* ---- status bar ---------------------------------------------------- */
4093
+ .status {
4094
+ display: flex;
4095
+ align-items: center;
4096
+ gap: 18px;
4097
+ height: 22px;
4098
+ padding: 0 10px;
4099
+ font-size: 12px;
4100
+ /* Explicit: a host page that uppercases small text turns "Count" into
4101
+ "COUNT", which reads as shouting rather than as a status bar. */
4102
+ text-transform: none;
4103
+ letter-spacing: normal;
4104
+ color: var(--sg-fg, #242424);
4105
+ border-top: 1px solid var(--sg-border, #d9d9d9);
4106
+ background: var(--sg-header-bg, #f3f3f3);
4107
+ }
4108
+ .status .grow { flex: 1 1 auto; }
4109
+ .status .mode { color: var(--sg-muted, #616161); }
4110
+ .status b {
4111
+ font-weight: 400;
4112
+ font-variant-numeric: tabular-nums;
4113
+ }
4114
+ </style>