@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.
- package/dist/components/Spreadsheet/Index.vue.d.ts +3 -22
- package/dist/components/Spreadsheet/Index.vue.d.ts.map +1 -1
- package/dist/components/Spreadsheet/SpreadsheetCell.vue.d.ts +23 -0
- package/dist/components/Spreadsheet/SpreadsheetCell.vue.d.ts.map +1 -0
- package/dist/components/Spreadsheet/SpreadsheetTable.vue.d.ts +2 -15
- package/dist/components/Spreadsheet/SpreadsheetTable.vue.d.ts.map +1 -1
- package/dist/components/Spreadsheet/composables/useSpreadsheetClipboard.d.ts +7 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetClipboard.d.ts.map +1 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetColumns.d.ts +14 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetColumns.d.ts.map +1 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetSelection.d.ts +24 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetSelection.d.ts.map +1 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetSort.d.ts +8 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetSort.d.ts.map +1 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetUndo.d.ts +72 -0
- package/dist/components/Spreadsheet/composables/useSpreadsheetUndo.d.ts.map +1 -0
- package/dist/components/Spreadsheet/types.d.ts +36 -0
- package/dist/components/Spreadsheet/types.d.ts.map +1 -0
- package/dist/components/Spreadsheet/utils.d.ts +6 -0
- package/dist/components/Spreadsheet/utils.d.ts.map +1 -0
- package/dist/index.cjs +90 -90
- package/dist/index.mjs +10165 -10102
- package/dist/style.css +1 -1
- package/package.json +2 -2
- package/src/components/Spreadsheet/Index.vue +127 -619
- package/src/components/Spreadsheet/SpreadsheetCell.vue +56 -0
- package/src/components/Spreadsheet/SpreadsheetTable.vue +79 -77
- package/src/components/Spreadsheet/composables/useSpreadsheetClipboard.ts +71 -0
- package/src/components/Spreadsheet/composables/useSpreadsheetColumns.ts +110 -0
- package/src/components/Spreadsheet/composables/useSpreadsheetSelection.ts +43 -0
- package/src/components/Spreadsheet/composables/useSpreadsheetSort.ts +42 -0
- package/src/components/Spreadsheet/composables/useSpreadsheetUndo.ts +55 -0
- package/src/components/Spreadsheet/types.ts +35 -0
- 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 {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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(--
|
|
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
|
+
}
|