@svgrid/enterprise 3.0.1 → 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.
- package/dist/SvFormulaBar.svelte +606 -0
- package/dist/SvFormulaBar.svelte.d.ts +62 -0
- package/dist/SvPivotDesigner.svelte +65 -14
- package/dist/SvRibbonIcon.svelte +46 -0
- package/dist/SvRibbonIcon.svelte.d.ts +17 -0
- package/dist/SvSchemaChart.svelte +4 -2
- package/dist/SvSheet.svelte +4114 -0
- package/dist/SvSheet.svelte.d.ts +142 -0
- package/dist/SvSheetComment.svelte +98 -0
- package/dist/SvSheetComment.svelte.d.ts +22 -0
- package/dist/SvSheetConditionalFormat.svelte +162 -0
- package/dist/SvSheetConditionalFormat.svelte.d.ts +14 -0
- package/dist/SvSheetDataValidation.svelte +249 -0
- package/dist/SvSheetDataValidation.svelte.d.ts +14 -0
- package/dist/SvSheetFilterMenu.svelte +217 -0
- package/dist/SvSheetFilterMenu.svelte.d.ts +17 -0
- package/dist/SvSheetFindReplace.svelte +132 -0
- package/dist/SvSheetFindReplace.svelte.d.ts +10 -0
- package/dist/SvSheetFormatCells.svelte +421 -0
- package/dist/SvSheetFormatCells.svelte.d.ts +17 -0
- package/dist/SvSheetGoalSeek.svelte +144 -0
- package/dist/SvSheetGoalSeek.svelte.d.ts +12 -0
- package/dist/SvSheetInsertFunction.svelte +147 -0
- package/dist/SvSheetInsertFunction.svelte.d.ts +8 -0
- package/dist/SvSheetListPicker.svelte +89 -0
- package/dist/SvSheetListPicker.svelte.d.ts +16 -0
- package/dist/SvSheetManageRules.svelte +185 -0
- package/dist/SvSheetManageRules.svelte.d.ts +16 -0
- package/dist/SvSheetNameManager.svelte +225 -0
- package/dist/SvSheetNameManager.svelte.d.ts +11 -0
- package/dist/SvSheetPasteSpecial.svelte +105 -0
- package/dist/SvSheetPasteSpecial.svelte.d.ts +12 -0
- package/dist/SvSheetRemoveDuplicates.svelte +149 -0
- package/dist/SvSheetRemoveDuplicates.svelte.d.ts +12 -0
- package/dist/SvSheetRibbon.svelte +1201 -0
- package/dist/SvSheetRibbon.svelte.d.ts +34 -0
- package/dist/SvSheetSizeDialog.svelte +58 -0
- package/dist/SvSheetSizeDialog.svelte.d.ts +11 -0
- package/dist/SvSheetTabs.svelte +441 -0
- package/dist/SvSheetTabs.svelte.d.ts +36 -0
- package/dist/SvSheetTabs.test-harness.svelte.d.ts +17 -0
- package/dist/SvSheetTabs.test-harness.svelte.js +22 -0
- package/dist/SvSheetTextToColumns.svelte +172 -0
- package/dist/SvSheetTextToColumns.svelte.d.ts +13 -0
- package/dist/SvSheetValidationAlert.svelte +83 -0
- package/dist/SvSheetValidationAlert.svelte.d.ts +15 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +18997 -5396
- package/dist/designer/assets/GridMenus-BlsvL_aU.js +8 -0
- package/dist/designer/assets/SvChartMenu-BYO9cYdy.css +1 -0
- package/dist/designer/assets/SvChartMenu-D9hEeHar.js +1 -0
- package/dist/designer/assets/SvDateRangeInput-BBRzrQeg.js +1 -0
- package/dist/designer/assets/SvDateTimePicker-DARvKUL2.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-C7uQXYds.js +4 -0
- package/dist/designer/assets/SvGridChart-B8j1SSy3.js +3 -0
- package/dist/designer/assets/SvGridChart-BgLuPO7I.css +1 -0
- package/dist/designer/assets/SvGridChartBuilder-08YWWeqY.css +1 -0
- package/dist/designer/assets/SvGridChartBuilder-kvyIU_Rl.js +1 -0
- package/dist/designer/assets/SvGridChartPanel-4Pc8y5yO.css +1 -0
- package/dist/designer/assets/SvGridChartPanel-pT6ITc2P.js +4 -0
- package/dist/designer/assets/{SvGridChartView-DUc-1RUf.css → SvGridChartView-7msfnWZT.css} +1 -1
- package/dist/designer/assets/SvGridChartView-bg281UA1.js +1 -0
- package/dist/designer/assets/SvListBox-BDS66lmZ.js +110 -0
- package/dist/designer/assets/SvMenuList-DJ47ba1h.css +1 -0
- package/dist/designer/assets/SvMenuList-Dgq2D5VN.js +1 -0
- package/dist/designer/assets/SvTabs-DKmwb5p1.css +1 -0
- package/dist/designer/assets/SvTabs-UFCMTHXS.js +1 -0
- package/dist/designer/assets/chart-cIPUsbM5.js +1 -0
- package/dist/designer/assets/chart-export-B6bkQ3X8.js +11 -0
- package/dist/designer/assets/chart-export-pdf-BbA28-gt.js +11 -0
- package/dist/designer/assets/chart-panel-messages-DtTxpBUU.js +1 -0
- package/dist/designer/assets/chart-summary-Czu_5epb.js +1 -0
- package/dist/designer/assets/chart-trend-D5I5VEe8.js +1 -0
- package/dist/designer/assets/chart-validate-BMqM-qkF.js +1 -0
- package/dist/designer/assets/column-resize-BYDzR1Ve.js +1 -0
- package/dist/designer/assets/date-format-B8JbEtIG.js +1 -0
- package/dist/designer/assets/date-format-DKS-z0xS.css +1 -0
- package/dist/designer/assets/editor-contract-Bpoa7A5g.js +1 -0
- package/dist/designer/assets/focus-trap-CAreAhlU.js +1 -0
- package/dist/designer/assets/index-CMFgAoVV.css +1 -0
- package/dist/designer/assets/{index-C_a9Cj8Y.js → index-DwU7CAMm.js} +291 -290
- package/dist/designer/assets/list-option-BP_dWS66.js +1 -0
- package/dist/designer/assets/row-resize-DLktcqUK.js +1 -0
- package/dist/designer/assets/src-CITpcm6b.js +1 -0
- package/dist/designer/assets/src-EdI3Xqds.js +58 -0
- package/dist/designer/index.html +23 -14
- package/dist/export-pdf.d.ts +89 -0
- package/dist/export-pdf.js +130 -0
- package/dist/export.js +17 -10
- package/dist/index.d.ts +64 -1
- package/dist/index.js +68 -1
- package/dist/install.js +5 -0
- package/dist/node/studio.js +64 -2
- package/dist/pivot-chart.d.ts +25 -1
- package/dist/pivot-chart.js +25 -69
- package/dist/sheet/address.d.ts +43 -0
- package/dist/sheet/address.js +81 -0
- package/dist/sheet/ast.d.ts +94 -0
- package/dist/sheet/ast.js +56 -0
- package/dist/sheet/auto-filter.d.ts +66 -0
- package/dist/sheet/auto-filter.js +147 -0
- package/dist/sheet/autocomplete.d.ts +42 -0
- package/dist/sheet/autocomplete.js +162 -0
- package/dist/sheet/coerce.d.ts +31 -0
- package/dist/sheet/coerce.js +122 -0
- package/dist/sheet/commands.d.ts +89 -0
- package/dist/sheet/commands.js +230 -0
- package/dist/sheet/comments.d.ts +29 -0
- package/dist/sheet/comments.js +73 -0
- package/dist/sheet/conditional-formats.d.ts +144 -0
- package/dist/sheet/conditional-formats.js +316 -0
- package/dist/sheet/deps.d.ts +60 -0
- package/dist/sheet/deps.js +199 -0
- package/dist/sheet/document.d.ts +150 -0
- package/dist/sheet/document.js +264 -0
- package/dist/sheet/edit-keys.d.ts +14 -0
- package/dist/sheet/edit-keys.js +40 -0
- package/dist/sheet/entry.d.ts +31 -0
- package/dist/sheet/entry.js +76 -0
- package/dist/sheet/evaluate.d.ts +61 -0
- package/dist/sheet/evaluate.js +337 -0
- package/dist/sheet/find-replace.d.ts +62 -0
- package/dist/sheet/find-replace.js +152 -0
- package/dist/sheet/format-store.d.ts +99 -0
- package/dist/sheet/format-store.js +233 -0
- package/dist/sheet/freeze.d.ts +54 -0
- package/dist/sheet/freeze.js +58 -0
- package/dist/sheet/function-catalog.d.ts +10 -0
- package/dist/sheet/function-catalog.js +94 -0
- package/dist/sheet/functions.d.ts +28 -0
- package/dist/sheet/functions.js +362 -0
- package/dist/sheet/goal-seek.d.ts +73 -0
- package/dist/sheet/goal-seek.js +153 -0
- package/dist/sheet/index.d.ts +53 -0
- package/dist/sheet/index.js +53 -0
- package/dist/sheet/merges.d.ts +71 -0
- package/dist/sheet/merges.js +137 -0
- package/dist/sheet/names.d.ts +31 -0
- package/dist/sheet/names.js +87 -0
- package/dist/sheet/navigate.d.ts +52 -0
- package/dist/sheet/navigate.js +135 -0
- package/dist/sheet/number-format.d.ts +63 -0
- package/dist/sheet/number-format.js +508 -0
- package/dist/sheet/palette.d.ts +19 -0
- package/dist/sheet/palette.js +84 -0
- package/dist/sheet/parse.d.ts +13 -0
- package/dist/sheet/parse.js +108 -0
- package/dist/sheet/paste-special.d.ts +164 -0
- package/dist/sheet/paste-special.js +592 -0
- package/dist/sheet/protection.d.ts +21 -0
- package/dist/sheet/protection.js +48 -0
- package/dist/sheet/rects.d.ts +45 -0
- package/dist/sheet/rects.js +112 -0
- package/dist/sheet/refs.d.ts +82 -0
- package/dist/sheet/refs.js +266 -0
- package/dist/sheet/ribbon-icons.d.ts +31 -0
- package/dist/sheet/ribbon-icons.js +350 -0
- package/dist/sheet/ribbon.d.ts +173 -0
- package/dist/sheet/ribbon.js +753 -0
- package/dist/sheet/shortcuts.d.ts +164 -0
- package/dist/sheet/shortcuts.js +623 -0
- package/dist/sheet/structure.d.ts +87 -0
- package/dist/sheet/structure.js +166 -0
- package/dist/sheet/tables.d.ts +70 -0
- package/dist/sheet/tables.js +147 -0
- package/dist/sheet/tokenize.d.ts +54 -0
- package/dist/sheet/tokenize.js +304 -0
- package/dist/sheet/transforms.d.ts +76 -0
- package/dist/sheet/transforms.js +154 -0
- package/dist/sheet/validation.d.ts +92 -0
- package/dist/sheet/validation.js +241 -0
- package/dist/sheet/workbook.d.ts +72 -0
- package/dist/sheet/workbook.js +409 -0
- package/dist/sheet-enable.d.ts +6 -0
- package/dist/sheet-enable.js +46 -0
- package/dist/studio/ui-components.generated.js +63 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -4
- package/dist/designer/assets/GridMenus-B1JtIN21.js +0 -8
- package/dist/designer/assets/SvDateRangeInput-CjO7nn_x.js +0 -1
- package/dist/designer/assets/SvDateTimePicker-ByBD_ov6.js +0 -1
- package/dist/designer/assets/SvField-YDLipR6O.css +0 -1
- package/dist/designer/assets/SvField-cj1PFNgZ.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-0W1L2hJ1.js +0 -2
- package/dist/designer/assets/SvGridChart-DBOR4xcz.js +0 -2
- package/dist/designer/assets/SvGridChart-fkMWeh1J.css +0 -1
- package/dist/designer/assets/SvGridChartPanel-symrhNnR.js +0 -11
- package/dist/designer/assets/SvGridChartView-BmRxq6mU.js +0 -1
- package/dist/designer/assets/SvListBox-1oUF6Iav.js +0 -110
- package/dist/designer/assets/chart-DXFganU8.js +0 -1
- package/dist/designer/assets/column-resize-C_inr5zT.js +0 -1
- package/dist/designer/assets/date-format-DGagAqRc.css +0 -1
- package/dist/designer/assets/date-format-onlYDsrY.js +0 -1
- package/dist/designer/assets/easing-zaypKrr0.js +0 -3
- package/dist/designer/assets/index-k2stW6L8.css +0 -1
- package/dist/designer/assets/row-resize-B0FWHcNr.js +0 -1
- package/dist/designer/assets/src-D9p27OGC.js +0 -58
|
@@ -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>
|