@bagelink/vue 1.10.1 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/components/Spreadsheet/Index.vue.d.ts +3 -22
  2. package/dist/components/Spreadsheet/Index.vue.d.ts.map +1 -1
  3. package/dist/components/Spreadsheet/SpreadsheetCell.vue.d.ts +23 -0
  4. package/dist/components/Spreadsheet/SpreadsheetCell.vue.d.ts.map +1 -0
  5. package/dist/components/Spreadsheet/SpreadsheetTable.vue.d.ts +2 -15
  6. package/dist/components/Spreadsheet/SpreadsheetTable.vue.d.ts.map +1 -1
  7. package/dist/components/Spreadsheet/composables/useSpreadsheetClipboard.d.ts +7 -0
  8. package/dist/components/Spreadsheet/composables/useSpreadsheetClipboard.d.ts.map +1 -0
  9. package/dist/components/Spreadsheet/composables/useSpreadsheetColumns.d.ts +14 -0
  10. package/dist/components/Spreadsheet/composables/useSpreadsheetColumns.d.ts.map +1 -0
  11. package/dist/components/Spreadsheet/composables/useSpreadsheetSelection.d.ts +24 -0
  12. package/dist/components/Spreadsheet/composables/useSpreadsheetSelection.d.ts.map +1 -0
  13. package/dist/components/Spreadsheet/composables/useSpreadsheetSort.d.ts +8 -0
  14. package/dist/components/Spreadsheet/composables/useSpreadsheetSort.d.ts.map +1 -0
  15. package/dist/components/Spreadsheet/composables/useSpreadsheetUndo.d.ts +72 -0
  16. package/dist/components/Spreadsheet/composables/useSpreadsheetUndo.d.ts.map +1 -0
  17. package/dist/components/Spreadsheet/types.d.ts +36 -0
  18. package/dist/components/Spreadsheet/types.d.ts.map +1 -0
  19. package/dist/components/Spreadsheet/utils.d.ts +6 -0
  20. package/dist/components/Spreadsheet/utils.d.ts.map +1 -0
  21. package/dist/index.cjs +90 -90
  22. package/dist/index.mjs +10165 -10102
  23. package/dist/style.css +1 -1
  24. package/package.json +2 -2
  25. package/src/components/Spreadsheet/Index.vue +127 -619
  26. package/src/components/Spreadsheet/SpreadsheetCell.vue +56 -0
  27. package/src/components/Spreadsheet/SpreadsheetTable.vue +79 -77
  28. package/src/components/Spreadsheet/composables/useSpreadsheetClipboard.ts +71 -0
  29. package/src/components/Spreadsheet/composables/useSpreadsheetColumns.ts +110 -0
  30. package/src/components/Spreadsheet/composables/useSpreadsheetSelection.ts +43 -0
  31. package/src/components/Spreadsheet/composables/useSpreadsheetSort.ts +42 -0
  32. package/src/components/Spreadsheet/composables/useSpreadsheetUndo.ts +55 -0
  33. package/src/components/Spreadsheet/types.ts +35 -0
  34. package/src/components/Spreadsheet/utils.ts +69 -0
@@ -0,0 +1,56 @@
1
+ <script lang="ts" setup>
2
+ import type { ColumnConfig } from './types'
3
+ import { CheckInput } from '@bagelink/vue'
4
+ import { formatCellValue } from './utils'
5
+
6
+ interface Props {
7
+ col: ColumnConfig
8
+ row: { [key: string]: any }
9
+ rowIndex: number
10
+ globalColIndex: number
11
+ editing: boolean
12
+ editable: boolean
13
+ wrapText?: boolean
14
+ }
15
+
16
+ const props = defineProps<Props>()
17
+
18
+ defineEmits<{
19
+ (e: 'updateCell', rowIndex: number, key: string, value: string | boolean): void
20
+ (e: 'stopEditing', cancelled: boolean): void
21
+ (e: 'setInputRef', el: any, key: string): void
22
+ }>()
23
+ </script>
24
+
25
+ <template>
26
+ <template v-if="editing">
27
+ <input
28
+ :ref="el => $emit('setInputRef', el, `cell-${rowIndex}-${globalColIndex}`)"
29
+ :value="row[col.key]"
30
+ type="text"
31
+ class="spreadsheet-input"
32
+ @input="(e) => $emit('updateCell', rowIndex, col.key, (e.target as HTMLInputElement).value)"
33
+ @blur="$emit('stopEditing', false)"
34
+ @keydown.enter.prevent="$emit('stopEditing', false)"
35
+ @keydown.esc.prevent="$emit('stopEditing', true)"
36
+ @mousedown.stop
37
+ >
38
+ <span class="spreadsheet-cell spreadsheetCellPlaceHolder">{{ formatCellValue(row[col.key], col.format) }}</span>
39
+ </template>
40
+ <template v-else-if="col.format === 'image'">
41
+ <div class="h40px w-100p flex align-items-center justify-content-center overflow-hidden">
42
+ <img class="w-100p h-100p contain radius-05" :src="row[col.key]" :alt="col.label || col.key">
43
+ </div>
44
+ </template>
45
+ <template v-else-if="col.format === 'boolean'">
46
+ <CheckInput
47
+ :modelValue="!!row[col.key]"
48
+ :disabled="!editable"
49
+ @update:modelValue="(value) => $emit('updateCell', rowIndex, col.key, !!value)"
50
+ @mousedown.stop
51
+ />
52
+ </template>
53
+ <template v-else>
54
+ <span class="spreadsheet-cell">{{ formatCellValue(row[col.key], col.format) }}</span>
55
+ </template>
56
+ </template>
@@ -1,23 +1,7 @@
1
1
  <script lang="ts" setup>
2
- import { Icon, CheckInput } from '@bagelink/vue'
3
-
4
- // Define column configuration types from parent
5
- interface CellPosition {
6
- row: number
7
- col: number
8
- }
9
-
10
- interface ColumnConfig {
11
- key: string
12
- label?: string
13
- locked?: boolean
14
- format?: 'text' | 'number' | 'currency' | 'date' | 'percentage' | 'image' | 'boolean'
15
- sortable?: boolean
16
- width?: string
17
- fixed?: boolean
18
- hidden?: boolean
19
- defaultValue?: any
20
- }
2
+ import type { CellPosition, ColumnConfig } from './types'
3
+ import { Icon } from '@bagelink/vue'
4
+ import SpreadsheetCell from './SpreadsheetCell.vue'
21
5
 
22
6
  interface Props {
23
7
  columns: ColumnConfig[]
@@ -32,6 +16,7 @@ interface Props {
32
16
  editingCell: CellPosition | null
33
17
  baseColumnIndex: number
34
18
  wrapText?: boolean
19
+ emptyMessage?: string
35
20
  }
36
21
 
37
22
  const props = defineProps<Props>()
@@ -78,31 +63,31 @@ function isEditing(row: number, localColIndex: number): boolean {
78
63
  return props.editingCell.row === row && props.editingCell.col === colIndex
79
64
  }
80
65
 
81
- // Format cell value based on column configuration
82
- function formatCellValue(value: any, format?: ColumnConfig['format']): string {
83
- if (value === null || value === undefined) { return '' }
66
+ // Returns a stable key representing this row's selection state.
67
+ // Used by v-memo so rows outside the selection don't re-render on selection changes.
68
+ function rowSelectionKey(rowIndex: number): string {
69
+ if (!props.selectionStart || !props.selectionEnd) { return 'none' }
70
+ const startRow = Math.min(props.selectionStart.row, props.selectionEnd.row)
71
+ const endRow = Math.max(props.selectionStart.row, props.selectionEnd.row)
72
+ if (rowIndex < startRow || rowIndex > endRow) { return 'none' }
73
+ const startCol = Math.min(props.selectionStart.col, props.selectionEnd.col)
74
+ const endCol = Math.max(props.selectionStart.col, props.selectionEnd.col)
75
+ return `${startCol}-${endCol}`
76
+ }
84
77
 
85
- switch (format) {
86
- case 'image':
87
- return String(value) // Return raw value for images
88
- case 'boolean':
89
- return '' // Return empty string since we're using CheckInput component
90
- case 'number':
91
- return Number(value).toLocaleString()
92
- case 'currency':
93
- return Number(value).toLocaleString(undefined, {
94
- style: 'currency',
95
- currency: 'USD'
96
- })
97
- case 'date':
98
- return value ? new Date(value).toLocaleDateString() : ''
99
- case 'percentage':
100
- return Number(value).toLocaleString(undefined, {
101
- style: 'percent',
102
- minimumFractionDigits: 2
103
- })
104
- default:
105
- return String(value)
78
+ function selectionEdgeClasses(rowIndex: number, localColIndex: number): Record<string, boolean> {
79
+ if (!props.selectionStart || !props.selectionEnd) { return {} }
80
+ const startRow = Math.min(props.selectionStart.row, props.selectionEnd.row)
81
+ const endRow = Math.max(props.selectionStart.row, props.selectionEnd.row)
82
+ const startCol = Math.min(props.selectionStart.col, props.selectionEnd.col)
83
+ const endCol = Math.max(props.selectionStart.col, props.selectionEnd.col)
84
+ const colIndex = getActualColumnIndex(localColIndex)
85
+ if (rowIndex < startRow || rowIndex > endRow || colIndex < startCol || colIndex > endCol) { return {} }
86
+ return {
87
+ 'sel-top': rowIndex === startRow,
88
+ 'sel-bottom': rowIndex === endRow,
89
+ 'sel-left': colIndex === startCol,
90
+ 'sel-right': colIndex === endCol,
106
91
  }
107
92
  }
108
93
  </script>
@@ -136,7 +121,11 @@ function formatCellValue(value: any, format?: ColumnConfig['format']): string {
136
121
  </tr>
137
122
  </thead>
138
123
  <tbody>
139
- <tr v-for="(row, rowIndex) in rows" :key="rowIndex">
124
+ <tr
125
+ v-for="(row, rowIndex) in rows"
126
+ :key="rowIndex"
127
+ v-memo="[rowSelectionKey(rowIndex), editingCell?.row === rowIndex, row, wrapText]"
128
+ >
140
129
  <td
141
130
  v-if="showRowNumbers"
142
131
  class="row-number txt-center hover user-select-none pointer txt12 regular"
@@ -151,6 +140,7 @@ function formatCellValue(value: any, format?: ColumnConfig['format']): string {
151
140
  'selected': isCellSelected(rowIndex, colIndex),
152
141
  'locked': !isCellEditable(col.key),
153
142
  'wrap-text': wrapText,
143
+ ...selectionEdgeClasses(rowIndex, colIndex),
154
144
  }"
155
145
  :style="{ width: columnWidths.get(col.key) ? `${columnWidths.get(col.key)}px` : col.width }"
156
146
  :tabindex="col.hidden ? undefined : 0"
@@ -160,38 +150,26 @@ function formatCellValue(value: any, format?: ColumnConfig['format']): string {
160
150
  @dblclick="$emit('cellEditStart', rowIndex, getActualColumnIndex(colIndex))"
161
151
  @keydown="$emit('cellKeyDown', $event, rowIndex, getActualColumnIndex(colIndex))"
162
152
  >
163
- <template v-if="isEditing(rowIndex, colIndex)">
164
- <input
165
- :ref="el => $emit('setInputRef', el, `cell-${rowIndex}-${getActualColumnIndex(colIndex)}`)"
166
- :value="row[col.key]"
167
- type="text"
168
- class="spreadsheet-input"
169
- @input="(e) => $emit('updateCell', rowIndex, col.key, (e.target as HTMLInputElement).value)"
170
- @blur="$emit('stopEditing', false)"
171
- @keydown.enter.prevent="$emit('stopEditing', false)"
172
- @keydown.esc.prevent="$emit('stopEditing', true)"
173
- @mousedown.stop
174
- >
175
- <span class="spreadsheet-cell spreadsheetCellPlaceHolder">{{ formatCellValue(row[col.key], col.format) }}</span>
176
- </template>
177
- <template v-else>
178
- <template v-if="col.format === 'image'">
179
- <div class="h40px w-100p flex align-items-center justify-content-center overflow-hidden">
180
- <img class="w-100p h-100p contain radius-05" :src="row[col.key]" :alt="col.label || col.key">
181
- </div>
182
- </template>
183
- <template v-else-if="col.format === 'boolean'">
184
- <CheckInput
185
- :modelValue="!!row[col.key]"
186
- :disabled="!isCellEditable(col.key)"
187
- @update:modelValue="(value) => $emit('updateCell', rowIndex, col.key, !!value)"
188
- @mousedown.stop
189
- />
190
- </template>
191
- <template v-else>
192
- <span class="spreadsheet-cell">{{ formatCellValue(row[col.key], col.format) }}</span>
193
- </template>
194
- </template>
153
+ <SpreadsheetCell
154
+ :col="col"
155
+ :row="row"
156
+ :row-index="rowIndex"
157
+ :global-col-index="getActualColumnIndex(colIndex)"
158
+ :editing="isEditing(rowIndex, colIndex)"
159
+ :editable="isCellEditable(col.key)"
160
+ :wrap-text="wrapText"
161
+ @updateCell="(ri, key, val) => $emit('updateCell', ri, key, val)"
162
+ @stopEditing="(cancelled) => $emit('stopEditing', cancelled)"
163
+ @setInputRef="(el, key) => $emit('setInputRef', el, key)"
164
+ />
165
+ </td>
166
+ </tr>
167
+ <tr v-if="!isFixed && rows.length === 0">
168
+ <td
169
+ :colspan="columns.length + (showRowNumbers ? 1 : 0)"
170
+ class="empty-state"
171
+ >
172
+ {{ emptyMessage ?? 'No results' }}
195
173
  </td>
196
174
  </tr>
197
175
  </tbody>
@@ -202,7 +180,8 @@ function formatCellValue(value: any, format?: ColumnConfig['format']): string {
202
180
  .row-number{
203
181
  position: sticky;
204
182
  inset-inline-start: 0;
205
- background: var(--bgl-gray-80);
183
+ background: var(--input-bg);
184
+ color: var(--txt-muted, #888);
206
185
  z-index: 1;
207
186
  }
208
187
  .row-number:before{
@@ -256,6 +235,20 @@ th .bgl_icon-font{
256
235
  td.selected {
257
236
  background: var(--bgl-primary-light);
258
237
  }
238
+ td.sel-top { box-shadow: inset 0 2px 0 var(--bgl-primary); }
239
+ td.sel-bottom { box-shadow: inset 0 -2px 0 var(--bgl-primary); }
240
+ td.sel-left { box-shadow: inset 2px 0 0 var(--bgl-primary); }
241
+ td.sel-right { box-shadow: inset -2px 0 0 var(--bgl-primary); }
242
+ td.sel-top.sel-bottom { box-shadow: inset 0 2px 0 var(--bgl-primary), inset 0 -2px 0 var(--bgl-primary); }
243
+ td.sel-top.sel-left { box-shadow: inset 2px 2px 0 var(--bgl-primary); }
244
+ td.sel-top.sel-right { box-shadow: inset -2px 2px 0 var(--bgl-primary); }
245
+ td.sel-bottom.sel-left { box-shadow: inset 2px -2px 0 var(--bgl-primary); }
246
+ td.sel-bottom.sel-right { box-shadow: inset -2px -2px 0 var(--bgl-primary); }
247
+ td.sel-top.sel-left.sel-right { box-shadow: inset 2px 2px 0 var(--bgl-primary), inset -2px 2px 0 var(--bgl-primary); }
248
+ td.sel-bottom.sel-left.sel-right { box-shadow: inset 2px -2px 0 var(--bgl-primary), inset -2px -2px 0 var(--bgl-primary); }
249
+ td.sel-top.sel-bottom.sel-left { box-shadow: inset 2px 2px 0 var(--bgl-primary), inset 2px -2px 0 var(--bgl-primary); }
250
+ td.sel-top.sel-bottom.sel-right { box-shadow: inset -2px 2px 0 var(--bgl-primary), inset -2px -2px 0 var(--bgl-primary); }
251
+ td.sel-top.sel-bottom.sel-left.sel-right { box-shadow: inset 2px 2px 0 var(--bgl-primary), inset -2px -2px 0 var(--bgl-primary), inset -2px 2px 0 var(--bgl-primary), inset 2px -2px 0 var(--bgl-primary); }
259
252
  td.locked {
260
253
  background: var(--bgl-gray-light);
261
254
  cursor: default;
@@ -355,4 +348,13 @@ td:has(:checked){
355
348
  .resize-handle:hover {
356
349
  background: var(--bgl-primary);
357
350
  }
351
+
352
+ .empty-state {
353
+ text-align: center;
354
+ padding: 2rem 1rem;
355
+ color: var(--txt-muted, #888);
356
+ font-size: 0.875rem;
357
+ background: var(--bgl-white);
358
+ border: 1px solid var(--border-color);
359
+ }
358
360
  </style>
@@ -0,0 +1,71 @@
1
+ import type { Ref, ComputedRef } from 'vue'
2
+ import type { CellPosition, ColumnConfig, Row, SpreadsheetChange } from '../types'
3
+ import { formatCellValue, parseValueForFormat } from '../utils'
4
+
5
+ export function useSpreadsheetClipboard(
6
+ localRows: Ref<Row[]>,
7
+ columns: ComputedRef<ColumnConfig[]>,
8
+ selectionStart: Ref<CellPosition | null>,
9
+ selectionEnd: Ref<CellPosition | null>,
10
+ saveState: (type: SpreadsheetChange['type']) => void,
11
+ isCellEditable: (key: string) => boolean,
12
+ createEmptyRow: () => Row,
13
+ emitUpdate: () => void,
14
+ ) {
15
+ async function copySelection() {
16
+ if (!selectionStart.value || !selectionEnd.value) { return }
17
+
18
+ const startRow = Math.min(selectionStart.value.row, selectionEnd.value.row)
19
+ const endRow = Math.max(selectionStart.value.row, selectionEnd.value.row)
20
+ const startCol = Math.min(selectionStart.value.col, selectionEnd.value.col)
21
+ const endCol = Math.max(selectionStart.value.col, selectionEnd.value.col)
22
+
23
+ const tsv = Array.from({ length: endRow - startRow + 1 }, (_, ri) => {
24
+ return Array.from({ length: endCol - startCol + 1 }, (_, ci) => {
25
+ const col = columns.value[startCol + ci]
26
+ return formatCellValue(localRows.value[startRow + ri][col.key], col.format)
27
+ }).join('\t')
28
+ }).join('\n')
29
+
30
+ try {
31
+ await navigator.clipboard.writeText(tsv)
32
+ } catch (err) {
33
+ console.error('Failed to copy:', err)
34
+ }
35
+ }
36
+
37
+ async function pasteSelection() {
38
+ if (!selectionStart.value) { return }
39
+
40
+ try {
41
+ const text = await navigator.clipboard.readText()
42
+ const pasteRows = text.split('\n').map(r => r.split('\t'))
43
+
44
+ saveState('paste')
45
+
46
+ const startRow = selectionStart.value.row
47
+ const startCol = selectionStart.value.col
48
+
49
+ const neededRows = startRow + pasteRows.length - localRows.value.length
50
+ for (let i = 0; i < neededRows; i++) {
51
+ localRows.value.push(createEmptyRow())
52
+ }
53
+
54
+ pasteRows.forEach((rowData, ri) => {
55
+ rowData.forEach((cellValue, ci) => {
56
+ const targetCol = startCol + ci
57
+ if (targetCol >= columns.value.length) { return }
58
+ const col = columns.value[targetCol]
59
+ if (!isCellEditable(col.key)) { return }
60
+ localRows.value[startRow + ri][col.key] = parseValueForFormat(cellValue, col.format)
61
+ })
62
+ })
63
+
64
+ emitUpdate()
65
+ } catch (err) {
66
+ console.error('Failed to paste:', err)
67
+ }
68
+ }
69
+
70
+ return { copySelection, pasteSelection }
71
+ }
@@ -0,0 +1,110 @@
1
+ import type { Ref } from 'vue'
2
+ import type { ColumnConfig, Row } from '../types'
3
+ import { computed, ref, watch, onUnmounted } from 'vue'
4
+
5
+ export function useSpreadsheetColumns(
6
+ localRows: Ref<Row[]>,
7
+ getColumnConfig: () => ColumnConfig[] | undefined,
8
+ ) {
9
+ const visibleColumns = ref<string[]>([])
10
+ const columnWidths = ref<Map<string, number>>(new Map())
11
+
12
+ const isResizing = ref(false)
13
+ const resizingColumn = ref<string | null>(null)
14
+ const startX = ref(0)
15
+ const startWidth = ref(0)
16
+
17
+ const columns = computed<ColumnConfig[]>(() => {
18
+ const dataKeys = localRows.value.length
19
+ ? new Set(localRows.value.flatMap(row => Object.keys(row)))
20
+ : new Set<string>()
21
+
22
+ const config = getColumnConfig()
23
+ const configMap = new Map((config || []).map(c => [c.key, c]))
24
+ const allKeys = new Set([...dataKeys, ...(config?.map(c => c.key) || [])])
25
+
26
+ return Array.from(allKeys).map((key) => {
27
+ const col = configMap.get(key)
28
+ return {
29
+ key,
30
+ label: col?.label ?? key,
31
+ locked: col?.locked ?? false,
32
+ sortable: col?.sortable ?? true,
33
+ format: col?.format ?? 'text',
34
+ width: col?.width,
35
+ fixed: col?.fixed ?? false,
36
+ hidden: col?.hidden ?? false,
37
+ defaultValue: col?.defaultValue,
38
+ }
39
+ })
40
+ })
41
+
42
+ const fixedColumns = computed(() => columns.value.filter(col => col.fixed && !col.hidden && visibleColumns.value.includes(col.key))
43
+ )
44
+
45
+ const scrollableColumns = computed(() => columns.value.filter(col => !col.fixed && !col.hidden && visibleColumns.value.includes(col.key))
46
+ )
47
+
48
+ const columnOptions = computed(() => columns.value.filter(col => !col.hidden))
49
+
50
+ watch(columns, (newColumns) => {
51
+ if (newColumns.length > 0 && visibleColumns.value.length === 0) {
52
+ visibleColumns.value = newColumns.filter(col => !col.hidden).map(col => col.key)
53
+ }
54
+ })
55
+
56
+ function isCellEditable(columnKey: string): boolean {
57
+ return !(columns.value.find(col => col.key === columnKey)?.locked ?? false)
58
+ }
59
+
60
+ function createEmptyRow(): Row {
61
+ const row: Row = {}
62
+ columns.value.forEach((col) => {
63
+ row[col.key] = col.defaultValue !== undefined
64
+ ? col.defaultValue
65
+ : col.format === 'boolean' ? false : ''
66
+ })
67
+ return row
68
+ }
69
+
70
+ function handleResizeMove(e: MouseEvent) {
71
+ if (!isResizing.value || !resizingColumn.value) { return }
72
+ e.preventDefault()
73
+ const newWidth = Math.max(80, startWidth.value + (e.pageX - startX.value))
74
+ columnWidths.value.set(resizingColumn.value, newWidth)
75
+ }
76
+
77
+ function handleResizeEnd() {
78
+ isResizing.value = false
79
+ resizingColumn.value = null
80
+ document.removeEventListener('mousemove', handleResizeMove)
81
+ document.removeEventListener('mouseup', handleResizeEnd)
82
+ }
83
+
84
+ function handleResizeStart(e: MouseEvent, columnKey: string) {
85
+ isResizing.value = true
86
+ resizingColumn.value = columnKey
87
+ startX.value = e.pageX
88
+ const th = (e.target as HTMLElement).closest('th')
89
+ startWidth.value = columnWidths.value.get(columnKey) ?? (th?.getBoundingClientRect().width ?? 0)
90
+ document.addEventListener('mousemove', handleResizeMove)
91
+ document.addEventListener('mouseup', handleResizeEnd)
92
+ }
93
+
94
+ onUnmounted(() => {
95
+ document.removeEventListener('mousemove', handleResizeMove)
96
+ document.removeEventListener('mouseup', handleResizeEnd)
97
+ })
98
+
99
+ return {
100
+ columns,
101
+ fixedColumns,
102
+ scrollableColumns,
103
+ columnOptions,
104
+ visibleColumns,
105
+ columnWidths,
106
+ isCellEditable,
107
+ createEmptyRow,
108
+ handleResizeStart,
109
+ }
110
+ }
@@ -0,0 +1,43 @@
1
+ import type { ComputedRef } from 'vue'
2
+ import type { CellPosition, ColumnConfig } from '../types'
3
+ import { ref, onUnmounted } from 'vue'
4
+
5
+ export function useSpreadsheetSelection(columns: ComputedRef<ColumnConfig[]>) {
6
+ const isSelecting = ref(false)
7
+ const selectionStart = ref<CellPosition | null>(null)
8
+ const selectionEnd = ref<CellPosition | null>(null)
9
+
10
+ function handleMouseDown(row: number, col: number) {
11
+ selectionStart.value = { row, col }
12
+ selectionEnd.value = { row, col }
13
+ isSelecting.value = true
14
+ }
15
+
16
+ function handleMouseOver(row: number, col: number) {
17
+ if (isSelecting.value) {
18
+ selectionEnd.value = { row, col }
19
+ }
20
+ }
21
+
22
+ function handleMouseUp() {
23
+ isSelecting.value = false
24
+ }
25
+
26
+ function selectEntireRow(rowIndex: number) {
27
+ selectionStart.value = { row: rowIndex, col: 0 }
28
+ selectionEnd.value = { row: rowIndex, col: columns.value.length - 1 }
29
+ }
30
+
31
+ window.addEventListener('mouseup', handleMouseUp)
32
+ onUnmounted(() => { window.removeEventListener('mouseup', handleMouseUp) })
33
+
34
+ return {
35
+ isSelecting,
36
+ selectionStart,
37
+ selectionEnd,
38
+ handleMouseDown,
39
+ handleMouseOver,
40
+ handleMouseUp,
41
+ selectEntireRow,
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ import type { Ref, ComputedRef } from 'vue'
2
+ import type { ColumnConfig, Row } from '../types'
3
+ import { ref, nextTick } from 'vue'
4
+
5
+ export function useSpreadsheetSort(
6
+ localRows: Ref<Row[]>,
7
+ columns: ComputedRef<ColumnConfig[]>,
8
+ visibleColumns: Ref<string[]>,
9
+ emitUpdate: () => void,
10
+ ) {
11
+ const sortColumn = ref<string | null>(null)
12
+ const sortDirection = ref<'asc' | 'desc'>('asc')
13
+
14
+ function sortByColumn(columnKey: string) {
15
+ const column = columns.value.find(col => col.key === columnKey)
16
+ if (!column?.sortable) { return }
17
+
18
+ if (sortColumn.value === columnKey) {
19
+ sortDirection.value = sortDirection.value === 'desc' ? 'asc' : 'desc'
20
+ } else {
21
+ sortColumn.value = columnKey
22
+ sortDirection.value = 'desc'
23
+ }
24
+
25
+ const sorted = [...localRows.value].sort((a, b) => {
26
+ const aVal = a[columnKey]
27
+ const bVal = b[columnKey]
28
+ if (aVal === bVal) { return 0 }
29
+ if (aVal == null) { return 1 }
30
+ if (bVal == null) { return -1 }
31
+ const modifier = sortDirection.value === 'desc' ? 1 : -1
32
+ return aVal < bVal ? -modifier : modifier
33
+ })
34
+
35
+ const savedVisible = [...visibleColumns.value]
36
+ localRows.value = sorted
37
+ emitUpdate()
38
+ nextTick(() => { visibleColumns.value = savedVisible })
39
+ }
40
+
41
+ return { sortColumn, sortDirection, sortByColumn }
42
+ }
@@ -0,0 +1,55 @@
1
+ import type { Ref } from 'vue'
2
+ import type { CellPosition, Row, SpreadsheetChange } from '../types'
3
+ import { ref, computed } from 'vue'
4
+
5
+ export function useSpreadsheetUndo(
6
+ localRows: Ref<Row[]>,
7
+ selectionStart: Ref<CellPosition | null>,
8
+ selectionEnd: Ref<CellPosition | null>,
9
+ emitUpdate: () => void,
10
+ ) {
11
+ const undoStack = ref<SpreadsheetChange[]>([])
12
+ const redoStack = ref<SpreadsheetChange[]>([])
13
+
14
+ const canUndo = computed(() => undoStack.value.length > 0)
15
+ const canRedo = computed(() => redoStack.value.length > 0)
16
+
17
+ function snapshot(): SpreadsheetChange['data'] {
18
+ return {
19
+ rows: JSON.parse(JSON.stringify(localRows.value)),
20
+ selection: selectionStart.value && selectionEnd.value
21
+ ? { start: { ...selectionStart.value }, end: { ...selectionEnd.value } }
22
+ : undefined,
23
+ }
24
+ }
25
+
26
+ function restoreSnapshot(change: SpreadsheetChange) {
27
+ localRows.value = JSON.parse(JSON.stringify(change.data.rows))
28
+ if (change.data.selection) {
29
+ selectionStart.value = { ...change.data.selection.start }
30
+ selectionEnd.value = { ...change.data.selection.end }
31
+ }
32
+ emitUpdate()
33
+ }
34
+
35
+ function saveState(type: SpreadsheetChange['type']) {
36
+ undoStack.value.push({ type, data: snapshot() })
37
+ redoStack.value = []
38
+ }
39
+
40
+ function undo() {
41
+ const change = undoStack.value.pop()
42
+ if (!change) { return }
43
+ redoStack.value.push({ type: change.type, data: snapshot() })
44
+ restoreSnapshot(change)
45
+ }
46
+
47
+ function redo() {
48
+ const change = redoStack.value.pop()
49
+ if (!change) { return }
50
+ undoStack.value.push({ type: change.type, data: snapshot() })
51
+ restoreSnapshot(change)
52
+ }
53
+
54
+ return { undoStack, redoStack, canUndo, canRedo, saveState, undo, redo }
55
+ }
@@ -0,0 +1,35 @@
1
+ export type ColumnFormat = 'text' | 'number' | 'currency' | 'date' | 'percentage' | 'image' | 'boolean'
2
+
3
+ export interface Row { [key: string]: any }
4
+
5
+ export interface ColumnConfig {
6
+ key: string
7
+ label?: string
8
+ locked?: boolean
9
+ format?: ColumnFormat
10
+ sortable?: boolean
11
+ width?: string
12
+ fixed?: boolean
13
+ hidden?: boolean
14
+ defaultValue?: any
15
+ }
16
+
17
+ export interface CellPosition {
18
+ row: number
19
+ col: number
20
+ }
21
+
22
+ export interface SpreadsheetChange {
23
+ type: 'cell' | 'row' | 'paste'
24
+ data: {
25
+ rows: Row[]
26
+ selection?: { start: CellPosition, end: CellPosition }
27
+ }
28
+ }
29
+
30
+ export interface SpreadsheetProps {
31
+ modelValue: Row[]
32
+ columnConfig?: ColumnConfig[]
33
+ label?: string
34
+ allowAddRow?: boolean
35
+ }