@svadmin/ui 0.72.0 → 0.74.0

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 (276) hide show
  1. package/README.md +62 -22
  2. package/THIRD_PARTY_NOTICES.md +52 -0
  3. package/dist/app.css +8488 -6297
  4. package/dist/app.theme.css +8514 -6374
  5. package/dist/class-properties.generated.d.ts +1 -0
  6. package/dist/class-properties.generated.js +2 -0
  7. package/dist/classnames.d.ts +8 -0
  8. package/dist/classnames.js +26 -0
  9. package/dist/component-registry.d.ts +14977 -0
  10. package/dist/component-registry.js +216 -0
  11. package/dist/components/AboutSettings.svelte +7 -1
  12. package/dist/components/AboutSettings.svelte.d.ts +4 -1
  13. package/dist/components/ActivityFeed.svelte +9 -3
  14. package/dist/components/ActivityFeed.svelte.d.ts +3 -0
  15. package/dist/components/AdminApp.svelte +19 -10
  16. package/dist/components/AdminApp.svelte.d.ts +6 -0
  17. package/dist/components/ApiSettings.svelte +41 -5
  18. package/dist/components/ApprovalCenter.svelte +283 -0
  19. package/dist/components/ApprovalCenter.svelte.d.ts +10 -0
  20. package/dist/components/AuditLogViewer.svelte +3 -1
  21. package/dist/components/AutoForm.svelte +53 -9
  22. package/dist/components/AutoForm.svelte.d.ts +3 -0
  23. package/dist/components/AutoTable.svelte +846 -43
  24. package/dist/components/AutoTable.svelte.d.ts +22 -1
  25. package/dist/components/BoundRecordDetailDrawer.svelte +17 -2
  26. package/dist/components/BoundRecordDetailDrawer.svelte.d.ts +4 -0
  27. package/dist/components/Breadcrumbs.svelte +11 -1
  28. package/dist/components/CancelTaskButton.svelte +12 -42
  29. package/dist/components/ColorPicker.svelte +73 -0
  30. package/dist/components/ColorPicker.svelte.d.ts +15 -0
  31. package/dist/components/ComboboxField.svelte +143 -34
  32. package/dist/components/ComboboxField.svelte.d.ts +4 -2
  33. package/dist/components/CreatePage.svelte +4 -0
  34. package/dist/components/CreatePage.svelte.d.ts +2 -0
  35. package/dist/components/DashboardView.svelte +99 -0
  36. package/dist/components/DashboardView.svelte.d.ts +14 -0
  37. package/dist/components/DatePicker.svelte +10 -0
  38. package/dist/components/DatePicker.svelte.d.ts +6 -0
  39. package/dist/components/DateRangeInput.svelte +155 -0
  40. package/dist/components/DateRangeInput.svelte.d.ts +35 -0
  41. package/dist/components/DateRangePicker.svelte +10 -0
  42. package/dist/components/DateRangePicker.svelte.d.ts +6 -0
  43. package/dist/components/DateTimeInput.svelte +134 -0
  44. package/dist/components/DateTimeInput.svelte.d.ts +27 -0
  45. package/dist/components/DateTimePicker.svelte +10 -0
  46. package/dist/components/DateTimePicker.svelte.d.ts +6 -0
  47. package/dist/components/DevTools.svelte +36 -28
  48. package/dist/components/DevframePanel.svelte +123 -0
  49. package/dist/components/DevframePanel.svelte.d.ts +3 -0
  50. package/dist/components/EditPage.svelte +4 -0
  51. package/dist/components/EditPage.svelte.d.ts +2 -0
  52. package/dist/components/ErrorSummary.svelte +52 -0
  53. package/dist/components/ErrorSummary.svelte.d.ts +14 -0
  54. package/dist/components/FieldRenderer.svelte +172 -99
  55. package/dist/components/FileUpload.svelte +412 -0
  56. package/dist/components/FileUpload.svelte.d.ts +43 -0
  57. package/dist/components/FilterBuilder.svelte +244 -265
  58. package/dist/components/FilterBuilder.svelte.d.ts +3 -0
  59. package/dist/components/FilterCollectionInput.svelte +80 -0
  60. package/dist/components/FilterCollectionInput.svelte.d.ts +10 -0
  61. package/dist/components/GanttChart.svelte +66 -14
  62. package/dist/components/GanttChart.svelte.d.ts +6 -0
  63. package/dist/components/Header.svelte +18 -2
  64. package/dist/components/ImportWizard.svelte +509 -100
  65. package/dist/components/ImportWizard.svelte.d.ts +11 -0
  66. package/dist/components/JsonSchemaForm.svelte +322 -98
  67. package/dist/components/JsonSchemaForm.svelte.d.ts +18 -1
  68. package/dist/components/KanbanBoard.svelte +195 -40
  69. package/dist/components/KanbanBoard.svelte.d.ts +9 -2
  70. package/dist/components/Layout.svelte +4 -3
  71. package/dist/components/LazyAutoForm.svelte +28 -0
  72. package/dist/components/LazyAutoForm.svelte.d.ts +8 -0
  73. package/dist/components/LazyAutoTable.svelte +28 -0
  74. package/dist/components/LazyAutoTable.svelte.d.ts +8 -0
  75. package/dist/components/LazySettingsPage.svelte +28 -0
  76. package/dist/components/LazySettingsPage.svelte.d.ts +8 -0
  77. package/dist/components/LazyShowPage.svelte +28 -0
  78. package/dist/components/LazyShowPage.svelte.d.ts +8 -0
  79. package/dist/components/LazyTaskQueueDrawer.svelte.d.ts +1 -1
  80. package/dist/components/ListPage.svelte +9 -1
  81. package/dist/components/ListPage.svelte.d.ts +5 -0
  82. package/dist/components/LoginPage.svelte +2 -2
  83. package/dist/components/MoneyInput.svelte +25 -0
  84. package/dist/components/MoneyInput.svelte.d.ts +20 -0
  85. package/dist/components/MultiTabKeepAlive.svelte +11 -6
  86. package/dist/components/NotificationsSettings.svelte +69 -2
  87. package/dist/components/NotificationsSettings.svelte.d.ts +25 -1
  88. package/dist/components/NumberInput.svelte +25 -0
  89. package/dist/components/NumberInput.svelte.d.ts +19 -0
  90. package/dist/components/NumericInput.svelte +110 -0
  91. package/dist/components/NumericInput.svelte.d.ts +23 -0
  92. package/dist/components/PercentInput.svelte +25 -0
  93. package/dist/components/PercentInput.svelte.d.ts +20 -0
  94. package/dist/components/PermissionMatrix.svelte +20 -1
  95. package/dist/components/PivotExportButton.svelte +157 -0
  96. package/dist/components/PivotExportButton.svelte.d.ts +16 -0
  97. package/dist/components/PivotTable.svelte +229 -74
  98. package/dist/components/PivotTable.svelte.d.ts +19 -2
  99. package/dist/components/QuickEditDrawer.svelte +4 -1
  100. package/dist/components/QuickEditDrawer.svelte.d.ts +2 -0
  101. package/dist/components/RadioGroup.svelte +121 -0
  102. package/dist/components/RadioGroup.svelte.d.ts +43 -0
  103. package/dist/components/RangeSlider.svelte +127 -0
  104. package/dist/components/RangeSlider.svelte.d.ts +22 -0
  105. package/dist/components/Rate.svelte +121 -0
  106. package/dist/components/Rate.svelte.d.ts +16 -0
  107. package/dist/components/RecordDetailDrawer.svelte +6 -2
  108. package/dist/components/RecordDetailDrawer.svelte.d.ts +4 -0
  109. package/dist/components/RelationPicker.svelte +341 -0
  110. package/dist/components/RelationPicker.svelte.d.ts +26 -0
  111. package/dist/components/ResourceOperationsPage.svelte +25 -19
  112. package/dist/components/ResourceOperationsPage.svelte.d.ts +5 -1
  113. package/dist/components/RetryTaskButton.svelte +12 -42
  114. package/dist/components/SecuritySettings.svelte +29 -5
  115. package/dist/components/SegmentedControl.svelte +145 -0
  116. package/dist/components/SegmentedControl.svelte.d.ts +38 -0
  117. package/dist/components/SettingsPage.svelte +3 -1
  118. package/dist/components/SettingsPage.svelte.d.ts +1 -0
  119. package/dist/components/ShowInferencer.svelte +4 -2
  120. package/dist/components/ShowInferencer.svelte.d.ts +2 -0
  121. package/dist/components/ShowPage.svelte +25 -6
  122. package/dist/components/ShowPage.svelte.d.ts +5 -0
  123. package/dist/components/Sidebar.svelte +5 -5
  124. package/dist/components/Slider.svelte +100 -0
  125. package/dist/components/Slider.svelte.d.ts +19 -0
  126. package/dist/components/SpreadsheetView.svelte +1037 -124
  127. package/dist/components/SpreadsheetView.svelte.d.ts +19 -0
  128. package/dist/components/StepsForm.svelte +5 -5
  129. package/dist/components/SvarAutoTable.svelte +39 -0
  130. package/dist/components/SvarAutoTable.svelte.d.ts +13 -0
  131. package/dist/components/SvarDataGrid.svelte +362 -0
  132. package/dist/components/SvarDataGrid.svelte.d.ts +57 -0
  133. package/dist/components/SvarGridCell.svelte +10 -0
  134. package/dist/components/SvarGridCell.svelte.d.ts +7 -0
  135. package/dist/components/SvarResourceRead.svelte +50 -0
  136. package/dist/components/SvarResourceRead.svelte.d.ts +23 -0
  137. package/dist/components/SvarResourceTable.svelte +63 -0
  138. package/dist/components/SvarResourceTable.svelte.d.ts +42 -0
  139. package/dist/components/SvarResourceTableView.svelte +364 -0
  140. package/dist/components/SvarResourceTableView.svelte.d.ts +4 -0
  141. package/dist/components/TagsInput.svelte +178 -0
  142. package/dist/components/TagsInput.svelte.d.ts +17 -0
  143. package/dist/components/TaskQueueDrawer.svelte +72 -16
  144. package/dist/components/TimePicker.svelte +10 -0
  145. package/dist/components/TimePicker.svelte.d.ts +6 -0
  146. package/dist/components/TreeSelect.svelte +440 -46
  147. package/dist/components/TreeSelect.svelte.d.ts +6 -1
  148. package/dist/components/TreeTable.svelte +484 -82
  149. package/dist/components/TreeTable.svelte.d.ts +24 -1
  150. package/dist/components/TwoFactorAuthPage.svelte +85 -48
  151. package/dist/components/TwoFactorAuthPage.svelte.d.ts +12 -1
  152. package/dist/components/VirtualTable.svelte +116 -20
  153. package/dist/components/VirtualTable.svelte.d.ts +5 -0
  154. package/dist/components/account/CompanyProfilePage.svelte +74 -38
  155. package/dist/components/account/GetStartedPage.svelte +2 -1
  156. package/dist/components/account/ImportMembersPage.svelte +22 -167
  157. package/dist/components/account/ImportMembersPage.svelte.d.ts +4 -1
  158. package/dist/components/account/MemberDirectory.svelte +156 -0
  159. package/dist/components/account/MemberDirectory.svelte.d.ts +19 -0
  160. package/dist/components/account/MembersStarterPage.svelte +9 -55
  161. package/dist/components/account/MembersStarterPage.svelte.d.ts +5 -1
  162. package/dist/components/account/SecurityLogPage.svelte +4 -1
  163. package/dist/components/account/SettingsPlainPage.svelte +7 -38
  164. package/dist/components/account/SettingsPlainPage.svelte.d.ts +4 -1
  165. package/dist/components/account/SettingsSidebarPage.svelte +7 -20
  166. package/dist/components/account/TeamMembersPage.svelte +4 -20
  167. package/dist/components/account/TeamMembersPage.svelte.d.ts +5 -1
  168. package/dist/components/account/UserProfilePage.svelte +3 -25
  169. package/dist/components/account/UserProfilePage.svelte.d.ts +17 -2
  170. package/dist/components/auto-table-grid.d.ts +26 -0
  171. package/dist/components/auto-table-grid.js +1 -0
  172. package/dist/components/buttons/ExportButton.svelte +56 -12
  173. package/dist/components/buttons/ExportButton.svelte.d.ts +11 -0
  174. package/dist/components/content/ContentPageHeader.svelte +12 -9
  175. package/dist/components/content/ContentPageShell.svelte +6 -9
  176. package/dist/components/content/FileBrowser.svelte +35 -0
  177. package/dist/components/content/FileBrowser.svelte.d.ts +14 -0
  178. package/dist/components/content/MetricBlock.svelte +10 -13
  179. package/dist/components/content/NetworkTable.svelte +3 -3
  180. package/dist/components/content/NetworkTable.svelte.d.ts +1 -0
  181. package/dist/components/content/PageToolbar.svelte +6 -10
  182. package/dist/components/content/ReferenceNotice.svelte +5 -0
  183. package/dist/components/content/ReferenceNotice.svelte.d.ts +18 -0
  184. package/dist/components/content/SectionHeader.svelte +8 -13
  185. package/dist/components/content/SettingsFieldRow.svelte +9 -23
  186. package/dist/components/content/SettingsGroup.svelte +11 -27
  187. package/dist/components/content/StatusBadge.svelte +3 -8
  188. package/dist/components/content/WorkspaceLayout.svelte +8 -12
  189. package/dist/components/enterprise/filter-tree.d.ts +48 -0
  190. package/dist/components/enterprise/filter-tree.js +224 -0
  191. package/dist/components/enterprise/json-schema-form.d.ts +39 -0
  192. package/dist/components/enterprise/json-schema-form.js +195 -0
  193. package/dist/components/enterprise/recursive-schema-form.d.ts +30 -0
  194. package/dist/components/enterprise/recursive-schema-form.js +196 -0
  195. package/dist/components/enterprise/spreadsheet-formula.d.ts +12 -0
  196. package/dist/components/enterprise/spreadsheet-formula.js +245 -0
  197. package/dist/components/export-reference.d.ts +2 -0
  198. package/dist/components/export-reference.js +9 -0
  199. package/dist/components/fieldComponentMap.js +2 -0
  200. package/dist/components/fields/DateField.svelte +12 -12
  201. package/dist/components/fields/date-display.d.ts +6 -0
  202. package/dist/components/fields/date-display.js +24 -0
  203. package/dist/components/fields/date-field-value.d.ts +2 -0
  204. package/dist/components/fields/date-field-value.js +13 -0
  205. package/dist/components/json-schema-form-state.d.ts +37 -0
  206. package/dist/components/json-schema-form-state.js +168 -0
  207. package/dist/components/network/TeamCrewTablePage.svelte +6 -1
  208. package/dist/components/network/UserCardsNFTPage.svelte +2 -0
  209. package/dist/components/profile/PublicProfilePage.svelte +2 -0
  210. package/dist/components/record-detail.svelte.d.ts +3 -0
  211. package/dist/components/record-detail.svelte.js +10 -4
  212. package/dist/components/saved-list-views.d.ts +74 -1
  213. package/dist/components/saved-list-views.js +191 -3
  214. package/dist/components/svar-grid-cells.d.ts +14 -0
  215. package/dist/components/svar-grid-cells.js +2 -0
  216. package/dist/components/svar-grid-contract.d.ts +80 -0
  217. package/dist/components/svar-grid-contract.js +159 -0
  218. package/dist/components/svar-grid-interactions.d.ts +54 -0
  219. package/dist/components/svar-grid-interactions.js +174 -0
  220. package/dist/components/svar-grid-loading.d.ts +40 -0
  221. package/dist/components/svar-grid-loading.js +180 -0
  222. package/dist/components/svar-grid-operations.d.ts +37 -0
  223. package/dist/components/svar-grid-operations.js +186 -0
  224. package/dist/components/svar-grid-panes.d.ts +34 -0
  225. package/dist/components/svar-grid-panes.js +66 -0
  226. package/dist/components/svar-grid-resource-loading.d.ts +18 -0
  227. package/dist/components/svar-grid-resource-loading.js +68 -0
  228. package/dist/components/svar-grid-resource-session.svelte.d.ts +29 -0
  229. package/dist/components/svar-grid-resource-session.svelte.js +61 -0
  230. package/dist/components/table-contract.d.ts +12 -1
  231. package/dist/components/task-action.svelte.d.ts +13 -0
  232. package/dist/components/task-action.svelte.js +73 -0
  233. package/dist/components/ui/DesignSystemShowcase.svelte +164 -0
  234. package/dist/components/ui/DesignSystemShowcase.svelte.d.ts +3 -0
  235. package/dist/components/ui/badge/badge-variants.d.ts +3 -2
  236. package/dist/components/ui/badge/badge-variants.js +2 -1
  237. package/dist/components/ui/badge/badge.svelte +3 -0
  238. package/dist/components/ui/badge/badge.svelte.d.ts +1 -0
  239. package/dist/components/ui/button/button-variants.d.ts +5 -4
  240. package/dist/components/ui/button/button-variants.js +7 -2
  241. package/dist/components/ui/checkbox/checkbox.svelte +5 -1
  242. package/dist/components/ui/checkbox/checkbox.svelte.d.ts +5 -1
  243. package/dist/components/ui/input/input.svelte +8 -6
  244. package/dist/components/ui/input/input.svelte.d.ts +4 -1
  245. package/dist/components/ui/select/select.svelte +4 -1
  246. package/dist/components/ui/select/select.svelte.d.ts +2 -1
  247. package/dist/components/ui/textarea/textarea.svelte +2 -1
  248. package/dist/components.css +3110 -0
  249. package/dist/date-input-policy.d.ts +4 -0
  250. package/dist/date-input-policy.js +35 -0
  251. package/dist/design-contract.d.ts +13 -0
  252. package/dist/design-contract.js +11 -0
  253. package/dist/design-system-components.d.ts +100 -0
  254. package/dist/design-system-components.js +101 -0
  255. package/dist/design-system.d.ts +15 -0
  256. package/dist/design-system.js +7 -0
  257. package/dist/devtools-bridge.d.ts +80 -0
  258. package/dist/devtools-bridge.js +92 -0
  259. package/dist/index.d.ts +50 -1
  260. package/dist/index.js +32 -2
  261. package/dist/page-chrome.css +12 -49
  262. package/dist/recipes.d.ts +960 -0
  263. package/dist/recipes.js +264 -0
  264. package/dist/rendering/index.d.ts +87 -0
  265. package/dist/rendering/index.js +117 -0
  266. package/dist/styles/aliases.css +49 -0
  267. package/dist/styles/compatibility.epilogue.css +384 -0
  268. package/dist/styles/compatibility.prelude.css +5513 -0
  269. package/dist/styles/conditional-states.css +28 -0
  270. package/dist/styles/conditional-utilities.css +22 -0
  271. package/dist/tree-table-model.d.ts +24 -0
  272. package/dist/tree-table-model.js +111 -0
  273. package/dist/utils.d.ts +1 -1
  274. package/dist/utils.js +1 -1
  275. package/package.json +40 -10
  276. package/styles-compatibility.json +12 -0
@@ -1,7 +1,12 @@
1
1
  <script lang="ts">
2
+ import { onDestroy } from 'svelte';
2
3
  import { Button } from './ui/button/index.js';
3
- import { Plus, Download, FileSpreadsheet } from '@lucide/svelte';
4
+ import { Plus, Download, FileSpreadsheet, Redo2, Undo2, Trash2, Upload, Bold, Italic, Copy } from '@lucide/svelte';
4
5
  import { cn } from '../utils.js';
6
+ import { parseSpreadsheetClipboard, serializeSpreadsheetClipboard, parseSpreadsheetWorkbook, snapshotSpreadsheetWorkbook, serializeSpreadsheetWorkbook, toCsv, type SpreadsheetCellFormat, type SpreadsheetWorkbook } from '@svadmin/core';
7
+ import { useTranslation } from '@svadmin/core/i18n';
8
+
9
+ const i18n = useTranslation();
5
10
 
6
11
  export interface SheetData {
7
12
  id: string;
@@ -9,17 +14,47 @@
9
14
  rows: number;
10
15
  cols: number;
11
16
  cells: Record<string, string>; // e.g. "A1": "100", "A2": "=SUM(A1:A1)"
17
+ formats?: Record<string, SpreadsheetCellFormat>;
18
+ }
19
+
20
+ export interface SpreadsheetLimits {
21
+ maxRows: number;
22
+ maxCols: number;
23
+ maxCells: number;
24
+ maxSheets: number;
25
+ maxCellLength: number;
26
+ maxFormulaLength: number;
27
+ maxFormulaDepth: number;
28
+ maxRangeCells: number;
29
+ maxFormulaSteps: number;
12
30
  }
13
31
 
32
+ const defaultLimits: SpreadsheetLimits = {
33
+ maxRows: 1000,
34
+ maxCols: 100,
35
+ maxCells: 10_000,
36
+ maxSheets: 32,
37
+ maxCellLength: 10_000,
38
+ maxFormulaLength: 1000,
39
+ maxFormulaDepth: 32,
40
+ maxRangeCells: 10_000,
41
+ maxFormulaSteps: 50_000,
42
+ };
43
+
14
44
  interface Props {
15
45
  sheets?: SheetData[];
16
46
  activeSheetId?: string;
47
+ scopeKey?: string;
17
48
  readonly?: boolean;
18
49
  onchange?: (sheets: SheetData[]) => void;
19
50
  onexport?: (csv: string) => void;
51
+ onworkbookexport?: (json: string) => void;
52
+ onxlsxexport?: (bytes: Uint8Array) => void | Promise<void>;
53
+ xlsxexporter?: (workbook: SpreadsheetWorkbook) => Promise<Uint8Array | undefined>;
54
+ xlsximporter?: (bytes: Uint8Array, limits: SpreadsheetLimits, signal: AbortSignal) => Promise<unknown>;
55
+ limits?: Partial<SpreadsheetLimits>;
20
56
  class?: string;
21
57
  }
22
-
23
58
  let {
24
59
  sheets = $bindable([
25
60
  {
@@ -50,26 +85,182 @@
50
85
  },
51
86
  ]),
52
87
  activeSheetId = $bindable('sheet1'),
88
+ scopeKey,
53
89
  readonly = false,
54
90
  onchange,
55
91
  onexport,
92
+ onworkbookexport,
93
+ onxlsxexport,
94
+ xlsxexporter,
95
+ xlsximporter,
96
+ limits: limitOverrides = {},
56
97
  class: className = '',
57
98
  }: Props = $props();
58
99
 
59
100
  let selectedCell = $state<string>('A1');
101
+ let fileInput: HTMLInputElement;
102
+ let workbookImportError = $state(false);
103
+ let workbookXlsxError = $state(false);
104
+ let workbookXlsxExporting = $state(false);
105
+ let importRequest: object | undefined;
106
+ let importController: AbortController | undefined;
107
+ let importMounted = true;
108
+ let pendingWorkbook = $state.raw<{ workbook: SpreadsheetWorkbook; context: object }>();
109
+ onDestroy(() => { importMounted = false; importRequest = undefined; importController?.abort(); });
110
+ let selectedRangeEnd = $state<string>('A1');
111
+ let selectionError = $state(false);
112
+ let selectionSheetId: string | undefined;
60
113
  let formulaInput = $state('');
114
+ const HISTORY_LIMIT = 100;
115
+ const HISTORY_TEXT_LIMIT = 2_000_000;
116
+ interface HistoryEntry { data: string; activeSheetId: string }
117
+ let undoHistory = $state.raw<HistoryEntry[]>([]);
118
+ let redoHistory = $state.raw<HistoryEntry[]>([]);
119
+ let committedFingerprint = '';
120
+ let committedScopeKey: string | undefined;
121
+ let committedSheets: SheetData[] | undefined;
122
+ let committedLimits = '';
123
+ let xlsxExportRequest: object | undefined;
61
124
 
62
- const currentSheet = $derived(
63
- sheets.find((s) => s.id === activeSheetId) ?? sheets[0]
64
- );
125
+ function snapshotSheets(value: SheetData[]): SheetData[] {
126
+ return value.map((sheet) => ({
127
+ ...sheet,
128
+ cells: { ...sheet.cells },
129
+ ...(sheet.formats ? {
130
+ formats: Object.fromEntries(Object.entries(sheet.formats).map(([key, format]) => [key, { ...format }])),
131
+ } : {}),
132
+ }));
133
+ }
65
134
 
66
- function getColName(index: number): string {
67
- let result = '';
68
- let i = index;
69
- while (i >= 0) {
70
- result = String.fromCharCode((i % 26) + 65) + result;
71
- i = Math.floor(i / 26) - 1;
135
+ function fingerprintSheets(value: SheetData[]): string {
136
+ return JSON.stringify(value);
137
+ }
138
+
139
+ function pushBounded(history: HistoryEntry[]): HistoryEntry[] {
140
+ const data = fingerprintSheets(sheets);
141
+ if (data.length > HISTORY_TEXT_LIMIT) return [];
142
+ const next = [...history, { data, activeSheetId }].slice(-HISTORY_LIMIT);
143
+ let size = next.reduce((total, entry) => total + entry.data.length, 0);
144
+ while (size > HISTORY_TEXT_LIMIT) {
145
+ const oldest = next.shift();
146
+ if (!oldest) break;
147
+ size -= oldest.data.length;
148
+ }
149
+ return next;
150
+ }
151
+
152
+ function notifyChange(value: SheetData[]) {
153
+ onchange?.(snapshotSheets(value));
154
+ }
155
+
156
+ function commitSheets(next: SheetData[], nextActiveSheetId = activeSheetId) {
157
+ synchronizeHistory();
158
+ const nextSnapshot = snapshotSheets(next);
159
+ if (fingerprintSheets(nextSnapshot) === committedFingerprint && nextActiveSheetId === activeSheetId) return;
160
+ undoHistory = pushBounded(undoHistory);
161
+ redoHistory = [];
162
+ sheets = nextSnapshot;
163
+ activeSheetId = nextActiveSheetId;
164
+ committedSheets = sheets;
165
+ committedFingerprint = fingerprintSheets(nextSnapshot);
166
+ committedScopeKey = scopeKey;
167
+ notifyChange(nextSnapshot);
168
+ }
169
+
170
+ function synchronizeHistory() {
171
+ const fingerprint = fingerprintSheets(sheets);
172
+ const limitKey = JSON.stringify(limits);
173
+ const scopeChanged = committedScopeKey !== scopeKey;
174
+ const externalDataChanged = committedSheets !== sheets || committedFingerprint !== fingerprint;
175
+ if (scopeChanged || externalDataChanged || committedLimits !== limitKey) {
176
+ undoHistory = [];
177
+ redoHistory = [];
178
+ committedSheets = sheets;
179
+ committedLimits = limitKey;
180
+ committedFingerprint = fingerprint;
181
+ committedScopeKey = scopeKey;
182
+ if (scopeChanged || externalDataChanged) {
183
+ selectedCell = 'A1';
184
+ selectedRangeEnd = 'A1';
185
+ selectionError = false;
186
+ }
187
+ }
188
+ }
189
+
190
+ const limits = $derived.by(() => {
191
+ const positive = (value: number | undefined, fallback: number) =>
192
+ typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? Math.min(value, fallback) : fallback;
193
+ return {
194
+ maxRows: positive(limitOverrides.maxRows, defaultLimits.maxRows),
195
+ maxCols: positive(limitOverrides.maxCols, defaultLimits.maxCols),
196
+ maxCells: positive(limitOverrides.maxCells, defaultLimits.maxCells),
197
+ maxSheets: positive(limitOverrides.maxSheets, defaultLimits.maxSheets),
198
+ maxCellLength: positive(limitOverrides.maxCellLength, defaultLimits.maxCellLength),
199
+ maxFormulaLength: positive(limitOverrides.maxFormulaLength, defaultLimits.maxFormulaLength),
200
+ maxFormulaDepth: positive(limitOverrides.maxFormulaDepth, defaultLimits.maxFormulaDepth),
201
+ maxRangeCells: positive(limitOverrides.maxRangeCells, defaultLimits.maxRangeCells),
202
+ maxFormulaSteps: positive(limitOverrides.maxFormulaSteps, defaultLimits.maxFormulaSteps),
203
+ } satisfies SpreadsheetLimits;
204
+ });
205
+ $effect.pre(synchronizeHistory);
206
+ const validWorkbook = $derived(sheets.length <= limits.maxSheets && new Set(sheets.map(sheet => sheet.id)).size === sheets.length);
207
+ const currentSheet = $derived(validWorkbook ? (sheets.find(s => s.id === activeSheetId) ?? sheets[0]) : undefined);
208
+ const validGrid = $derived(Boolean(currentSheet
209
+ && Number.isSafeInteger(currentSheet.rows) && currentSheet.rows > 0 && currentSheet.rows <= limits.maxRows
210
+ && Number.isSafeInteger(currentSheet.cols) && currentSheet.cols > 0 && currentSheet.cols <= limits.maxCols
211
+ && currentSheet.rows * currentSheet.cols <= limits.maxCells));
212
+ const gridRows = $derived(validGrid && currentSheet ? currentSheet.rows : 0);
213
+ const gridCols = $derived(validGrid && currentSheet ? currentSheet.cols : 0);
214
+ const cellSnapshot = $derived.by(() => {
215
+ if (!validGrid || !currentSheet || !currentSheet.cells || typeof currentSheet.cells !== 'object') return null;
216
+ const cells: Record<string, string> = {};
217
+ let textLength = 0;
218
+ for (let row = 1; row <= gridRows; row++) {
219
+ for (let col = 0; col < gridCols; col++) {
220
+ const key = `${getColName(col)}${row}`;
221
+ const raw = Object.hasOwn(currentSheet.cells, key) ? currentSheet.cells[key] : '';
222
+ if (typeof raw !== 'string' || raw.length > limits.maxCellLength) return null;
223
+ textLength += raw.length;
224
+ if (textLength > 1_000_000) return null;
225
+ cells[key] = raw;
226
+ }
72
227
  }
228
+ return cells;
229
+ });
230
+ const canRender = $derived(validGrid && cellSnapshot !== null);
231
+ const importContext = $derived({
232
+ scopeKey, readonly, data: fingerprintSheets(sheets), limits: JSON.stringify(limits), activeSheetId, xlsximporter,
233
+ });
234
+ const importReady = $derived(pendingWorkbook?.context === importContext && !readonly);
235
+ const xlsxContext = $derived({ context: importContext, xlsxexporter, onxlsxexport });
236
+ $effect.pre(() => {
237
+ void xlsxContext;
238
+ xlsxExportRequest = undefined;
239
+ workbookXlsxExporting = false;
240
+ workbookXlsxError = false;
241
+ });
242
+ $effect.pre(() => {
243
+ void importContext;
244
+ pendingWorkbook = undefined;
245
+ workbookImportError = false;
246
+ importRequest = undefined;
247
+ importController?.abort();
248
+ });
249
+ const workbookJson = $derived.by(() => {
250
+ if (!validWorkbook || !currentSheet || sheets.some(sheet =>
251
+ sheet.rows > limits.maxRows || sheet.cols > limits.maxCols
252
+ || sheet.rows * sheet.cols > limits.maxCells
253
+ || Object.values(sheet.cells).some(value => typeof value !== 'string' || value.length > limits.maxCellLength))) return;
254
+ return serializeSpreadsheetWorkbook({
255
+ protocolVersion: 1, sheets: snapshotSheets(sheets), activeSheetId: currentSheet.id,
256
+ });
257
+ });
258
+ const canAddRow = $derived(canRender && gridRows < limits.maxRows && (gridRows + 1) * gridCols <= limits.maxCells);
259
+ const canAddCol = $derived(canRender && gridCols < limits.maxCols && gridRows * (gridCols + 1) <= limits.maxCells);
260
+
261
+ function getColName(index: number): string {
262
+ let result = '', current = index;
263
+ while (current >= 0) { result = String.fromCharCode(current % 26 + 65) + result; current = Math.floor(current / 26) - 1; }
73
264
  return result;
74
265
  }
75
266
 
@@ -77,90 +268,579 @@
77
268
  let idx = 0;
78
269
  for (let i = 0; i < col.length; i++) {
79
270
  idx = idx * 26 + (col.charCodeAt(i) - 64);
271
+ if (!Number.isSafeInteger(idx)) throw new Error('limit');
80
272
  }
81
273
  return idx - 1;
82
274
  }
83
275
 
84
276
  function parseCellKey(key: string): { col: string; row: number } | null {
85
- const match = key.match(/^([A-Z]+)(\d+)$/);
277
+ const match = key.match(/^([A-Z]+)([1-9]\d*)$/);
86
278
  if (!match || !match[1] || !match[2]) return null;
87
- return { col: match[1], row: parseInt(match[2], 10) };
88
- }
89
-
90
- function evaluateFormula(formula: string, cells: Record<string, string>): number | string {
91
- const sumMatch = formula.match(/^=SUM\(([A-Z]+\d+):([A-Z]+\d+)\)$/i);
92
- const avgMatch = formula.match(/^=AVG\(([A-Z]+\d+):([A-Z]+\d+)\)$/i);
93
- const countMatch = formula.match(/^=COUNT\(([A-Z]+\d+):([A-Z]+\d+)\)$/i);
94
-
95
- const match = sumMatch || avgMatch || countMatch;
96
- if (match && match[1] && match[2]) {
97
- const start = parseCellKey(match[1].toUpperCase());
98
- const end = parseCellKey(match[2].toUpperCase());
99
- if (!start || !end) return '#ERROR!';
100
-
101
- const startCol = colNameToIndex(start.col);
102
- const endCol = colNameToIndex(end.col);
103
- const minCol = Math.min(startCol, endCol);
104
- const maxCol = Math.max(startCol, endCol);
105
- const minRow = Math.min(start.row, end.row);
106
- const maxRow = Math.max(start.row, end.row);
107
-
108
- const numbers: number[] = [];
109
- for (let c = minCol; c <= maxCol; c++) {
110
- for (let r = minRow; r <= maxRow; r++) {
111
- const k = `${getColName(c)}${r}`;
112
- const raw = cells[k] ?? '';
113
- const val = raw.startsWith('=') ? evaluateFormula(raw, cells) : Number(raw);
114
- if (typeof val === 'number' && !isNaN(val)) {
115
- numbers.push(val);
116
- }
279
+ const row = Number(match[2]);
280
+ if (!Number.isSafeInteger(row)) throw new Error('limit');
281
+ return { col: match[1], row };
282
+ }
283
+
284
+ interface EvaluationState {
285
+ depth: number;
286
+ steps: number;
287
+ cache: Map<string, number | string>;
288
+ }
289
+
290
+ interface SpreadsheetReference {
291
+ sheet?: string;
292
+ cell: string;
293
+ }
294
+
295
+ function evaluateFormula(
296
+ formula: string,
297
+ cells: Record<string, string>,
298
+ visiting: Set<string>,
299
+ state: EvaluationState,
300
+ resolveSheet: (name: string | undefined) => Record<string, string> | undefined,
301
+ ): number | string {
302
+ if (!formula.startsWith('=')) return formula;
303
+ if (formula.length > limits.maxFormulaLength) return '#LIMIT!';
304
+ state.depth += 1;
305
+ if (state.depth > limits.maxFormulaDepth) {
306
+ state.depth -= 1;
307
+ return '#LIMIT!';
308
+ }
309
+
310
+ // 空白是分隔符,不能把 “1 2” 拼成 “12”。
311
+ const source = formula.slice(1);
312
+ let cursor = 0;
313
+ const consumeStep = (cost = 1) => {
314
+ state.steps += cost;
315
+ if (state.steps > limits.maxFormulaSteps) throw new Error('limit');
316
+ };
317
+ const skipSpace = () => {
318
+ while (cursor < source.length && /\s/.test(source[cursor] ?? '')) cursor++;
319
+ };
320
+
321
+ function readNumber(): number {
322
+ const start = cursor;
323
+ while (cursor < source.length && /[\d.]/.test(source[cursor] ?? '')) cursor += 1;
324
+ const result = Number(source.slice(start, cursor));
325
+ if (!Number.isFinite(result) || !/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(source.slice(start, cursor))) throw new Error('number');
326
+ return result;
327
+ }
328
+
329
+ function readCellReference(): SpreadsheetReference {
330
+ const start = cursor;
331
+ if (source[cursor] === "'") {
332
+ cursor += 1;
333
+ const sheetStart = cursor;
334
+ while (cursor < source.length && source[cursor] !== "'") cursor += 1;
335
+ if (source[cursor] !== "'" || source[cursor + 1] !== '!') throw new Error('reference');
336
+ const sheet = source.slice(sheetStart, cursor);
337
+ cursor += 2;
338
+ const cellStart = cursor;
339
+ if (source[cursor] === '$') cursor += 1;
340
+ while (cursor < source.length && /[A-Za-z]/.test(source[cursor] ?? '')) cursor += 1;
341
+ if (source[cursor] === '$') cursor += 1;
342
+ while (cursor < source.length && /\d/.test(source[cursor] ?? '')) cursor += 1;
343
+ return { sheet, cell: source.slice(cellStart, cursor).toUpperCase() };
344
+ }
345
+ const sheetMatch = source.slice(cursor).match(/^([A-Za-z_][A-Za-z0-9_]*)!/);
346
+ if (sheetMatch) {
347
+ const sheet = sheetMatch[1];
348
+ if (!sheet) throw new Error('reference');
349
+ cursor += sheetMatch[0].length;
350
+ const cellStart = cursor;
351
+ if (source[cursor] === '$') cursor += 1;
352
+ while (cursor < source.length && /[A-Za-z]/.test(source[cursor] ?? '')) cursor += 1;
353
+ if (source[cursor] === '$') cursor += 1;
354
+ while (cursor < source.length && /\d/.test(source[cursor] ?? '')) cursor += 1;
355
+ return { sheet, cell: source.slice(cellStart, cursor).toUpperCase() };
356
+ }
357
+ if (source[cursor] === '$') cursor += 1;
358
+ while (cursor < source.length && /[A-Za-z]/.test(source[cursor] ?? '')) cursor += 1;
359
+ if (source[cursor] === '$') cursor += 1;
360
+ while (cursor < source.length && /\d/.test(source[cursor] ?? '')) cursor += 1;
361
+ return { cell: source.slice(start, cursor).toUpperCase() };
362
+ }
363
+
364
+ function normalizeCellReference(value: string): string {
365
+ const match = value.match(/^\$?([A-Z]+)\$?([1-9]\d*)$/);
366
+ if (!match || !match[1] || !match[2]) throw new Error('reference');
367
+ return `${match[1]}${match[2]}`;
368
+ }
369
+
370
+ function cellValue(reference: SpreadsheetReference): number {
371
+ consumeStep();
372
+ const targetCells = resolveSheet(reference.sheet);
373
+ const key = normalizeCellReference(reference.cell);
374
+ const targetSheetKey = reference.sheet === undefined ? '__current__' : reference.sheet.toLowerCase();
375
+ const visitKey = `${targetSheetKey}!${key}`;
376
+ const targetCell = parseCellKey(key);
377
+ const targetSheet = reference.sheet === undefined
378
+ ? currentSheet
379
+ : sheets.find(sheet => sheet.id.toLowerCase() === reference.sheet?.toLowerCase()
380
+ || sheet.name.toLowerCase() === reference.sheet?.toLowerCase());
381
+ if (!targetCells || !targetCell || !targetSheet
382
+ || targetCell.row > targetSheet.rows || colNameToIndex(targetCell.col) >= targetSheet.cols) throw new Error('reference');
383
+ if (visiting.has(visitKey)) throw new Error('cycle');
384
+ const raw = targetCells[key] ?? '';
385
+ if (raw.startsWith('=')) {
386
+ const next = new Set(visiting);
387
+ next.add(visitKey);
388
+ const cacheKey = `${targetSheetKey}!${key}`;
389
+ const computed = state.cache.get(cacheKey) ?? evaluateFormula(raw, targetCells, next, state, resolveSheet);
390
+ state.cache.set(cacheKey, computed);
391
+ if (typeof computed !== 'number') {
392
+ throw new Error(computed === '#CYCLE!' ? 'cycle' : computed === '#LIMIT!' ? 'limit' : 'value');
117
393
  }
394
+ return computed;
118
395
  }
396
+ consumeStep(raw.length);
397
+ const result = Number(raw);
398
+ return Number.isFinite(result) ? result : 0;
399
+ }
119
400
 
120
- if (sumMatch) return numbers.reduce((a, b) => a + b, 0);
121
- if (avgMatch) return numbers.length ? numbers.reduce((a, b) => a + b, 0) / numbers.length : 0;
122
- if (countMatch) return numbers.length;
401
+ function readRange(): number[] {
402
+ skipSpace();
403
+ const start = readCellReference();
404
+ skipSpace();
405
+ if (source[cursor] !== ':') throw new Error('range');
406
+ cursor += 1;
407
+ skipSpace();
408
+ const end = readCellReference();
409
+ if (start.sheet !== end.sheet) throw new Error('range');
410
+ const startCell = parseCellKey(normalizeCellReference(start.cell));
411
+ const endCell = parseCellKey(normalizeCellReference(end.cell));
412
+ if (!startCell || !endCell) throw new Error('range');
413
+ const firstCol = Math.min(colNameToIndex(startCell.col), colNameToIndex(endCell.col));
414
+ const lastCol = Math.max(colNameToIndex(startCell.col), colNameToIndex(endCell.col));
415
+ const firstRow = Math.min(startCell.row, endCell.row);
416
+ const lastRow = Math.max(startCell.row, endCell.row);
417
+ const area = (lastCol - firstCol + 1) * (lastRow - firstRow + 1);
418
+ if (!Number.isSafeInteger(area) || area > limits.maxRangeCells) throw new Error('limit');
419
+ const targetSheet = start.sheet === undefined
420
+ ? currentSheet
421
+ : sheets.find(sheet => sheet.id.toLowerCase() === start.sheet?.toLowerCase()
422
+ || sheet.name.toLowerCase() === start.sheet?.toLowerCase());
423
+ if (!targetSheet || lastRow > targetSheet.rows || lastCol >= targetSheet.cols) throw new Error('reference');
424
+ const values: number[] = [];
425
+ for (let col = firstCol; col <= lastCol; col += 1) {
426
+ for (let row = firstRow; row <= lastRow; row += 1) {
427
+ consumeStep();
428
+ if (values.length >= limits.maxRangeCells) throw new Error('limit');
429
+ values.push(cellValue({
430
+ ...(start.sheet === undefined ? {} : { sheet: start.sheet }),
431
+ cell: `${getColName(col)}${row}`,
432
+ }));
433
+ }
434
+ }
435
+ return values;
123
436
  }
124
437
 
125
- // Direct arithmetic
126
- if (formula.startsWith('=')) {
438
+ function readPrimary(): number {
439
+ consumeStep();
440
+ skipSpace();
441
+ state.depth += 1;
127
442
  try {
128
- const expr = formula.slice(1).replace(/([A-Z]+\d+)/g, (k) => {
129
- const val = cells[k.toUpperCase()] ?? '0';
130
- return String(Number(val) || 0);
131
- });
132
- const sanitized = expr.replace(/[^0-9+\-*/(). ]/g, '');
133
- const fn = new Function(`return (${sanitized || 0})`);
134
- return Number(fn());
135
- } catch {
136
- return '#VALUE!';
443
+ if (state.depth > limits.maxFormulaDepth) throw new Error('limit');
444
+ return readAtom();
445
+ } finally {
446
+ state.depth -= 1;
137
447
  }
138
448
  }
139
449
 
140
- return formula;
450
+ function readAtom(): number {
451
+ if (source[cursor] === '(') {
452
+ cursor += 1;
453
+ const result = readExpression();
454
+ skipSpace();
455
+ if (source[cursor] !== ')') throw new Error('parenthesis');
456
+ cursor += 1;
457
+ return result;
458
+ }
459
+ if (source[cursor] === '-' || source[cursor] === '+') {
460
+ const sign = source[cursor++] === '-' ? -1 : 1;
461
+ return sign * readPrimary();
462
+ }
463
+ if (/[0-9.]/.test(source[cursor] ?? '')) return readNumber();
464
+
465
+ const identifier = readCellReference();
466
+ skipSpace();
467
+ if (identifier.cell === 'IF' && identifier.sheet === undefined && source[cursor] === '(') {
468
+ cursor += 1;
469
+ const condition = readExpression();
470
+ skipSpace();
471
+ if (source[cursor] !== ',') throw new Error('function');
472
+ cursor += 1;
473
+ const whenTrue = readExpression();
474
+ skipSpace();
475
+ if (source[cursor] !== ',') throw new Error('function');
476
+ cursor += 1;
477
+ const whenFalse = readExpression();
478
+ skipSpace();
479
+ if (source[cursor] !== ')') throw new Error('function');
480
+ cursor += 1;
481
+ return condition !== 0 ? whenTrue : whenFalse;
482
+ }
483
+ if (['SUM', 'AVG', 'COUNT', 'MIN', 'MAX'].includes(identifier.cell)
484
+ && source[cursor] === '(' && identifier.sheet === undefined) {
485
+ cursor += 1;
486
+ const values = readRange();
487
+ skipSpace();
488
+ if (source[cursor] !== ')') throw new Error('function');
489
+ cursor += 1;
490
+ if (identifier.cell === 'SUM') return values.reduce((sum, value) => sum + value, 0);
491
+ if (identifier.cell === 'AVG') return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
492
+ if (identifier.cell === 'COUNT') return values.length;
493
+ if (identifier.cell === 'MIN') return values.length > 0 ? Math.min(...values) : 0;
494
+ return values.length > 0 ? Math.max(...values) : 0;
495
+ }
496
+ return cellValue(identifier);
497
+ }
498
+
499
+ function readTerm(): number {
500
+ let result = readPrimary();
501
+ skipSpace();
502
+ while (source[cursor] === '*' || source[cursor] === '/') {
503
+ const operator = source[cursor++];
504
+ const next = readPrimary();
505
+ if (operator === '/' && next === 0) throw new Error('division');
506
+ result = operator === '*' ? result * next : result / next;
507
+ skipSpace();
508
+ }
509
+ return result;
510
+ }
511
+
512
+ function readExpression(): number {
513
+ let result = readTerm();
514
+ skipSpace();
515
+ while (source[cursor] === '+' || source[cursor] === '-') {
516
+ const operator = source[cursor++];
517
+ const next = readTerm();
518
+ result = operator === '+' ? result + next : result - next;
519
+ skipSpace();
520
+ }
521
+ return result;
522
+ }
523
+
524
+ try {
525
+ state.steps += formula.length;
526
+ if (state.steps > limits.maxFormulaSteps) throw new Error('limit');
527
+ const result = readExpression();
528
+ if (cursor !== source.length || !Number.isFinite(result)) return '#VALUE!';
529
+ return result;
530
+ } catch (error) {
531
+ return error instanceof Error && error.message === 'cycle'
532
+ ? '#CYCLE!'
533
+ : error instanceof Error && error.message === 'limit' ? '#LIMIT!' : '#VALUE!';
534
+ } finally {
535
+ state.depth -= 1;
536
+ }
141
537
  }
142
538
 
143
- function getRenderedValue(key: string): string {
144
- if (!currentSheet) return '';
145
- const raw = currentSheet.cells[key] ?? '';
146
- if (raw.startsWith('=')) {
147
- const computed = evaluateFormula(raw, currentSheet.cells);
148
- return typeof computed === 'number'
149
- ? computed.toLocaleString(undefined, { maximumFractionDigits: 2 })
150
- : String(computed);
539
+ const evaluatedCells = $derived.by(() => {
540
+ const cells = cellSnapshot;
541
+ const values = new Map<string, number | string>();
542
+ if (!cells) return values;
543
+ const sheetCells = new Map<string, Record<string, string>>();
544
+ const sheetNames = new Map<string, string>();
545
+ for (const sheet of sheets) {
546
+ sheetCells.set(sheet.id.toLowerCase(), sheet.cells);
547
+ sheetNames.set(sheet.id.toLowerCase(), sheet.id);
548
+ sheetNames.set(sheet.name.toLowerCase(), sheet.id);
151
549
  }
152
- return raw;
550
+ const resolveSheet = (name: string | undefined): Record<string, string> | undefined => {
551
+ if (name === undefined) return cells;
552
+ const sheetId = sheetNames.get(name.toLowerCase());
553
+ return sheetId === undefined ? undefined : sheetCells.get(sheetId.toLowerCase());
554
+ };
555
+ // 同一数据快照共享预算和依赖缓存,禁止为每个单元格重新启动昂贵计算。
556
+ const state: EvaluationState = { depth: 0, steps: 0, cache: values };
557
+ for (const [key, raw] of Object.entries(cells)) {
558
+ if (!values.has(`__current__!${key}`)) {
559
+ const computed = evaluateFormula(raw, cells, new Set([`__current__!${key}`]), state, resolveSheet);
560
+ values.set(`__current__!${key}`, computed);
561
+ values.set(key, computed);
562
+ }
563
+ }
564
+ return values;
565
+ });
566
+
567
+ $effect.pre(() => {
568
+ if (selectionSheetId !== currentSheet?.id) {
569
+ selectionSheetId = currentSheet?.id;
570
+ selectedCell = 'A1';
571
+ selectedRangeEnd = 'A1';
572
+ selectionError = false;
573
+ }
574
+ if (cellSnapshot && !Object.hasOwn(cellSnapshot, selectedCell)) selectedCell = 'A1';
575
+ if (cellSnapshot && !Object.hasOwn(cellSnapshot, selectedRangeEnd)) selectedRangeEnd = selectedCell;
576
+ formulaInput = cellSnapshot?.[selectedCell] ?? '';
577
+ });
578
+
579
+ function getRenderedValue(key: string): string {
580
+ const computed = evaluatedCells.get(key) ?? '';
581
+ const format = cellFormat(key);
582
+ const value = typeof computed === 'number' ? computed
583
+ : format.numberFormat !== undefined && format.numberFormat !== 'general'
584
+ && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(computed) ? Number(computed) : computed;
585
+ if (typeof value !== 'number' || !Number.isFinite(value)) return String(computed);
586
+ const decimals = format.decimalPlaces;
587
+ const options = decimals === undefined || !Number.isInteger(decimals) || decimals < 0 || decimals > 20
588
+ ? { maximumFractionDigits: 2 } : {
589
+ minimumFractionDigits: decimals, maximumFractionDigits: decimals,
590
+ };
591
+ return value.toLocaleString(i18n.locale, {
592
+ ...options, style: format.numberFormat === 'percent' ? 'percent' : 'decimal',
593
+ });
594
+ }
595
+
596
+ function cellFormat(key = selectedCell): SpreadsheetCellFormat {
597
+ return currentSheet?.formats?.[key] ?? {};
598
+ }
599
+
600
+ function updateCellFormat(patch: Partial<SpreadsheetCellFormat>) {
601
+ if (readonly || !currentSheet || !canRender) return;
602
+ const keys = selectedKeys();
603
+ const targets = keys.length > 1 ? keys : [selectedCell];
604
+ const nextSheets = sheets.map(sheet => {
605
+ if (sheet.id !== currentSheet.id) return sheet;
606
+ const formats = { ...(sheet.formats ?? {}) };
607
+ for (const key of targets) formats[key] = { ...(formats[key] ?? {}), ...patch };
608
+ return { ...sheet, formats };
609
+ });
610
+ commitSheets(nextSheets);
153
611
  }
154
612
 
155
613
  function selectCell(key: string) {
614
+ selectionError = false;
615
+ selectedRangeEnd = key;
156
616
  selectedCell = key;
157
617
  if (currentSheet) {
158
618
  formulaInput = currentSheet.cells[key] ?? '';
159
619
  }
160
620
  }
161
621
 
622
+ function selectRange(key: string, extend: boolean) {
623
+ selectionError = false;
624
+ if (extend) {
625
+ selectedRangeEnd = key;
626
+ return;
627
+ }
628
+ selectCell(key);
629
+ }
630
+
631
+ function rangeBounds() {
632
+ const start = parseCellKey(selectedCell);
633
+ const end = parseCellKey(selectedRangeEnd);
634
+ if (!start || !end) return null;
635
+ const startCol = colNameToIndex(start.col);
636
+ const endCol = colNameToIndex(end.col);
637
+ return {
638
+ firstCol: Math.min(startCol, endCol),
639
+ lastCol: Math.max(startCol, endCol),
640
+ firstRow: Math.min(start.row, end.row),
641
+ lastRow: Math.max(start.row, end.row),
642
+ };
643
+ }
644
+
645
+ function selectedKeys() {
646
+ const bounds = rangeBounds();
647
+ if (!bounds) return [];
648
+ const keys: string[] = [];
649
+ for (let row = bounds.firstRow; row <= bounds.lastRow; row += 1) {
650
+ for (let col = bounds.firstCol; col <= bounds.lastCol; col += 1) {
651
+ keys.push(`${getColName(col)}${row}`);
652
+ }
653
+ }
654
+ return keys;
655
+ }
656
+
657
+ function isCellSelected(key: string) {
658
+ const cell = parseCellKey(key);
659
+ const bounds = rangeBounds();
660
+ if (!cell || !bounds) return false;
661
+ const col = colNameToIndex(cell.col);
662
+ return cell.row >= bounds.firstRow && cell.row <= bounds.lastRow
663
+ && col >= bounds.firstCol && col <= bounds.lastCol;
664
+ }
665
+
666
+ function applyCellValues(values: Map<string, string>): boolean {
667
+ if (readonly || !currentSheet || !canRender || !cellSnapshot) return false;
668
+ let textLength = Object.values(cellSnapshot).reduce((total, value) => total + value.length, 0);
669
+ for (const [key, value] of values) {
670
+ if (!Object.hasOwn(cellSnapshot, key) || value.length > limits.maxCellLength) return false;
671
+ textLength += value.length - (cellSnapshot[key]?.length ?? 0);
672
+ }
673
+ if (textLength > 1_000_000) return false;
674
+ const nextSheets = sheets.map((sheet) => sheet.id === currentSheet.id
675
+ ? { ...sheet, cells: { ...sheet.cells, ...Object.fromEntries(values) } }
676
+ : sheet);
677
+ commitSheets(nextSheets);
678
+ return true;
679
+ }
680
+
681
+ function clearSelection() {
682
+ synchronizeHistory();
683
+ const values = new Map(selectedKeys().map(key => [key, '']));
684
+ applyCellValues(values);
685
+ }
686
+
687
+ function translateFormula(value: string, rowOffset: number, colOffset: number): string {
688
+ if (!value.startsWith('=')) return value;
689
+ return value.replace(/(?<![A-Z0-9_$])(\$?)([A-Z]+)(\$?)([1-9]\d*)\b/gi, (
690
+ _match,
691
+ columnAbsolute: string,
692
+ letters: string,
693
+ rowAbsolute: string,
694
+ digits: string,
695
+ ) => {
696
+ const row = Number(digits);
697
+ const col = colNameToIndex(letters.toUpperCase());
698
+ const nextRow = rowAbsolute ? row : row + rowOffset;
699
+ const nextCol = columnAbsolute ? col : col + colOffset;
700
+ if (nextRow < 1 || nextRow > gridRows || nextCol < 0 || nextCol >= gridCols) throw new Error('reference');
701
+ return `${columnAbsolute ? '$' : ''}${getColName(nextCol)}${rowAbsolute ? '$' : ''}${nextRow}`;
702
+ });
703
+ }
704
+
705
+ function parseFillNumber(value: string): { value: number; precision: number } | undefined {
706
+ if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) return undefined;
707
+ const number = Number(value);
708
+ if (!Number.isFinite(number)) return undefined;
709
+ return { value: number, precision: value.includes('.') ? value.length - value.indexOf('.') - 1 : 0 };
710
+ }
711
+
712
+ function fillNumberSequence(bounds: NonNullable<ReturnType<typeof rangeBounds>>): {
713
+ first: number;
714
+ step: number;
715
+ precision: number;
716
+ } | undefined {
717
+ const vertical = bounds.firstCol === bounds.lastCol && bounds.lastRow - bounds.firstRow >= 1;
718
+ const horizontal = bounds.firstRow === bounds.lastRow && bounds.lastCol - bounds.firstCol >= 1;
719
+ if (!vertical && !horizontal) return undefined;
720
+ const firstKey = `${getColName(bounds.firstCol)}${bounds.firstRow}`;
721
+ const secondKey = vertical
722
+ ? `${getColName(bounds.firstCol)}${bounds.firstRow + 1}`
723
+ : `${getColName(bounds.firstCol + 1)}${bounds.firstRow}`;
724
+ const first = parseFillNumber(currentSheet?.cells[firstKey] ?? '');
725
+ const second = parseFillNumber(currentSheet?.cells[secondKey] ?? '');
726
+ if (!first || !second) return undefined;
727
+ return {
728
+ first: first.value,
729
+ step: second.value - first.value,
730
+ precision: Math.max(first.precision, second.precision),
731
+ };
732
+ }
733
+
734
+ function fillSelection() {
735
+ if (readonly || !currentSheet || !canRender) return;
736
+ const bounds = rangeBounds();
737
+ if (!bounds || (bounds.firstRow === bounds.lastRow && bounds.firstCol === bounds.lastCol)) return;
738
+ const originKey = `${getColName(bounds.firstCol)}${bounds.firstRow}`;
739
+ const origin = currentSheet.cells[originKey] ?? '';
740
+ const sequence = fillNumberSequence(bounds);
741
+ const values = new Map<string, string>();
742
+ try {
743
+ for (let row = bounds.firstRow; row <= bounds.lastRow; row += 1) {
744
+ for (let col = bounds.firstCol; col <= bounds.lastCol; col += 1) {
745
+ const key = `${getColName(col)}${row}`;
746
+ const offset = bounds.firstCol === bounds.lastCol
747
+ ? row - bounds.firstRow
748
+ : col - bounds.firstCol;
749
+ const translated = translateFormula(origin, row - bounds.firstRow, col - bounds.firstCol);
750
+ if (sequence && !origin.startsWith('=')) {
751
+ const value = sequence.first + sequence.step * offset;
752
+ if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER
753
+ || sequence.precision > 20) throw new Error('sequence');
754
+ values.set(key, offset < 2 ? currentSheet.cells[key] ?? ''
755
+ : sequence.precision > 0 ? value.toFixed(sequence.precision) : String(value));
756
+ } else {
757
+ values.set(key, translated);
758
+ }
759
+ }
760
+ }
761
+ } catch {
762
+ selectionError = true;
763
+ return;
764
+ }
765
+ selectionError = !applyCellValues(values);
766
+ }
767
+
768
+ function handleCellKeydown(event: KeyboardEvent) {
769
+ if (event.isComposing) return;
770
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
771
+ event.preventDefault();
772
+ fillSelection();
773
+ return;
774
+ }
775
+ const end = parseCellKey(selectedRangeEnd);
776
+ if (event.shiftKey && end && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
777
+ event.preventDefault();
778
+ const col = Math.max(0, Math.min(gridCols - 1, colNameToIndex(end.col)
779
+ + (event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0)));
780
+ const row = Math.max(1, Math.min(gridRows, end.row
781
+ + (event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0)));
782
+ selectedRangeEnd = `${getColName(col)}${row}`;
783
+ return;
784
+ }
785
+ if ((event.key === 'Delete' || event.key === 'Backspace') && selectedKeys().length > 1) {
786
+ event.preventDefault();
787
+ clearSelection();
788
+ }
789
+ }
790
+
791
+ function handlePaste(event: ClipboardEvent) {
792
+ event.preventDefault();
793
+ synchronizeHistory();
794
+ if (readonly || !currentSheet || !canRender || !event.clipboardData) return;
795
+ const csv = event.clipboardData.getData('text/csv');
796
+ const text = csv || event.clipboardData.getData('text/plain');
797
+ const rows = parseSpreadsheetClipboard(text, csv ? 'csv' : 'tsv');
798
+ const bounds = rangeBounds();
799
+ selectionError = true;
800
+ const firstPastedRow = rows?.[0];
801
+ if (!rows || !firstPastedRow || !bounds) return;
802
+ const firstRow = bounds.firstRow;
803
+ const firstCol = bounds.firstCol;
804
+ const values = new Map<string, string>();
805
+ for (const [rowOffset, row] of rows.entries()) {
806
+ for (const [colOffset, value] of row.entries()) {
807
+ const rowIndex = firstRow + rowOffset;
808
+ const colIndex = firstCol + colOffset;
809
+ if (rowIndex > gridRows || colIndex >= gridCols) return;
810
+ values.set(`${getColName(colIndex)}${rowIndex}`, value);
811
+ }
812
+ }
813
+ if (!applyCellValues(values)) return;
814
+ selectionError = false;
815
+ selectedCell = `${getColName(firstCol)}${firstRow}`;
816
+ selectedRangeEnd = `${getColName(firstCol + firstPastedRow.length - 1)}${firstRow + rows.length - 1}`;
817
+ }
818
+
819
+ function handleCopy(event: ClipboardEvent) {
820
+ if (!canRender || !currentSheet || selectedKeys().length <= 1 || !event.clipboardData) return;
821
+ const bounds = rangeBounds();
822
+ if (!bounds) return;
823
+ const rows: (string | number)[][] = [];
824
+ for (let row = bounds.firstRow; row <= bounds.lastRow; row += 1) {
825
+ const values: (string | number)[] = [];
826
+ for (let col = bounds.firstCol; col <= bounds.lastCol; col += 1) {
827
+ values.push(evaluatedCells.get(`${getColName(col)}${row}`) ?? '');
828
+ }
829
+ rows.push(values);
830
+ }
831
+ event.preventDefault();
832
+ const tsv = serializeSpreadsheetClipboard(rows, 'tsv');
833
+ const csv = serializeSpreadsheetClipboard(rows, 'csv');
834
+ if (tsv === undefined || csv === undefined) return;
835
+ event.clipboardData.setData('text/plain', tsv);
836
+ event.clipboardData.setData('text/csv', csv);
837
+ }
838
+
162
839
  function updateCellValue(key: string, val: string) {
163
- if (readonly || !currentSheet) return;
840
+ if (readonly || !currentSheet || !canRender || !cellSnapshot || !Object.hasOwn(cellSnapshot, key)
841
+ || val.length > limits.maxCellLength) return;
842
+ const textLength = Object.values(cellSnapshot).reduce((total, value) => total + value.length, 0);
843
+ if (textLength - (cellSnapshot[key]?.length ?? 0) + val.length > 1_000_000) return;
164
844
  const nextSheets = sheets.map((s) => {
165
845
  if (s.id === currentSheet.id) {
166
846
  return {
@@ -173,120 +853,335 @@
173
853
  }
174
854
  return s;
175
855
  });
176
- sheets = nextSheets;
177
- onchange?.(sheets);
178
- }
179
-
180
- function handleFormulaCommit() {
181
- if (!selectedCell) return;
182
- updateCellValue(selectedCell, formulaInput);
856
+ commitSheets(nextSheets);
183
857
  }
184
858
 
185
859
  function addRow() {
186
- if (!currentSheet) return;
860
+ if (readonly || !currentSheet || !canAddRow) return;
187
861
  const next = sheets.map((s) => (s.id === currentSheet.id ? { ...s, rows: s.rows + 1 } : s));
188
- sheets = next;
189
- onchange?.(sheets);
862
+ commitSheets(next);
190
863
  }
191
864
 
192
865
  function addCol() {
193
- if (!currentSheet) return;
866
+ if (readonly || !currentSheet || !canAddCol) return;
194
867
  const next = sheets.map((s) => (s.id === currentSheet.id ? { ...s, cols: s.cols + 1 } : s));
195
- sheets = next;
196
- onchange?.(sheets);
868
+ commitSheets(next);
869
+ }
870
+
871
+ function undo() {
872
+ synchronizeHistory();
873
+ if (readonly || undoHistory.length === 0) return;
874
+ const previous = undoHistory.at(-1);
875
+ if (!previous) return;
876
+ redoHistory = pushBounded(redoHistory);
877
+ undoHistory = undoHistory.slice(0, -1);
878
+ restoreHistory(previous);
879
+ }
880
+
881
+ function redo() {
882
+ synchronizeHistory();
883
+ if (readonly || redoHistory.length === 0) return;
884
+ const next = redoHistory.at(-1);
885
+ if (!next) return;
886
+ undoHistory = pushBounded(undoHistory);
887
+ redoHistory = redoHistory.slice(0, -1);
888
+ restoreHistory(next);
889
+ }
890
+
891
+ function restoreHistory(entry: HistoryEntry) {
892
+ // 历史仅来自当前组件序列化的 SheetData,不接收外部历史载荷。
893
+ const restored = JSON.parse(entry.data) as SheetData[];
894
+ sheets = restored;
895
+ activeSheetId = entry.activeSheetId;
896
+ committedSheets = sheets;
897
+ committedFingerprint = fingerprintSheets(restored);
898
+ committedScopeKey = scopeKey;
899
+ notifyChange(restored);
197
900
  }
198
901
 
199
902
  function exportCsv() {
200
- if (!currentSheet) return;
201
- const lines: string[] = [];
903
+ if (!currentSheet || !canRender) return;
904
+ const records: Record<string, unknown>[] = [];
202
905
  for (let r = 1; r <= currentSheet.rows; r++) {
203
- const rowVals: string[] = [];
906
+ const row: Record<string, unknown> = {};
204
907
  for (let c = 0; c < currentSheet.cols; c++) {
205
908
  const k = `${getColName(c)}${r}`;
206
- const val = getRenderedValue(k).replace(/"/g, '""');
207
- rowVals.push(`"${val}"`);
909
+ const computed = evaluatedCells.get(k) ?? '';
910
+ // 文本先中和公式前缀,数值保留机器可读精度;CSV 引号由公共编码器负责。
911
+ row[getColName(c)] = typeof computed === 'string' && (/^[\t\r\n]/.test(computed) || /^\s*[=+@-]/.test(computed))
912
+ ? `'${computed}` : computed;
208
913
  }
209
- lines.push(rowVals.join(','));
914
+ records.push(row);
210
915
  }
211
- const csvContent = lines.join('\n');
916
+ // 公共编码器包含表头;工作表导出只保留原有数据行。
917
+ const csvContent = toCsv(records).split('\n').slice(1).join('\n');
212
918
  if (onexport) {
213
919
  onexport(csvContent);
214
920
  } else {
215
921
  const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
216
922
  const a = document.createElement('a');
217
- a.href = URL.createObjectURL(blob);
923
+ const url = URL.createObjectURL(blob);
924
+ a.href = url;
218
925
  a.download = `${currentSheet.name}_${Date.now()}.csv`;
219
- a.click();
926
+ try {
927
+ a.click();
928
+ } finally {
929
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
930
+ }
931
+ }
932
+ }
933
+
934
+ function exportWorkbook() {
935
+ const json = workbookJson;
936
+ if (json === undefined) return;
937
+ if (onworkbookexport) {
938
+ onworkbookexport(json);
939
+ return;
940
+ }
941
+ const url = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8;' }));
942
+ const anchor = document.createElement('a');
943
+ anchor.href = url;
944
+ anchor.download = 'workbook.json';
945
+ try {
946
+ anchor.click();
947
+ } finally {
948
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
949
+ }
950
+ }
951
+
952
+ function workbookFitsLimits(workbook: SpreadsheetWorkbook): boolean {
953
+ return workbook.sheets.length <= limits.maxSheets && workbook.sheets.every(sheet =>
954
+ sheet.rows <= limits.maxRows && sheet.cols <= limits.maxCols
955
+ && sheet.rows * sheet.cols <= limits.maxCells
956
+ && Object.values(sheet.cells).every(value => value.length <= limits.maxCellLength));
957
+ }
958
+
959
+ async function exportWorkbookXlsx() {
960
+ const json = workbookJson;
961
+ const exporter = xlsxexporter;
962
+ const deliver = onxlsxexport;
963
+ if (json === undefined || !exporter || workbookXlsxExporting) return;
964
+ const request = {};
965
+ const context = xlsxContext;
966
+ xlsxExportRequest = request;
967
+ const current = () => importMounted && xlsxExportRequest === request && context === xlsxContext;
968
+ workbookXlsxError = false;
969
+ workbookXlsxExporting = true;
970
+ try {
971
+ const workbook = parseSpreadsheetWorkbook(json);
972
+ if (!workbook) throw new Error('invalid workbook');
973
+ const bytes = await exporter(workbook);
974
+ if (!current()) return;
975
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 4 || bytes.byteLength > 50 * 1024 * 1024
976
+ || bytes[0] !== 80 || bytes[1] !== 75 || bytes[2] !== 3 || bytes[3] !== 4) throw new Error('invalid export');
977
+ const snapshot = new Uint8Array(bytes);
978
+ if (deliver) {
979
+ await deliver(snapshot);
980
+ } else {
981
+ const url = URL.createObjectURL(new Blob([snapshot], {
982
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
983
+ }));
984
+ try {
985
+ const anchor = document.createElement('a');
986
+ anchor.href = url;
987
+ anchor.download = 'workbook.xlsx';
988
+ anchor.click();
989
+ } finally {
990
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
991
+ }
992
+ }
993
+ } catch {
994
+ if (current()) workbookXlsxError = true;
995
+ } finally {
996
+ if (current()) workbookXlsxExporting = false;
997
+ }
998
+ }
999
+
1000
+ async function importWorkbook(event: Event) {
1001
+ const input = event.currentTarget;
1002
+ if (!(input instanceof HTMLInputElement)) return;
1003
+ const file = input.files?.[0];
1004
+ input.value = '';
1005
+ workbookImportError = false;
1006
+ pendingWorkbook = undefined;
1007
+ importController?.abort();
1008
+ const controller = new AbortController();
1009
+ importController = controller;
1010
+ const importer = xlsximporter;
1011
+ const request = {};
1012
+ importRequest = request;
1013
+ const context = importContext;
1014
+ const current = () => importMounted && importRequest === request && context === importContext && !readonly;
1015
+ if (readonly || !file || file.size > 10_000_000) {
1016
+ workbookImportError = Boolean(file);
1017
+ return;
1018
+ }
1019
+ try {
1020
+ const bytes = new Uint8Array(await file.arrayBuffer());
1021
+ if (!current()) return;
1022
+ const isXlsx = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
1023
+ || file.name.toLowerCase().endsWith('.xlsx')
1024
+ || bytes[0] === 0x50 && bytes[1] === 0x4b;
1025
+ const workbook = isXlsx
1026
+ ? importer ? snapshotSpreadsheetWorkbook(await importer(bytes.slice(), { ...limits }, controller.signal)) : undefined
1027
+ : parseSpreadsheetWorkbook(new TextDecoder().decode(bytes));
1028
+ if (!current()) return;
1029
+ if (!workbook || !workbookFitsLimits(workbook)) throw new Error('invalid workbook');
1030
+ pendingWorkbook = { workbook, context };
1031
+ } catch {
1032
+ if (current()) workbookImportError = true;
220
1033
  }
221
1034
  }
222
1035
 
1036
+ function confirmWorkbookImport() {
1037
+ if (!importReady || !pendingWorkbook) return;
1038
+ const workbook = pendingWorkbook.workbook;
1039
+ pendingWorkbook = undefined;
1040
+ const firstSheet = workbook.sheets[0];
1041
+ if (!firstSheet) return;
1042
+ commitSheets(workbook.sheets, workbook.activeSheetId ?? firstSheet.id);
1043
+ }
1044
+
223
1045
  function addSheet() {
224
- const id = `sheet_${Date.now()}`;
1046
+ if (readonly || !validWorkbook || sheets.length >= limits.maxSheets) return;
1047
+ const id = `sheet_${crypto.randomUUID()}`;
225
1048
  const newSheet: SheetData = {
226
1049
  id,
227
- name: `Sheet ${sheets.length + 1}`,
228
- rows: 6,
229
- cols: 5,
1050
+ name: i18n.t('spreadsheet.sheetName', { number: String(sheets.length + 1) }),
1051
+ rows: Math.min(6, limits.maxRows, limits.maxCells),
1052
+ cols: Math.min(5, limits.maxCols, Math.max(1, Math.floor(limits.maxCells / Math.min(6, limits.maxRows, limits.maxCells)))),
230
1053
  cells: {},
231
1054
  };
232
- sheets = [...sheets, newSheet];
233
- activeSheetId = id;
234
- onchange?.(sheets);
1055
+ const nextSheets = [...sheets, newSheet];
1056
+ commitSheets(nextSheets, id);
235
1057
  }
236
1058
  </script>
237
1059
 
238
- <div class={cn('svadmin-u-a217b4eaa918 svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af svadmin-u-cd0ad9a56558 svadmin-u-cef5b893cf23 svadmin-u-359090c2d529 svadmin-u-6f7e013d6499 svadmin-u-eb6e8b881acd svadmin-u-2cd02d11d1af', className)}>
239
- <!-- Header Toolbar -->
1060
+ <div data-testid="spreadsheet-view" class={cn('svadmin-u-a217b4eaa918 svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af svadmin-u-cd0ad9a56558 svadmin-u-cef5b893cf23 svadmin-u-359090c2d529 svadmin-u-6f7e013d6499 svadmin-u-eb6e8b881acd svadmin-u-2cd02d11d1af', className)}>
240
1061
  <div class="svadmin-u-60fbb7713999 svadmin-u-1eb5c6df38c1 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-77a2a20e90d4 svadmin-u-f4cc511ff0c1 svadmin-u-65fdbade2025 svadmin-u-05faf5c801ff">
241
1062
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4">
242
1063
  <FileSpreadsheet class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-20aaf08a7ed1 svadmin-u-012fbd121f37" />
243
- <span class="svadmin-u-e83a7042bc91 svadmin-u-d4108abe6359">{currentSheet?.name ?? 'Spreadsheet'}</span>
1064
+ <span class="svadmin-u-e83a7042bc91 svadmin-u-d4108abe6359">{currentSheet?.name ?? i18n.t('spreadsheet.title')}</span>
244
1065
  </div>
245
1066
 
246
- <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-58284b4ea568">
1067
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-58284b4ea568" style:flex-wrap="wrap">
1068
+ <input bind:this={fileInput} aria-label={i18n.t('spreadsheet.importWorkbook')} hidden type="file"
1069
+ accept={xlsximporter ? 'application/json,.json,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : 'application/json,.json'}
1070
+ onchange={importWorkbook} />
247
1071
  {#if !readonly}
248
- <Button variant="outline" size="sm" class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={addRow}>
1072
+ <Button variant="outline" size="sm" onclick={() => fileInput?.click()}>
1073
+ <Upload class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
1074
+ {i18n.t('spreadsheet.importWorkbook')}
1075
+ </Button>
1076
+ <Button variant="ghost" size="sm" disabled={undoHistory.length === 0} class="svadmin-u-f6fe902450dc svadmin-u-8a539c7fe216" onclick={undo} aria-label={i18n.t('spreadsheet.undo')} title={i18n.t('spreadsheet.undo')}>
1077
+ <Undo2 class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
1078
+ </Button>
1079
+ <Button variant="ghost" size="sm" disabled={redoHistory.length === 0} class="svadmin-u-f6fe902450dc svadmin-u-8a539c7fe216" onclick={redo} aria-label={i18n.t('spreadsheet.redo')} title={i18n.t('spreadsheet.redo')}>
1080
+ <Redo2 class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
1081
+ </Button>
1082
+ <Button variant="ghost" size="sm" disabled={!canRender} onclick={clearSelection} aria-label={i18n.t('spreadsheet.clear')} title={i18n.t('spreadsheet.clear')}>
1083
+ <Trash2 class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
1084
+ </Button>
1085
+ <Button variant="ghost" size="sm" disabled={!canRender || selectedKeys().length < 2} onclick={fillSelection} aria-label={i18n.t('spreadsheet.fill')} title={i18n.t('spreadsheet.fill')}>
1086
+ <Copy aria-hidden="true" />
1087
+ </Button>
1088
+ <Button variant="ghost" size="sm" disabled={!canRender} aria-pressed={cellFormat().bold === true} onclick={() => updateCellFormat({ bold: !cellFormat().bold })} aria-label={i18n.t('spreadsheet.bold')} title={i18n.t('spreadsheet.bold')}>
1089
+ <Bold aria-hidden="true" />
1090
+ </Button>
1091
+ <Button variant="ghost" size="sm" disabled={!canRender} aria-pressed={cellFormat().italic === true} onclick={() => updateCellFormat({ italic: !cellFormat().italic })} aria-label={i18n.t('spreadsheet.italic')} title={i18n.t('spreadsheet.italic')}>
1092
+ <Italic aria-hidden="true" />
1093
+ </Button>
1094
+ <select disabled={!canRender} aria-label={i18n.t('spreadsheet.numberFormat')} value={cellFormat().numberFormat ?? 'general'} onchange={(event) => {
1095
+ const value = event.currentTarget.value;
1096
+ if (value === 'general' || value === 'number' || value === 'percent') updateCellFormat({ numberFormat: value });
1097
+ }}>
1098
+ <option value="general">{i18n.t('spreadsheet.general')}</option>
1099
+ <option value="number">{i18n.t('spreadsheet.number')}</option>
1100
+ <option value="percent">{i18n.t('spreadsheet.percent')}</option>
1101
+ </select>
1102
+ <input type="number" min="0" max="20" step="1" style:width="4rem"
1103
+ disabled={!canRender} aria-label={i18n.t('spreadsheet.decimalPlaces')}
1104
+ value={cellFormat().decimalPlaces ?? 2} onchange={(event) => {
1105
+ const value = event.currentTarget.valueAsNumber;
1106
+ if (Number.isInteger(value) && value >= 0 && value <= 20) updateCellFormat({ decimalPlaces: value });
1107
+ }} />
1108
+ <select disabled={!canRender} aria-label={i18n.t('spreadsheet.align')} value={cellFormat().align ?? 'left'} onchange={(event) => {
1109
+ const value = event.currentTarget.value;
1110
+ if (value === 'left' || value === 'center' || value === 'right') updateCellFormat({ align: value });
1111
+ }}>
1112
+ <option value="left">{i18n.t('spreadsheet.left')}</option>
1113
+ <option value="center">{i18n.t('spreadsheet.center')}</option>
1114
+ <option value="right">{i18n.t('spreadsheet.right')}</option>
1115
+ </select>
1116
+ <Button variant="outline" size="sm" disabled={!canAddRow} class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={addRow}>
249
1117
  <Plus class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
250
- Row
1118
+ {i18n.t('spreadsheet.row')}
251
1119
  </Button>
252
- <Button variant="outline" size="sm" class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={addCol}>
1120
+ <Button variant="outline" size="sm" disabled={!canAddCol} class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={addCol}>
253
1121
  <Plus class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
254
- Column
1122
+ {i18n.t('spreadsheet.column')}
255
1123
  </Button>
256
1124
  {/if}
257
1125
 
258
- <Button size="sm" class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={exportCsv}>
1126
+ <Button size="sm" disabled={!canRender} class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={exportCsv}>
1127
+ <Download class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
1128
+ {i18n.t('spreadsheet.export')}
1129
+ </Button>
1130
+ <Button size="sm" disabled={workbookJson === undefined} class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={exportWorkbook}>
259
1131
  <Download class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
260
- Export CSV
1132
+ {i18n.t('spreadsheet.exportWorkbook')}
261
1133
  </Button>
1134
+ {#if xlsxexporter}
1135
+ <Button size="sm" disabled={workbookJson === undefined || workbookXlsxExporting} aria-busy={workbookXlsxExporting}
1136
+ class="svadmin-u-d0a52b312f7d svadmin-u-359090c2d529 svadmin-u-44ee8ba0a421" onclick={exportWorkbookXlsx}>
1137
+ <Download class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
1138
+ {i18n.t('spreadsheet.exportXlsx')}
1139
+ </Button>
1140
+ {/if}
262
1141
  </div>
263
1142
  </div>
1143
+ {#if workbookImportError}<p role="alert">{i18n.t('spreadsheet.importRejected')}</p>{/if}
1144
+ {#if workbookXlsxError}<p role="alert">{i18n.t('common.operationFailed')}</p>{/if}
1145
+ {#if importReady && pendingWorkbook}
1146
+ <div role="group" aria-label={i18n.t('spreadsheet.confirmImport')}>
1147
+ <p>{i18n.t('spreadsheet.replaceWorkbook')}</p>
1148
+ <Button onclick={confirmWorkbookImport}>{i18n.t('spreadsheet.confirmImport')}</Button>
1149
+ <Button variant="ghost" onclick={() => { pendingWorkbook = undefined; }}>{i18n.t('common.cancel')}</Button>
1150
+ </div>
1151
+ {/if}
264
1152
 
265
1153
  <!-- Formula Bar -->
266
1154
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4 svadmin-u-d5eab218aa34 svadmin-u-ec0091ee009b svadmin-u-5f22e64f2282 svadmin-u-b00f43c30c2b svadmin-u-ca6bcd4b6f3f svadmin-u-05faf5c801ff svadmin-u-0e65706bcccd">
267
- <div class="svadmin-u-d5eab218aa34 svadmin-u-465609a240a8 svadmin-u-07389a777c1f svadmin-u-e6f9e383a762 svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af svadmin-u-d4108abe6359 svadmin-u-e83a7042bc91 svadmin-u-d058ca6de60f svadmin-u-28ae52cc20ae svadmin-u-ca6bf63030aa">
268
- {selectedCell}
269
- </div>
1155
+ <div class="svadmin-u-d5eab218aa34 svadmin-u-465609a240a8 svadmin-u-07389a777c1f svadmin-u-e6f9e383a762 svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af svadmin-u-d4108abe6359 svadmin-u-e83a7042bc91 svadmin-u-d058ca6de60f svadmin-u-28ae52cc20ae svadmin-u-ca6bf63030aa">{selectedCell}</div>
270
1156
  <span class="svadmin-u-bfa603190748 svadmin-u-359090c2d529">fx</span>
271
1157
  <input
272
1158
  type="text"
273
1159
  bind:value={formulaInput}
274
1160
  {readonly}
275
- placeholder="Value or formula, e.g. =SUM(B2:E2)"
1161
+ disabled={!canRender}
1162
+ maxlength={limits.maxCellLength}
1163
+ aria-label={i18n.t('spreadsheet.formula')}
276
1164
  class="svadmin-u-36e579c0b41c svadmin-u-f6fe902450dc svadmin-u-7f19cdf4c5bb svadmin-u-119b2aa0b8f6 svadmin-u-d8e0e382c67b svadmin-u-359090c2d529 svadmin-u-d4108abe6359 svadmin-u-f10f771f87e9 svadmin-u-35ca6b75d707 svadmin-u-0e65706bcccd"
277
- oninput={handleFormulaCommit}
278
- onkeydown={(e) => { if (e.key === 'Enter') handleFormulaCommit(); }}
1165
+ oninput={(e) => updateCellValue(selectedCell, e.currentTarget.value)}
1166
+ onkeydown={(e) => { if (e.key === 'Enter') updateCellValue(selectedCell, e.currentTarget.value); }}
279
1167
  />
280
1168
  </div>
281
1169
 
282
1170
  <!-- Spreadsheet Grid -->
283
- {#if currentSheet}
1171
+ {#if selectionError}
1172
+ <p role="alert">{i18n.t('spreadsheet.pasteRejected')}</p>
1173
+ {/if}
1174
+ {#if !validWorkbook || (currentSheet && !canRender)}
1175
+ <p role="alert">{i18n.t('spreadsheet.invalid')}</p>
1176
+ {:else if !currentSheet}
1177
+ <p role="status">{i18n.t('spreadsheet.empty')}</p>
1178
+ {:else}
284
1179
  <div class="svadmin-u-73fc3fb18ceb svadmin-u-b63b7d8eef5d svadmin-u-ca6bcd4b6f3f svadmin-u-05faf5c801ff svadmin-u-5f22e64f2282">
285
1180
  <table class="svadmin-u-6da6a3c3f741 svadmin-u-4583f90cd9bd svadmin-u-2eba0d65d059 svadmin-u-0e65706bcccd">
286
1181
  <thead class="svadmin-u-706701550477 svadmin-u-3e0fd166d494 svadmin-u-2167406b24d7 svadmin-u-236812d64c82 svadmin-u-bfa603190748">
287
1182
  <tr>
288
1183
  <th class="svadmin-u-d854e5698b57 svadmin-u-cd009d7d208c svadmin-u-ca6bf63030aa svadmin-u-5ceb636bd9f3 svadmin-u-65fdbade2025 svadmin-u-05faf5c801ff svadmin-u-85d919893645 svadmin-u-1dc571a3609f">#</th>
289
- {#each Array.from({ length: currentSheet.cols }) as _, c (c)}
1184
+ {#each Array.from({ length: gridCols }) as _, c (c)}
290
1185
  <th class="svadmin-u-25effcb585ab svadmin-u-cd009d7d208c svadmin-u-ca6bf63030aa svadmin-u-5ceb636bd9f3 svadmin-u-65fdbade2025 svadmin-u-05faf5c801ff svadmin-u-e83a7042bc91 svadmin-u-d4108abe6359 svadmin-u-359090c2d529">
291
1186
  {getColName(c)}
292
1187
  </th>
@@ -294,32 +1189,50 @@
294
1189
  </tr>
295
1190
  </thead>
296
1191
  <tbody class="svadmin-u-fa6acbf81d74 svadmin-u-08b5607c7258">
297
- {#each Array.from({ length: currentSheet.rows }) as _, r (r)}
1192
+ {#each Array.from({ length: gridRows }) as _, r (r)}
298
1193
  {@const rowNum = r + 1}
299
1194
  <tr class="svadmin-u-9fc5d875f5ca">
300
1195
  <td class="svadmin-u-d854e5698b57 svadmin-u-eb6a3cef9686 svadmin-u-ca6bf63030aa svadmin-u-5ceb636bd9f3 svadmin-u-05faf5c801ff svadmin-u-2859c861d7de svadmin-u-1dc571a3609f svadmin-u-e83a7042bc91 svadmin-u-bfa603190748 svadmin-u-7f6912283f11">
301
1196
  {rowNum}
302
1197
  </td>
303
- {#each Array.from({ length: currentSheet.cols }) as _, c (c)}
1198
+ {#each Array.from({ length: gridCols }) as _, c (c)}
304
1199
  {@const cellKey = `${getColName(c)}${rowNum}`}
305
- {@const isSelected = selectedCell === cellKey}
1200
+ {@const computedCell = evaluatedCells.get(cellKey)}
1201
+ {@const errorCell = typeof computedCell === 'string' ? computedCell : ''}
1202
+ {@const isSelected = isCellSelected(cellKey)}
306
1203
  <td
1204
+ data-selected={isSelected || undefined}
307
1205
  class={cn(
308
1206
  'svadmin-u-8a539c7fe216 svadmin-u-5ceb636bd9f3 svadmin-u-6ee2d41e2d2d svadmin-u-d89972fe17d6 svadmin-u-34516836730d',
309
1207
  isSelected ? 'svadmin-u-16b1efa5875e svadmin-u-fd1dda07320a svadmin-u-236812d64c82 svadmin-u-989c466fdbe7' : ''
310
1208
  )}
311
- onclick={() => selectCell(cellKey)}
1209
+ onmousedown={(event) => {
1210
+ if (event.shiftKey) event.preventDefault();
1211
+ }}
1212
+ onclick={(event) => selectRange(cellKey, event.shiftKey)}
312
1213
  >
313
1214
  <input
314
1215
  type="text"
315
1216
  {readonly}
316
- value={isSelected ? (currentSheet.cells[cellKey] ?? '') : getRenderedValue(cellKey)}
1217
+ aria-label={cellKey}
1218
+ maxlength={limits.maxCellLength}
1219
+ value={selectedCell === cellKey && !readonly
1220
+ && !errorCell.startsWith('#')
1221
+ ? (cellSnapshot?.[cellKey] ?? '')
1222
+ : getRenderedValue(cellKey)}
317
1223
  class={cn(
318
1224
  'svadmin-u-6da6a3c3f741 svadmin-u-d0a52b312f7d svadmin-u-d5eab218aa34 svadmin-u-7f19cdf4c5bb svadmin-u-119b2aa0b8f6 svadmin-u-359090c2d529 svadmin-u-0e65706bcccd svadmin-u-f10f771f87e9',
319
- typeof evaluateFormula(currentSheet.cells[cellKey] ?? '', currentSheet.cells) === 'number' ? 'svadmin-u-308fc069e46e' : 'svadmin-u-2eba0d65d059'
1225
+ typeof computedCell === 'number' ? 'svadmin-u-308fc069e46e' : 'svadmin-u-2eba0d65d059'
320
1226
  )}
1227
+ style:font-weight={cellFormat(cellKey).bold === true ? '700' : '400'}
1228
+ style:font-style={cellFormat(cellKey).italic === true ? 'italic' : 'normal'}
1229
+ style:text-align={['left', 'center', 'right'].includes(cellFormat(cellKey).align ?? '')
1230
+ ? cellFormat(cellKey).align : typeof computedCell === 'number' ? 'right' : 'left'}
321
1231
  onfocus={() => selectCell(cellKey)}
322
1232
  oninput={(e) => updateCellValue(cellKey, e.currentTarget.value)}
1233
+ onkeydown={handleCellKeydown}
1234
+ oncopy={handleCopy}
1235
+ onpaste={handlePaste}
323
1236
  />
324
1237
  </td>
325
1238
  {/each}
@@ -329,11 +1242,9 @@
329
1242
  </table>
330
1243
  </div>
331
1244
  {/if}
332
-
333
- <!-- Sheet Tabs Footer -->
334
1245
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-77a2a20e90d4 svadmin-u-6b7d6e21ccbd svadmin-u-b950dda299d3 svadmin-u-05faf5c801ff">
335
1246
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-44ee8ba0a421 svadmin-u-1384f66f41d0">
336
- {#each sheets as sheet (sheet.id)}
1247
+ {#each (validWorkbook ? sheets : []) as sheet (sheet.id)}
337
1248
  {@const isActive = sheet.id === activeSheetId}
338
1249
  <button
339
1250
  type="button"
@@ -346,6 +1257,8 @@
346
1257
  onclick={() => {
347
1258
  activeSheetId = sheet.id;
348
1259
  selectedCell = 'A1';
1260
+ selectedRangeEnd = 'A1';
1261
+ selectionError = false;
349
1262
  formulaInput = sheet.cells['A1'] ?? '';
350
1263
  }}
351
1264
  >
@@ -354,7 +1267,7 @@
354
1267
  {/each}
355
1268
 
356
1269
  {#if !readonly}
357
- <Button variant="ghost" size="sm" class="svadmin-u-f6fe902450dc svadmin-u-7ec10f86d9b1 svadmin-u-8a539c7fe216 svadmin-u-bfa603190748" onclick={addSheet} title="New Sheet">
1270
+ <Button variant="ghost" size="sm" disabled={!validWorkbook || sheets.length >= limits.maxSheets} class="svadmin-u-f6fe902450dc svadmin-u-7ec10f86d9b1 svadmin-u-8a539c7fe216 svadmin-u-bfa603190748" onclick={addSheet} aria-label={i18n.t('spreadsheet.newSheet')} title={i18n.t('spreadsheet.newSheet')}>
358
1271
  <Plus class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
359
1272
  </Button>
360
1273
  {/if}