@casualoffice/sheets 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * PasteSpecialDialog — the SDK chrome's built-in Paste Special modal.
19
+ *
20
+ * Copies the DataValidationDialog/FormatCellsDialog structure: reads the active
21
+ * A1 selection off the FUniver facade, gathers a mode via a small form, and
22
+ * applies through real Univer commands / facade calls (no app context).
23
+ *
24
+ * Modes → mechanism (all grounded in the INSTALLED @univerjs/sheets-ui build):
25
+ * - values → `sheet.command.paste` with `{ value: 'special-paste-value' }`
26
+ * - formats → `sheet.command.paste` with `{ value: 'special-paste-format' }`
27
+ * - formulas → `sheet.command.paste` with `{ value: 'special-paste-formula' }`
28
+ *
29
+ * The special-paste hook names live in `PREDEFINED_HOOK_NAME_PASTE`
30
+ * (sheets-ui/lib/es/index.js: SPECIAL_PASTE_VALUE/FORMAT/FORMULA), and the
31
+ * dedicated `SheetPasteValueCommand`/`SheetPasteFormatCommand` handlers just
32
+ * forward `SheetPasteCommand.id` (= 'sheet.command.paste', name verified in
33
+ * the same build) with those `{ value }` params. We dispatch the base
34
+ * `sheet.command.paste` with the matching hook value so all three modes go
35
+ * through one real, installed command — including 'formulas', which has no
36
+ * dedicated `sheet.command.paste-*` id but IS a valid paste hook.
37
+ *
38
+ * - transpose → in-place matrix flip via the sheets facade:
39
+ * `FRange.getValues()` → transpose → `getRange(row, col, cols, rows)` →
40
+ * `.setValues()`. There is NO transpose paste command anywhere in the
41
+ * installed sheets/sheets-ui build (grep 'transpose' returns nothing), so
42
+ * rather than stub it we transpose the CURRENT selection in place using the
43
+ * real `getValues`/`getRange`/`setValues` facade methods (verified in
44
+ * @univerjs/sheets/facade f-range.d.ts + f-worksheet.d.ts). See the
45
+ * `limitations` note: this transposes the selection itself, not clipboard
46
+ * contents (the facade exposes no clipboard-transpose primitive).
47
+ *
48
+ * Mounted by `<DialogHost>` when `openDialog('paste-special')` is called and no
49
+ * host override is registered.
50
+ */
51
+
52
+ import { useMemo, useState, type CSSProperties } from 'react';
53
+ // Side-effect import: installs the sheets-ui facade extensions and registers
54
+ // the clipboard/paste commands on the FUniver facade so
55
+ // `executeCommand('sheet.command.paste', …)` resolves at runtime. Mirrors the
56
+ // `@univerjs/sheets/facade` side-effect import the SDK's api.ts already does for
57
+ // the core sheets mixins.
58
+ import '@univerjs/sheets-ui/facade';
59
+ import type { DialogComponentProps } from './extensions';
60
+ import type { CasualSheetsAPI } from '../sheets/api';
61
+ import { Dialog } from './Dialog';
62
+ import {
63
+ DIALOG_BTN_PRIMARY_STYLE,
64
+ DIALOG_BTN_SECONDARY_STYLE,
65
+ DIALOG_FIELD_STYLE,
66
+ DIALOG_LABEL_STYLE,
67
+ } from './dialog-styles';
68
+
69
+ /** Paste-special modes this dialog offers. */
70
+ type PasteMode = 'values' | 'formats' | 'formulas' | 'transpose';
71
+
72
+ const MODE_OPTIONS: Array<{ value: PasteMode; label: string; hint: string }> = [
73
+ {
74
+ value: 'values',
75
+ label: 'Values only',
76
+ hint: 'Paste cell values, dropping formatting and formulas.',
77
+ },
78
+ {
79
+ value: 'formats',
80
+ label: 'Formats only',
81
+ hint: 'Paste number formats, fonts, borders and fills — no values.',
82
+ },
83
+ {
84
+ value: 'formulas',
85
+ label: 'Formulas only',
86
+ hint: 'Paste formulas, adjusting relative references.',
87
+ },
88
+ {
89
+ value: 'transpose',
90
+ label: 'Transpose',
91
+ hint: 'Flip the current selection — rows become columns and columns become rows, in place.',
92
+ },
93
+ ];
94
+
95
+ /** Paste hook names from sheets-ui `PREDEFINED_HOOK_NAME_PASTE`. */
96
+ const PASTE_HOOK: Record<'values' | 'formats' | 'formulas', string> = {
97
+ values: 'special-paste-value',
98
+ formats: 'special-paste-format',
99
+ formulas: 'special-paste-formula',
100
+ };
101
+
102
+ /** The base sheets paste command; the special modes are `{ value: <hook> }`. */
103
+ const SHEET_PASTE_COMMAND = 'sheet.command.paste';
104
+
105
+ /** Minimal shape of the FRange we lean on for the transpose path. */
106
+ interface TransposableRange {
107
+ getRow(): number;
108
+ getColumn(): number;
109
+ getWidth(): number;
110
+ getHeight(): number;
111
+ getValues(): unknown[][];
112
+ getA1Notation?(): string;
113
+ }
114
+
115
+ /** The active FRange, or null when there is no selection. */
116
+ function activeRange(api: CasualSheetsAPI) {
117
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
118
+ }
119
+
120
+ /**
121
+ * Transpose the current selection in place using the sheets facade: read the
122
+ * value matrix, flip it, then write it back into a range whose rows/columns are
123
+ * swapped, anchored at the same top-left cell. Returns false when there is no
124
+ * selection or nothing to transpose.
125
+ */
126
+ function transposeSelection(api: CasualSheetsAPI): boolean {
127
+ const sheet = api.univer.getActiveWorkbook()?.getActiveSheet();
128
+ const range = sheet?.getActiveRange() as unknown as TransposableRange | null;
129
+ if (!sheet || !range) return false;
130
+
131
+ const rows = range.getHeight();
132
+ const cols = range.getWidth();
133
+ if (rows <= 0 || cols <= 0) return false;
134
+
135
+ const values = range.getValues();
136
+ const transposed: unknown[][] = [];
137
+ for (let c = 0; c < cols; c++) {
138
+ const newRow: unknown[] = [];
139
+ for (let r = 0; r < rows; r++) {
140
+ newRow.push(values[r]?.[c] ?? null);
141
+ }
142
+ transposed.push(newRow);
143
+ }
144
+
145
+ // New range: same anchor, swapped dimensions (cols rows × rows columns).
146
+ const target = sheet.getRange(range.getRow(), range.getColumn(), cols, rows) as unknown as {
147
+ setValues: (v: unknown[][]) => unknown;
148
+ };
149
+ target.setValues(transposed);
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Apply the chosen paste-special mode. Values/formats/formulas dispatch the real
155
+ * `sheet.command.paste` with the matching special-paste hook; transpose flips the
156
+ * selection in place. Returns a promise that resolves to whether it ran.
157
+ */
158
+ async function applyPasteSpecial(api: CasualSheetsAPI, mode: PasteMode): Promise<boolean> {
159
+ if (activeRange(api) === null) return false;
160
+
161
+ if (mode === 'transpose') {
162
+ return transposeSelection(api);
163
+ }
164
+
165
+ return api.executeCommand(SHEET_PASTE_COMMAND, { value: PASTE_HOOK[mode] });
166
+ }
167
+
168
+ const MODE_RADIO_ROW_STYLE: CSSProperties = {
169
+ display: 'flex',
170
+ alignItems: 'flex-start',
171
+ gap: 8,
172
+ padding: '8px 10px',
173
+ border: '1px solid var(--cs-chrome-border, #cdd3db)',
174
+ borderRadius: 6,
175
+ marginBottom: 8,
176
+ cursor: 'pointer',
177
+ };
178
+
179
+ const MODE_RADIO_ROW_ACTIVE_STYLE: CSSProperties = {
180
+ ...MODE_RADIO_ROW_STYLE,
181
+ borderColor: 'var(--cs-chrome-active-fg, #0e7490)',
182
+ background: 'var(--cs-chrome-active-bg, rgba(14, 116, 144, 0.06))',
183
+ };
184
+
185
+ const MODE_LABEL_STYLE: CSSProperties = {
186
+ fontSize: 13,
187
+ fontWeight: 500,
188
+ color: 'var(--cs-chrome-fg, #201f1e)',
189
+ };
190
+
191
+ const MODE_HINT_STYLE: CSSProperties = {
192
+ fontSize: 12,
193
+ color: 'var(--cs-chrome-muted, #605e5c)',
194
+ marginTop: 2,
195
+ lineHeight: 1.35,
196
+ };
197
+
198
+ const RANGE_NOTE_STYLE: CSSProperties = {
199
+ fontSize: 12,
200
+ color: 'var(--cs-chrome-muted, #605e5c)',
201
+ marginBottom: 12,
202
+ };
203
+
204
+ export function PasteSpecialDialog({ api, onClose }: DialogComponentProps) {
205
+ const [mode, setMode] = useState<PasteMode>('values');
206
+
207
+ // Read the selection once for the header hint (getA1Notation is verified on
208
+ // the sheets facade FRange — f-range.d.ts).
209
+ const rangeLabel = useMemo(() => {
210
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
211
+ return fRange?.getA1Notation?.() ?? null;
212
+ }, [api]);
213
+
214
+ const hasSelection = activeRange(api) !== null;
215
+
216
+ const apply = () => {
217
+ void applyPasteSpecial(api, mode).then((ok) => {
218
+ if (ok) onClose();
219
+ });
220
+ };
221
+
222
+ return (
223
+ <Dialog
224
+ title="Paste special"
225
+ onClose={onClose}
226
+ width={440}
227
+ data-testid="cs-paste-special-dialog"
228
+ footer={
229
+ <>
230
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
231
+ Cancel
232
+ </button>
233
+ <button
234
+ type="button"
235
+ style={DIALOG_BTN_PRIMARY_STYLE}
236
+ data-testid="cs-paste-special-apply"
237
+ disabled={!hasSelection}
238
+ onClick={apply}
239
+ >
240
+ Paste
241
+ </button>
242
+ </>
243
+ }
244
+ >
245
+ {hasSelection ? (
246
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-paste-special-range">
247
+ {mode === 'transpose' ? 'Transposes' : 'Pastes into'}{' '}
248
+ <strong>{rangeLabel ?? 'the current selection'}</strong>
249
+ </div>
250
+ ) : (
251
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-paste-special-no-selection">
252
+ Select the destination cell(s) first, then reopen this dialog.
253
+ </div>
254
+ )}
255
+
256
+ <div style={DIALOG_FIELD_STYLE}>
257
+ <span style={DIALOG_LABEL_STYLE}>Paste</span>
258
+ <div role="radiogroup" aria-label="Paste special mode">
259
+ {MODE_OPTIONS.map((opt) => {
260
+ const active = mode === opt.value;
261
+ return (
262
+ <label
263
+ key={opt.value}
264
+ style={active ? MODE_RADIO_ROW_ACTIVE_STYLE : MODE_RADIO_ROW_STYLE}
265
+ data-testid={`cs-paste-special-mode-${opt.value}`}
266
+ >
267
+ <input
268
+ type="radio"
269
+ name="cs-paste-special-mode"
270
+ value={opt.value}
271
+ checked={active}
272
+ onChange={() => setMode(opt.value)}
273
+ style={{ marginTop: 2 }}
274
+ />
275
+ <span>
276
+ <span style={MODE_LABEL_STYLE}>{opt.label}</span>
277
+ <span style={MODE_HINT_STYLE}>{opt.hint}</span>
278
+ </span>
279
+ </label>
280
+ );
281
+ })}
282
+ </div>
283
+ </div>
284
+ </Dialog>
285
+ );
286
+ }
@@ -40,6 +40,18 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNo
40
40
  import type { CasualSheetsAPI } from '../sheets/api';
41
41
  import type { ChromeExtensions, DialogComponentProps } from './extensions';
42
42
  import { FormatCellsDialog } from './FormatCellsDialog';
43
+ import { DataValidationDialog } from './DataValidationDialog';
44
+ import { ConditionalFormattingDialog } from './ConditionalFormattingDialog';
45
+ import { CustomSortDialog } from './CustomSortDialog';
46
+ import { PasteSpecialDialog } from './PasteSpecialDialog';
47
+ import { InsertFunctionDialog } from './InsertFunctionDialog';
48
+ import { NameManagerDialog } from './NameManagerDialog';
49
+ import { InsertCellsDialog } from './InsertCellsDialog';
50
+ import { DeleteCellsDialog } from './DeleteCellsDialog';
51
+ import { GoalSeekDialog } from './GoalSeekDialog';
52
+ import { InsertChartDialog } from './InsertChartDialog';
53
+ import { InsertSparklineDialog } from './InsertSparklineDialog';
54
+ import { InsertPivotDialog } from './InsertPivotDialog';
43
55
 
44
56
  /**
45
57
  * All dialog kinds the chrome knows about. Mirrors `MenuDialogKind` in MenuBar
@@ -76,6 +88,18 @@ export type DialogKind =
76
88
  */
77
89
  const BUILT_IN_DIALOGS: Partial<Record<DialogKind, React.ComponentType<DialogComponentProps>>> = {
78
90
  'format-cells': FormatCellsDialog,
91
+ 'data-validation': DataValidationDialog,
92
+ 'conditional-formatting': ConditionalFormattingDialog,
93
+ 'custom-sort': CustomSortDialog,
94
+ 'paste-special': PasteSpecialDialog,
95
+ 'insert-function': InsertFunctionDialog,
96
+ 'name-manager': NameManagerDialog,
97
+ 'insert-cells': InsertCellsDialog,
98
+ 'delete-cells': DeleteCellsDialog,
99
+ 'goal-seek': GoalSeekDialog,
100
+ 'insert-chart': InsertChartDialog,
101
+ 'insert-sparkline': InsertSparklineDialog,
102
+ 'insert-pivot': InsertPivotDialog,
79
103
  };
80
104
 
81
105
  /** Kinds the chrome can open without a host (built-in modal or self-managing). */
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * MenuBar data model + pure gating engine.
19
+ *
20
+ * Split out of `MenuBar.tsx` so the feature-gating contract is unit-testable
21
+ * under `node --test`: `MenuBar.tsx` statically imports `@univerjs/core` values
22
+ * (which the vendored typeless-package ESM can't expose as named exports to
23
+ * node), so it can't be imported in a DOM-less test. This module has only
24
+ * type-only imports (all erased at build), so `computeVisibleMenus` — the code
25
+ * that decides which menus/items a host actually sees — can be exercised
26
+ * directly.
27
+ *
28
+ * Feature gates: pass `features` to hide a control or whole menu group when its
29
+ * feature is disabled. Defaults to all-enabled. A control whose feature is
30
+ * `false` does not render. An entire top-level menu whose own `feature` is
31
+ * `false`, or that ends up with no runnable items, is dropped.
32
+ */
33
+
34
+ import type { DialogKind } from './dialog-context';
35
+ import type { CasualSheetsAPI } from '../sheets/api';
36
+ import type { MenuExtension } from './extensions';
37
+
38
+ export type MenuId = 'file' | 'edit' | 'view' | 'insert' | 'format' | 'data' | 'help';
39
+
40
+ /**
41
+ * Dialog kinds the host can choose to render via `onDialogRequest`. These are
42
+ * the actions the SDK chrome can't fulfil on its own (no built-in modal). The
43
+ * string is passed straight to the host hook; the `context` (when present)
44
+ * carries the pre-resolved A1 selection so the host doesn't have to re-read it.
45
+ */
46
+ export type MenuDialogKind = DialogKind;
47
+
48
+ export type RunFn = (api: CasualSheetsAPI) => void;
49
+
50
+ export type MenuItemDef =
51
+ | {
52
+ kind: 'item';
53
+ id: string;
54
+ label: string;
55
+ icon?: string;
56
+ shortcut?: string;
57
+ /** Dispatch a command / facade call directly. */
58
+ run?: RunFn;
59
+ /** Route through the host's `onDialogRequest`. Omitted if no host hook. */
60
+ dialog?: MenuDialogKind;
61
+ /** Feature gate — item hidden when `features[feature] === false`. */
62
+ feature?: string;
63
+ }
64
+ | { kind: 'separator'; id: string; feature?: string }
65
+ | {
66
+ kind: 'submenu';
67
+ id: string;
68
+ label: string;
69
+ icon?: string;
70
+ items: MenuItemDef[];
71
+ feature?: string;
72
+ };
73
+
74
+ export interface MenuDef {
75
+ id: MenuId;
76
+ label: string;
77
+ /** Feature gate for the whole menu. */
78
+ feature?: string;
79
+ items: MenuItemDef[];
80
+ }
81
+
82
+ /* ───────────────────────────── filtering ──────────────────────────────── */
83
+
84
+ /** True when the feature gate (if any) is enabled (default: enabled). */
85
+ export function featureOn(feature: string | undefined, features: Record<string, boolean>): boolean {
86
+ if (!feature) return true;
87
+ return features[feature] !== false;
88
+ }
89
+
90
+ /**
91
+ * Keep an item if its feature is on AND — for a dialog item — the chrome can
92
+ * open it (built-in dialog, host override, or `onDialogRequest`). Dialog items
93
+ * with no way to open are dropped (the SDK never fakes a dialog). Submenus are
94
+ * filtered recursively and dropped when empty.
95
+ */
96
+ export function keepItem(
97
+ item: MenuItemDef,
98
+ features: Record<string, boolean>,
99
+ canOpen: (kind: DialogKind) => boolean,
100
+ ): MenuItemDef | null {
101
+ if (!featureOn(item.feature, features)) return null;
102
+ if (item.kind === 'separator') return item;
103
+ if (item.kind === 'submenu') {
104
+ const items = filterItems(item.items, features, canOpen);
105
+ if (items.length === 0) return null;
106
+ return { ...item, items };
107
+ }
108
+ if (item.dialog && !canOpen(item.dialog)) return null;
109
+ return item;
110
+ }
111
+
112
+ /** Filter a list and collapse leading/trailing/double separators. */
113
+ export function filterItems(
114
+ items: MenuItemDef[],
115
+ features: Record<string, boolean>,
116
+ canOpen: (kind: DialogKind) => boolean,
117
+ ): MenuItemDef[] {
118
+ const kept = items
119
+ .map((i) => keepItem(i, features, canOpen))
120
+ .filter((i): i is MenuItemDef => i !== null);
121
+ // Collapse separators: drop leading, trailing, and runs.
122
+ const out: MenuItemDef[] = [];
123
+ for (const item of kept) {
124
+ if (item.kind === 'separator') {
125
+ if (out.length === 0) continue;
126
+ if (out[out.length - 1].kind === 'separator') continue;
127
+ }
128
+ out.push(item);
129
+ }
130
+ while (out.length > 0 && out[out.length - 1].kind === 'separator') out.pop();
131
+ return out;
132
+ }
133
+
134
+ /* ─────────────────────────── host extensions ──────────────────────────── */
135
+
136
+ /**
137
+ * Append host menu extensions to their target top-level menu. Each extension
138
+ * becomes a normal `item` (with a leading separator before the first host item
139
+ * in that menu so it's visually grouped). Host items dispatch via `onClick` or
140
+ * route a `dialog` kind through the dialog host, exactly like built-ins.
141
+ */
142
+ export function withMenuExtensions(menus: MenuDef[], ext?: MenuExtension[]): MenuDef[] {
143
+ if (!ext || ext.length === 0) return menus;
144
+ const byMenu = new Map<MenuId, MenuExtension[]>();
145
+ for (const e of ext) {
146
+ const list = byMenu.get(e.menu) ?? [];
147
+ list.push(e);
148
+ byMenu.set(e.menu, list);
149
+ }
150
+ return menus.map((menu) => {
151
+ const extras = byMenu.get(menu.id);
152
+ if (!extras || extras.length === 0) return menu;
153
+ const items: MenuItemDef[] = [...menu.items, { kind: 'separator', id: `ext-sep-${menu.id}` }];
154
+ for (const e of extras) {
155
+ items.push({
156
+ kind: 'item',
157
+ id: `ext-${e.id}`,
158
+ label: e.label,
159
+ icon: e.icon,
160
+ shortcut: e.shortcut,
161
+ dialog: e.dialog,
162
+ run: e.onClick ? (api) => e.onClick?.(api) : undefined,
163
+ });
164
+ }
165
+ return { ...menu, items };
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Resolve the menus a host actually sees: append menu extensions, filter every
171
+ * item by its `feature` gate + dialog-openability, then drop any top-level menu
172
+ * whose own `feature` is off or that ends up empty.
173
+ *
174
+ * Exported (not inlined in the component) so the feature-gating contract — e.g.
175
+ * `features={{ help: false }}` drops the Help menu and
176
+ * `features={{ branding: false }}` drops the "View on GitHub" / "About" links —
177
+ * is unit-testable without a DOM.
178
+ */
179
+ export function computeVisibleMenus(
180
+ menus: MenuDef[],
181
+ features: Record<string, boolean>,
182
+ canOpen: (kind: DialogKind) => boolean,
183
+ ext?: MenuExtension[],
184
+ ): MenuDef[] {
185
+ return withMenuExtensions(menus, ext)
186
+ .map((menu) => ({
187
+ ...menu,
188
+ items: filterItems(menu.items, features, canOpen),
189
+ }))
190
+ .filter((menu) => featureOn(menu.feature, features) && menu.items.length > 0);
191
+ }