@xcelsior/ui-spreadsheets 1.0.1
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/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +28 -0
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +9 -0
- package/biome.json +3 -0
- package/dist/index.d.mts +687 -0
- package/dist/index.d.ts +687 -0
- package/dist/index.js +3459 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3417 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/postcss.config.js +5 -0
- package/src/components/ColorPickerPopover.tsx +73 -0
- package/src/components/ColumnHeaderActions.tsx +139 -0
- package/src/components/CommentModals.tsx +137 -0
- package/src/components/KeyboardShortcutsModal.tsx +119 -0
- package/src/components/RowIndexColumnHeader.tsx +70 -0
- package/src/components/Spreadsheet.stories.tsx +1146 -0
- package/src/components/Spreadsheet.tsx +1005 -0
- package/src/components/SpreadsheetCell.tsx +341 -0
- package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
- package/src/components/SpreadsheetHeader.tsx +111 -0
- package/src/components/SpreadsheetSettingsModal.tsx +555 -0
- package/src/components/SpreadsheetToolbar.tsx +346 -0
- package/src/hooks/index.ts +40 -0
- package/src/hooks/useSpreadsheetComments.ts +132 -0
- package/src/hooks/useSpreadsheetFiltering.ts +379 -0
- package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
- package/src/hooks/useSpreadsheetPinning.ts +203 -0
- package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
- package/src/index.ts +31 -0
- package/src/types.ts +612 -0
- package/src/utils.ts +16 -0
- package/tsconfig.json +30 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface KeyboardShortcutHandler {
|
|
4
|
+
key: string;
|
|
5
|
+
modifiers?: {
|
|
6
|
+
meta?: boolean;
|
|
7
|
+
ctrl?: boolean;
|
|
8
|
+
shift?: boolean;
|
|
9
|
+
alt?: boolean;
|
|
10
|
+
};
|
|
11
|
+
handler: () => void;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UseSpreadsheetKeyboardShortcutsOptions {
|
|
16
|
+
/** Handlers for undo action */
|
|
17
|
+
onUndo?: () => void;
|
|
18
|
+
/** Handler for redo action */
|
|
19
|
+
onRedo?: () => void;
|
|
20
|
+
/** Handler for escape key - receives context of what to close */
|
|
21
|
+
onEscape?: () => void;
|
|
22
|
+
/** Whether the shortcuts modal is open */
|
|
23
|
+
isShortcutsModalOpen?: boolean;
|
|
24
|
+
/** Additional custom shortcuts */
|
|
25
|
+
customShortcuts?: KeyboardShortcutHandler[];
|
|
26
|
+
/** Whether shortcuts are enabled */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseSpreadsheetKeyboardShortcutsReturn {
|
|
31
|
+
// Shortcuts modal state
|
|
32
|
+
showKeyboardShortcuts: boolean;
|
|
33
|
+
setShowKeyboardShortcuts: (show: boolean) => void;
|
|
34
|
+
|
|
35
|
+
// OS detection for display
|
|
36
|
+
isMac: boolean;
|
|
37
|
+
modifierKey: string;
|
|
38
|
+
|
|
39
|
+
// Shortcut definitions for display
|
|
40
|
+
shortcuts: {
|
|
41
|
+
general: Array<{ label: string; keys: string[] }>;
|
|
42
|
+
rowSelection: Array<{ label: string; keys: string[] }>;
|
|
43
|
+
editing: Array<{ label: string; keys: string[] }>;
|
|
44
|
+
rowActions: Array<{ label: string; description: string }>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useSpreadsheetKeyboardShortcuts({
|
|
49
|
+
onUndo,
|
|
50
|
+
onRedo,
|
|
51
|
+
onEscape,
|
|
52
|
+
customShortcuts = [],
|
|
53
|
+
enabled = true,
|
|
54
|
+
}: UseSpreadsheetKeyboardShortcutsOptions = {}): UseSpreadsheetKeyboardShortcutsReturn {
|
|
55
|
+
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
|
56
|
+
|
|
57
|
+
// Detect OS for keyboard shortcuts display
|
|
58
|
+
const isMac =
|
|
59
|
+
typeof navigator !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform);
|
|
60
|
+
const modifierKey = isMac ? '⌘' : 'Ctrl';
|
|
61
|
+
|
|
62
|
+
// Keyboard event handler
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled) return;
|
|
65
|
+
|
|
66
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
67
|
+
// Escape key
|
|
68
|
+
if (event.key === 'Escape') {
|
|
69
|
+
if (showKeyboardShortcuts) {
|
|
70
|
+
setShowKeyboardShortcuts(false);
|
|
71
|
+
} else {
|
|
72
|
+
onEscape?.();
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Cmd/Ctrl + / : Toggle keyboard shortcuts modal
|
|
78
|
+
if ((event.metaKey || event.ctrlKey) && event.key === '/') {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
setShowKeyboardShortcuts((prev) => !prev);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Cmd/Ctrl+Z: Undo (without shift)
|
|
85
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'z' && !event.shiftKey) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
onUndo?.();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cmd/Ctrl+Shift+Z: Redo
|
|
92
|
+
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'z') {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
onRedo?.();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Custom shortcuts
|
|
99
|
+
for (const shortcut of customShortcuts) {
|
|
100
|
+
const modifiersMatch =
|
|
101
|
+
(!shortcut.modifiers?.meta || event.metaKey) &&
|
|
102
|
+
(!shortcut.modifiers?.ctrl || event.ctrlKey) &&
|
|
103
|
+
(!shortcut.modifiers?.shift || event.shiftKey) &&
|
|
104
|
+
(!shortcut.modifiers?.alt || event.altKey);
|
|
105
|
+
|
|
106
|
+
if (event.key === shortcut.key && modifiersMatch) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
shortcut.handler();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
115
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
116
|
+
}, [enabled, showKeyboardShortcuts, onUndo, onRedo, onEscape, customShortcuts]);
|
|
117
|
+
|
|
118
|
+
// Shortcut definitions for display in modal
|
|
119
|
+
const shortcuts = {
|
|
120
|
+
general: [
|
|
121
|
+
{ label: 'Show keyboard shortcuts', keys: [modifierKey, '/'] },
|
|
122
|
+
{ label: 'Close modal / Clear selection', keys: ['Escape'] },
|
|
123
|
+
],
|
|
124
|
+
rowSelection: [
|
|
125
|
+
{ label: 'Select single row', keys: ['Click row number'] },
|
|
126
|
+
{ label: 'Select multiple rows', keys: [modifierKey, 'Click row number'] },
|
|
127
|
+
{ label: 'Select range of rows', keys: ['Shift', 'Click row number'] },
|
|
128
|
+
],
|
|
129
|
+
editing: [
|
|
130
|
+
{ label: 'Undo', keys: [modifierKey, 'Z'] },
|
|
131
|
+
{ label: 'Redo', keys: [modifierKey, 'Shift', 'Z'] },
|
|
132
|
+
{ label: 'Confirm cell edit', keys: ['Enter'] },
|
|
133
|
+
{ label: 'Cancel cell edit', keys: ['Escape'] },
|
|
134
|
+
],
|
|
135
|
+
rowActions: [
|
|
136
|
+
{ label: 'Duplicate row', description: 'Click duplicate icon' },
|
|
137
|
+
{ label: 'Highlight row', description: 'Click highlight icon' },
|
|
138
|
+
{ label: 'Add row comment', description: 'Click comment icon' },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
showKeyboardShortcuts,
|
|
144
|
+
setShowKeyboardShortcuts,
|
|
145
|
+
isMac,
|
|
146
|
+
modifierKey,
|
|
147
|
+
shortcuts,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import type { SpreadsheetColumn, SpreadsheetColumnGroup } from '../types';
|
|
3
|
+
|
|
4
|
+
// Special column ID for row index
|
|
5
|
+
export const ROW_INDEX_COLUMN_ID = '__row_index__';
|
|
6
|
+
export const ROW_INDEX_COLUMN_WIDTH = 56;
|
|
7
|
+
|
|
8
|
+
export interface UseSpreadsheetPinningOptions<T> {
|
|
9
|
+
columns: SpreadsheetColumn<T>[];
|
|
10
|
+
columnGroups?: SpreadsheetColumnGroup[];
|
|
11
|
+
showRowIndex?: boolean;
|
|
12
|
+
defaultPinnedColumns?: string[];
|
|
13
|
+
defaultPinRowIndex?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseSpreadsheetPinningReturn<T> {
|
|
17
|
+
// Pinned state
|
|
18
|
+
pinnedColumns: Map<string, 'left' | 'right'>;
|
|
19
|
+
isRowIndexPinned: boolean;
|
|
20
|
+
|
|
21
|
+
// Collapsed groups
|
|
22
|
+
collapsedGroups: Set<string>;
|
|
23
|
+
|
|
24
|
+
// Visible columns (filtered by groups and reordered by pinning)
|
|
25
|
+
visibleColumns: SpreadsheetColumn<T>[];
|
|
26
|
+
|
|
27
|
+
// Actions
|
|
28
|
+
handleTogglePin: (columnId: string) => void;
|
|
29
|
+
handleToggleRowIndexPin: () => void;
|
|
30
|
+
handleToggleGroupCollapse: (groupId: string) => void;
|
|
31
|
+
|
|
32
|
+
// Offset calculations
|
|
33
|
+
getColumnLeftOffset: (columnId: string) => number;
|
|
34
|
+
getRowIndexLeftOffset: () => number;
|
|
35
|
+
|
|
36
|
+
// Utility
|
|
37
|
+
isColumnPinned: (columnId: string) => boolean;
|
|
38
|
+
getColumnPinSide: (columnId: string) => 'left' | 'right' | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useSpreadsheetPinning<T>({
|
|
42
|
+
columns,
|
|
43
|
+
columnGroups,
|
|
44
|
+
showRowIndex = true,
|
|
45
|
+
defaultPinnedColumns = [],
|
|
46
|
+
defaultPinRowIndex = false,
|
|
47
|
+
}: UseSpreadsheetPinningOptions<T>): UseSpreadsheetPinningReturn<T> {
|
|
48
|
+
// Initialize pinned columns from defaults
|
|
49
|
+
const [pinnedColumns, setPinnedColumns] = useState<Map<string, 'left' | 'right'>>(() => {
|
|
50
|
+
const map = new Map<string, 'left' | 'right'>();
|
|
51
|
+
defaultPinnedColumns.forEach((col) => {
|
|
52
|
+
map.set(col, 'left');
|
|
53
|
+
});
|
|
54
|
+
return map;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const [isRowIndexPinned, setIsRowIndexPinned] = useState(defaultPinRowIndex);
|
|
58
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
59
|
+
|
|
60
|
+
// Toggle column pin
|
|
61
|
+
const handleTogglePin = useCallback((columnId: string) => {
|
|
62
|
+
setPinnedColumns((prev) => {
|
|
63
|
+
const newMap = new Map(prev);
|
|
64
|
+
if (newMap.has(columnId)) {
|
|
65
|
+
newMap.delete(columnId);
|
|
66
|
+
} else {
|
|
67
|
+
newMap.set(columnId, 'left');
|
|
68
|
+
}
|
|
69
|
+
return newMap;
|
|
70
|
+
});
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
// Toggle row index pin
|
|
74
|
+
const handleToggleRowIndexPin = useCallback(() => {
|
|
75
|
+
setIsRowIndexPinned((prev) => !prev);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// Toggle group collapse
|
|
79
|
+
const handleToggleGroupCollapse = useCallback((groupId: string) => {
|
|
80
|
+
setCollapsedGroups((prev) => {
|
|
81
|
+
const newSet = new Set(prev);
|
|
82
|
+
if (newSet.has(groupId)) {
|
|
83
|
+
newSet.delete(groupId);
|
|
84
|
+
} else {
|
|
85
|
+
newSet.add(groupId);
|
|
86
|
+
}
|
|
87
|
+
return newSet;
|
|
88
|
+
});
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Calculate visible columns based on groups, collapse state, and pinning order
|
|
92
|
+
const visibleColumns = useMemo(() => {
|
|
93
|
+
if (!columns || !Array.isArray(columns) || columns.length === 0) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let result: SpreadsheetColumn<T>[] = [...columns];
|
|
98
|
+
|
|
99
|
+
// Filter based on column groups and collapse state
|
|
100
|
+
if (columnGroups && Array.isArray(columnGroups)) {
|
|
101
|
+
result = result.filter((column) => {
|
|
102
|
+
const group = columnGroups.find((g) => g.columns.includes(column.id));
|
|
103
|
+
if (!group) return true;
|
|
104
|
+
if (!collapsedGroups.has(group.id)) return true;
|
|
105
|
+
// When collapsed, only show pinned columns
|
|
106
|
+
return pinnedColumns.has(column.id);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If no columns are pinned, return result as-is to preserve original order
|
|
111
|
+
if (pinnedColumns.size === 0) {
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reorder columns: left-pinned first, unpinned in middle, right-pinned last
|
|
116
|
+
const leftPinned: SpreadsheetColumn<T>[] = [];
|
|
117
|
+
const unpinned: SpreadsheetColumn<T>[] = [];
|
|
118
|
+
const rightPinned: SpreadsheetColumn<T>[] = [];
|
|
119
|
+
|
|
120
|
+
// Maintain the order of pinned columns as they were added
|
|
121
|
+
const pinnedLeftIds = Array.from(pinnedColumns.entries())
|
|
122
|
+
.filter(([, side]) => side === 'left')
|
|
123
|
+
.map(([id]) => id);
|
|
124
|
+
const pinnedRightIds = Array.from(pinnedColumns.entries())
|
|
125
|
+
.filter(([, side]) => side === 'right')
|
|
126
|
+
.map(([id]) => id);
|
|
127
|
+
|
|
128
|
+
for (const column of result) {
|
|
129
|
+
const pinSide = pinnedColumns.get(column.id);
|
|
130
|
+
if (pinSide === 'left') {
|
|
131
|
+
leftPinned.push(column);
|
|
132
|
+
} else if (pinSide === 'right') {
|
|
133
|
+
rightPinned.push(column);
|
|
134
|
+
} else {
|
|
135
|
+
unpinned.push(column);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Sort left and right pinned columns based on their order in the pinnedColumns map
|
|
140
|
+
leftPinned.sort((a, b) => pinnedLeftIds.indexOf(a.id) - pinnedLeftIds.indexOf(b.id));
|
|
141
|
+
rightPinned.sort((a, b) => pinnedRightIds.indexOf(a.id) - pinnedRightIds.indexOf(b.id));
|
|
142
|
+
|
|
143
|
+
return [...leftPinned, ...unpinned, ...rightPinned];
|
|
144
|
+
}, [columns, columnGroups, collapsedGroups, pinnedColumns]);
|
|
145
|
+
|
|
146
|
+
// Get left offset for row index column
|
|
147
|
+
const getRowIndexLeftOffset = useCallback((): number => {
|
|
148
|
+
return 0;
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
// Calculate column offset for sticky positioning
|
|
152
|
+
const getColumnLeftOffset = useCallback(
|
|
153
|
+
(columnId: string): number => {
|
|
154
|
+
const pinnedLeft = Array.from(pinnedColumns.entries())
|
|
155
|
+
.filter(([, side]) => side === 'left')
|
|
156
|
+
.map(([id]) => id);
|
|
157
|
+
const index = pinnedLeft.indexOf(columnId);
|
|
158
|
+
|
|
159
|
+
// Base offset includes the row index column if shown and pinned
|
|
160
|
+
const baseOffset = showRowIndex && isRowIndexPinned ? ROW_INDEX_COLUMN_WIDTH : 0;
|
|
161
|
+
|
|
162
|
+
if (index === -1) return baseOffset;
|
|
163
|
+
|
|
164
|
+
let offset = baseOffset;
|
|
165
|
+
for (let i = 0; i < index; i++) {
|
|
166
|
+
const col = columns.find((c) => c.id === pinnedLeft[i]);
|
|
167
|
+
offset += col?.width || col?.minWidth || 100;
|
|
168
|
+
}
|
|
169
|
+
return offset;
|
|
170
|
+
},
|
|
171
|
+
[pinnedColumns, columns, showRowIndex, isRowIndexPinned]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Check if column is pinned
|
|
175
|
+
const isColumnPinned = useCallback(
|
|
176
|
+
(columnId: string): boolean => {
|
|
177
|
+
return pinnedColumns.has(columnId);
|
|
178
|
+
},
|
|
179
|
+
[pinnedColumns]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Get column pin side
|
|
183
|
+
const getColumnPinSide = useCallback(
|
|
184
|
+
(columnId: string): 'left' | 'right' | undefined => {
|
|
185
|
+
return pinnedColumns.get(columnId);
|
|
186
|
+
},
|
|
187
|
+
[pinnedColumns]
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
pinnedColumns,
|
|
192
|
+
isRowIndexPinned,
|
|
193
|
+
collapsedGroups,
|
|
194
|
+
visibleColumns,
|
|
195
|
+
handleTogglePin,
|
|
196
|
+
handleToggleRowIndexPin,
|
|
197
|
+
handleToggleGroupCollapse,
|
|
198
|
+
getColumnLeftOffset,
|
|
199
|
+
getRowIndexLeftOffset,
|
|
200
|
+
isColumnPinned,
|
|
201
|
+
getColumnPinSide,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface UseSpreadsheetUndoRedoOptions {
|
|
4
|
+
/** Whether undo/redo is enabled */
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
/** Maximum stack size (default: 50) */
|
|
7
|
+
maxStackSize?: number;
|
|
8
|
+
/** Auto-save mode */
|
|
9
|
+
autoSave?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseSpreadsheetUndoRedoReturn<TSnapshot> {
|
|
13
|
+
// State
|
|
14
|
+
undoStack: TSnapshot[];
|
|
15
|
+
redoStack: TSnapshot[];
|
|
16
|
+
hasUnsavedChanges: boolean;
|
|
17
|
+
saveStatus: 'saved' | 'saving' | 'unsaved' | 'error';
|
|
18
|
+
|
|
19
|
+
// Computed
|
|
20
|
+
canUndo: boolean;
|
|
21
|
+
canRedo: boolean;
|
|
22
|
+
undoCount: number;
|
|
23
|
+
redoCount: number;
|
|
24
|
+
|
|
25
|
+
// Actions
|
|
26
|
+
handleUndo: () => TSnapshot | null;
|
|
27
|
+
handleRedo: () => TSnapshot | null;
|
|
28
|
+
handleSave: () => void;
|
|
29
|
+
pushToUndoStack: (snapshot: TSnapshot) => void;
|
|
30
|
+
clearStacks: () => void;
|
|
31
|
+
|
|
32
|
+
// For tracking changes
|
|
33
|
+
markAsChanged: () => void;
|
|
34
|
+
markAsSaved: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useSpreadsheetUndoRedo<TSnapshot>({
|
|
38
|
+
enabled = true,
|
|
39
|
+
maxStackSize = 50,
|
|
40
|
+
autoSave = true,
|
|
41
|
+
}: UseSpreadsheetUndoRedoOptions): UseSpreadsheetUndoRedoReturn<TSnapshot> {
|
|
42
|
+
const [undoStack, setUndoStack] = useState<TSnapshot[]>([]);
|
|
43
|
+
const [redoStack, setRedoStack] = useState<TSnapshot[]>([]);
|
|
44
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
45
|
+
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved' | 'error'>('saved');
|
|
46
|
+
|
|
47
|
+
// Push current state to undo stack before making changes
|
|
48
|
+
const pushToUndoStack = useCallback(
|
|
49
|
+
(snapshot: TSnapshot) => {
|
|
50
|
+
if (!enabled) return;
|
|
51
|
+
|
|
52
|
+
setUndoStack((prev) => {
|
|
53
|
+
const newStack = [...prev, snapshot];
|
|
54
|
+
// Limit stack size
|
|
55
|
+
if (newStack.length > maxStackSize) {
|
|
56
|
+
return newStack.slice(-maxStackSize);
|
|
57
|
+
}
|
|
58
|
+
return newStack;
|
|
59
|
+
});
|
|
60
|
+
// Clear redo stack when new action is performed
|
|
61
|
+
setRedoStack([]);
|
|
62
|
+
},
|
|
63
|
+
[enabled, maxStackSize]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Undo action - returns the state to restore
|
|
67
|
+
const handleUndo = useCallback((): TSnapshot | null => {
|
|
68
|
+
if (!enabled || undoStack.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
const previousSnapshot = undoStack[undoStack.length - 1];
|
|
71
|
+
|
|
72
|
+
// Pop from undo stack
|
|
73
|
+
setUndoStack((prev) => prev.slice(0, -1));
|
|
74
|
+
|
|
75
|
+
// Move snapshot to redo stack
|
|
76
|
+
setRedoStack((prev) => {
|
|
77
|
+
const newStack = [...prev, previousSnapshot];
|
|
78
|
+
if (newStack.length > maxStackSize) {
|
|
79
|
+
return newStack.slice(-maxStackSize);
|
|
80
|
+
}
|
|
81
|
+
return newStack;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return previousSnapshot;
|
|
85
|
+
}, [enabled, undoStack, maxStackSize]);
|
|
86
|
+
|
|
87
|
+
// Redo action - returns the state to restore
|
|
88
|
+
const handleRedo = useCallback((): TSnapshot | null => {
|
|
89
|
+
if (!enabled || redoStack.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
const nextSnapshot = redoStack[redoStack.length - 1];
|
|
92
|
+
|
|
93
|
+
// Pop from redo stack
|
|
94
|
+
setRedoStack((prev) => prev.slice(0, -1));
|
|
95
|
+
|
|
96
|
+
// Move snapshot back to undo stack
|
|
97
|
+
setUndoStack((prev) => {
|
|
98
|
+
const newStack = [...prev, nextSnapshot];
|
|
99
|
+
if (newStack.length > maxStackSize) {
|
|
100
|
+
return newStack.slice(-maxStackSize);
|
|
101
|
+
}
|
|
102
|
+
return newStack;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return nextSnapshot;
|
|
106
|
+
}, [enabled, redoStack, maxStackSize]);
|
|
107
|
+
|
|
108
|
+
// Save action
|
|
109
|
+
const handleSave = useCallback(() => {
|
|
110
|
+
if (!hasUnsavedChanges) return;
|
|
111
|
+
|
|
112
|
+
setSaveStatus('saving');
|
|
113
|
+
// Simulate save delay
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
setSaveStatus('saved');
|
|
116
|
+
setHasUnsavedChanges(false);
|
|
117
|
+
}, 500);
|
|
118
|
+
}, [hasUnsavedChanges]);
|
|
119
|
+
|
|
120
|
+
// Mark as changed (call after data modification)
|
|
121
|
+
const markAsChanged = useCallback(() => {
|
|
122
|
+
setHasUnsavedChanges(true);
|
|
123
|
+
if (autoSave) {
|
|
124
|
+
setSaveStatus('saving');
|
|
125
|
+
setTimeout(() => setSaveStatus('saved'), 500);
|
|
126
|
+
} else {
|
|
127
|
+
setSaveStatus('unsaved');
|
|
128
|
+
}
|
|
129
|
+
}, [autoSave]);
|
|
130
|
+
|
|
131
|
+
// Mark as saved
|
|
132
|
+
const markAsSaved = useCallback(() => {
|
|
133
|
+
setHasUnsavedChanges(false);
|
|
134
|
+
setSaveStatus('saved');
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
// Clear both stacks
|
|
138
|
+
const clearStacks = useCallback(() => {
|
|
139
|
+
setUndoStack([]);
|
|
140
|
+
setRedoStack([]);
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
// State
|
|
145
|
+
undoStack,
|
|
146
|
+
redoStack,
|
|
147
|
+
hasUnsavedChanges,
|
|
148
|
+
saveStatus,
|
|
149
|
+
|
|
150
|
+
// Computed
|
|
151
|
+
canUndo: enabled && undoStack.length > 0,
|
|
152
|
+
canRedo: enabled && redoStack.length > 0,
|
|
153
|
+
undoCount: undoStack.length,
|
|
154
|
+
redoCount: redoStack.length,
|
|
155
|
+
|
|
156
|
+
// Actions
|
|
157
|
+
handleUndo,
|
|
158
|
+
handleRedo,
|
|
159
|
+
handleSave,
|
|
160
|
+
pushToUndoStack,
|
|
161
|
+
clearStacks,
|
|
162
|
+
|
|
163
|
+
// Change tracking
|
|
164
|
+
markAsChanged,
|
|
165
|
+
markAsSaved,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Main Spreadsheet component
|
|
2
|
+
export { Spreadsheet } from './components/Spreadsheet';
|
|
3
|
+
|
|
4
|
+
// Sub-components
|
|
5
|
+
export { SpreadsheetCell } from './components/SpreadsheetCell';
|
|
6
|
+
export { SpreadsheetHeader } from './components/SpreadsheetHeader';
|
|
7
|
+
export { SpreadsheetFilterDropdown } from './components/SpreadsheetFilterDropdown';
|
|
8
|
+
export { SpreadsheetToolbar } from './components/SpreadsheetToolbar';
|
|
9
|
+
export { SpreadsheetSettingsModal } from './components/SpreadsheetSettingsModal';
|
|
10
|
+
export type { SpreadsheetSettings } from './components/SpreadsheetSettingsModal';
|
|
11
|
+
|
|
12
|
+
// Types
|
|
13
|
+
export type {
|
|
14
|
+
SpreadsheetProps,
|
|
15
|
+
SpreadsheetColumn,
|
|
16
|
+
SpreadsheetColumnGroup,
|
|
17
|
+
SpreadsheetSortConfig,
|
|
18
|
+
SpreadsheetColumnFilter,
|
|
19
|
+
SpreadsheetState,
|
|
20
|
+
SpreadsheetCellProps,
|
|
21
|
+
SpreadsheetHeaderProps,
|
|
22
|
+
SpreadsheetFilterDropdownProps,
|
|
23
|
+
SpreadsheetToolbarProps,
|
|
24
|
+
SpreadsheetColumnGroupHeaderProps,
|
|
25
|
+
CellPosition,
|
|
26
|
+
CellHighlight,
|
|
27
|
+
CellComment,
|
|
28
|
+
SelectionState,
|
|
29
|
+
PaginationState,
|
|
30
|
+
RowAction,
|
|
31
|
+
} from './types';
|