@grapecity-software/dsh-spreadjs-editor 0.1.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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/cordis.patch.yml +22 -0
- package/lib/index.js +45 -0
- package/package.json +111 -0
- package/scripts/build.mjs +31 -0
- package/scripts/smoke-client.mjs +47 -0
- package/scripts/smoke-node.mjs +88 -0
- package/scripts/update-grapecity.mjs +34 -0
- package/src/augment.ts +11 -0
- package/src/client/SpreadsheetHost.tsx +420 -0
- package/src/client/SpreadsheetViewer.tsx +92 -0
- package/src/client/better-sidebar.ts +98 -0
- package/src/client/global.d.ts +5 -0
- package/src/client/index.ts +50 -0
- package/src/client/styles.ts +27 -0
- package/src/client/viewer.css +212 -0
- package/src/index.ts +67 -0
- package/tsconfig.json +22 -0
- package/tsdown.config.ts +110 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpreadJS Designer host: mounts the full designer into the generic editor
|
|
3
|
+
* panel and loads workspace files through the /spreadjs file bridge.
|
|
4
|
+
*
|
|
5
|
+
* The browser bundle imports the current @grapecity-software 19.x plugin set:
|
|
6
|
+
* core sheets + IO, Chinese resources, charts/shapes/slicers/sparklines,
|
|
7
|
+
* print/pdf/barcode/formula panel, PivotTable, TableSheet, data charts,
|
|
8
|
+
* GanttSheet, ReportSheet, and the Designer itself.
|
|
9
|
+
*/
|
|
10
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
|
11
|
+
import * as GC from '@grapecity-software/spread-sheets'
|
|
12
|
+
import * as ExcelIO from '@grapecity-software/spread-excelio'
|
|
13
|
+
import '@grapecity-software/spread-sheets-io'
|
|
14
|
+
import '@grapecity-software/spread-sheets-resources-zh'
|
|
15
|
+
import '@grapecity-software/spread-sheets-shapes'
|
|
16
|
+
import '@grapecity-software/spread-sheets-charts'
|
|
17
|
+
import '@grapecity-software/spread-sheets-slicers'
|
|
18
|
+
import '@grapecity-software/spread-sheets-sparklines'
|
|
19
|
+
import '@grapecity-software/spread-sheets-print'
|
|
20
|
+
import '@grapecity-software/spread-sheets-pdf'
|
|
21
|
+
import '@grapecity-software/spread-sheets-barcode'
|
|
22
|
+
import '@grapecity-software/spread-sheets-formula-panel'
|
|
23
|
+
import '@grapecity-software/spread-sheets-pivot-addon'
|
|
24
|
+
import '@grapecity-software/spread-sheets-tablesheet'
|
|
25
|
+
import '@grapecity-software/spread-sheets-datacharts-addon'
|
|
26
|
+
import '@grapecity-software/spread-sheets-ganttsheet'
|
|
27
|
+
import '@grapecity-software/spread-sheets-reportsheet-addon'
|
|
28
|
+
import '@grapecity-software/spread-sheets-languagepackages'
|
|
29
|
+
import '@grapecity-software/spread-sheets-designer-resources-cn'
|
|
30
|
+
import * as GCDesigner from '@grapecity-software/spread-sheets-designer'
|
|
31
|
+
|
|
32
|
+
export type ExportFormat = 'xlsx' | 'sjs' | 'ssjson' | 'csv'
|
|
33
|
+
export type StatusTone = 'idle' | 'busy' | 'error'
|
|
34
|
+
|
|
35
|
+
export interface SpreadsheetHostHandle {
|
|
36
|
+
save: () => Promise<void>
|
|
37
|
+
exportAs: (format: ExportFormat) => Promise<void>
|
|
38
|
+
newWorkbook: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Result of one write-back operation. */
|
|
42
|
+
export type SpreadsheetSaveResult = 'saved' | 'download'
|
|
43
|
+
|
|
44
|
+
/** File access seam: the host panel may use /spreadjs or /sidebar routes. */
|
|
45
|
+
export interface SpreadsheetFileAccess {
|
|
46
|
+
fileUrl(path: string): string
|
|
47
|
+
save(blob: Blob, path: string): Promise<SpreadsheetSaveResult>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SpreadsheetHostProps {
|
|
51
|
+
/** Absolute path of the file to open (undefined = nothing). */
|
|
52
|
+
filePath: string | undefined
|
|
53
|
+
/** SpreadJS license key from /spreadjs/api/config. */
|
|
54
|
+
licenseKey: string
|
|
55
|
+
/** Whether the host config fetch has completed. */
|
|
56
|
+
ready: boolean
|
|
57
|
+
/** Concrete read/write access chosen by the active adapter. */
|
|
58
|
+
fileAccess: SpreadsheetFileAccess
|
|
59
|
+
onStatus: (status: string, tone?: StatusTone) => void
|
|
60
|
+
onLoadingChange: (loading: boolean) => void
|
|
61
|
+
onNewWorkbook: () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type LoadStatus = { kind: 'idle' } | { kind: 'loading' } | { kind: 'error'; message: string }
|
|
65
|
+
|
|
66
|
+
interface DesignerLike {
|
|
67
|
+
getWorkbook(): any
|
|
68
|
+
setWorkbook(spread: any): void
|
|
69
|
+
destroy?(): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface DesignerNamespace {
|
|
73
|
+
Designer?: new (host: HTMLDivElement, config?: unknown, spread?: unknown, spreadOptions?: unknown) => DesignerLike
|
|
74
|
+
DefaultConfig?: unknown
|
|
75
|
+
LicenseKey?: string
|
|
76
|
+
setTheme?(theme: Record<string, string | undefined> | null): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function basename(path: string): string {
|
|
80
|
+
return path.split(/[\\/]/).pop() ?? path
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extname(path: string): string {
|
|
84
|
+
const name = basename(path)
|
|
85
|
+
const idx = name.lastIndexOf('.')
|
|
86
|
+
return idx >= 0 ? name.slice(idx).toLowerCase() : ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function replaceExt(path: string, ext: string): string {
|
|
90
|
+
const current = extname(path)
|
|
91
|
+
const base = current === '' ? path : path.slice(0, path.length - current.length)
|
|
92
|
+
return `${base}.${ext}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function downloadBlob(blob: Blob, filename: string): void {
|
|
96
|
+
const url = URL.createObjectURL(blob)
|
|
97
|
+
const anchor = document.createElement('a')
|
|
98
|
+
anchor.href = url
|
|
99
|
+
anchor.download = filename
|
|
100
|
+
anchor.click()
|
|
101
|
+
URL.revokeObjectURL(url)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function designerNamespace(): DesignerNamespace | undefined {
|
|
105
|
+
const designer = GCDesigner as unknown as { Spread?: { Sheets?: { Designer?: DesignerNamespace } } }
|
|
106
|
+
if (designer.Spread?.Sheets?.Designer !== undefined) return designer.Spread.Sheets.Designer
|
|
107
|
+
const gc = GC as unknown as { Spread?: { Sheets?: { Designer?: DesignerNamespace } } }
|
|
108
|
+
return gc.Spread?.Sheets?.Designer
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cloneDefaultDesignerConfig(): unknown {
|
|
112
|
+
const ns = designerNamespace()
|
|
113
|
+
if (ns?.DefaultConfig === undefined) return undefined
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(JSON.stringify(ns.DefaultConfig))
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Dark palette for the Designer chrome (ribbon, tabs, dialogs, panels). */
|
|
122
|
+
const DARK_THEME: Record<string, string | undefined> = {
|
|
123
|
+
colorForeground: '#e8eaed',
|
|
124
|
+
colorForegroundDisabled: '#7a808a',
|
|
125
|
+
colorBackground: '#1e2227',
|
|
126
|
+
colorBackgroundHover: '#2a2f36',
|
|
127
|
+
colorBackgroundSelected: '#33383f',
|
|
128
|
+
colorBackgroundDisabled: '#262a30',
|
|
129
|
+
colorBackground2: '#171a1f',
|
|
130
|
+
colorBackground2Hover: '#23272d',
|
|
131
|
+
colorBackground2Selected: '#2c3138',
|
|
132
|
+
colorBrandForeground: '#ffffff',
|
|
133
|
+
colorBrandBackground: '#3b6ef6',
|
|
134
|
+
colorBrandBackgroundHover: '#2f5ce0',
|
|
135
|
+
colorBrandBackgroundSelected: '#2748b8',
|
|
136
|
+
colorStroke: '#3a414a',
|
|
137
|
+
colorStrokeHover: '#4b5563',
|
|
138
|
+
colorStrokeSelected: '#5a6470',
|
|
139
|
+
colorStrokeDisabled: '#2c3036',
|
|
140
|
+
borderRadiusM: '4px',
|
|
141
|
+
borderRadiusL: '6px',
|
|
142
|
+
borderRadiusXL: '8px',
|
|
143
|
+
shadow4: '0 1px 2px rgba(0, 0, 0, 0.55)',
|
|
144
|
+
shadow8: '0 2px 6px rgba(0, 0, 0, 0.5)',
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function applyDesignerTheme(): void {
|
|
148
|
+
const ns = designerNamespace()
|
|
149
|
+
if (ns?.setTheme === undefined) return
|
|
150
|
+
const dark = typeof window !== 'undefined'
|
|
151
|
+
&& typeof window.matchMedia === 'function'
|
|
152
|
+
&& window.matchMedia('(prefers-color-scheme: dark)').matches === true
|
|
153
|
+
try {
|
|
154
|
+
ns.setTheme(dark ? DARK_THEME : null)
|
|
155
|
+
} catch {
|
|
156
|
+
// Theming is cosmetic; never block the editor for a palette failure.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function workbookFileType(path: string): GC.Spread.Sheets.FileType {
|
|
161
|
+
const ext = extname(path)
|
|
162
|
+
if (ext === '.xlsx' || ext === '.xlsm') return GC.Spread.Sheets.FileType.excel
|
|
163
|
+
if (ext === '.csv') return GC.Spread.Sheets.FileType.csv
|
|
164
|
+
return GC.Spread.Sheets.FileType.ssjson
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function exportFileType(format: ExportFormat): GC.Spread.Sheets.FileType {
|
|
168
|
+
if (format === 'xlsx') return GC.Spread.Sheets.FileType.excel
|
|
169
|
+
if (format === 'csv') return GC.Spread.Sheets.FileType.csv
|
|
170
|
+
return GC.Spread.Sheets.FileType.ssjson
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface ExcelIoLike {
|
|
174
|
+
open(
|
|
175
|
+
file: File,
|
|
176
|
+
success: (json: object) => void,
|
|
177
|
+
error: (error?: { errorMessage?: string }) => void,
|
|
178
|
+
): void
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function excelJson(file: File): Promise<object> {
|
|
182
|
+
const IO = (ExcelIO as unknown as { IO?: new () => ExcelIoLike }).IO
|
|
183
|
+
if (IO === undefined) return Promise.reject(new Error('spread-excelio IO is unavailable'))
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const io = new IO()
|
|
186
|
+
io.open(file, json => resolve(json), error => {
|
|
187
|
+
reject(new Error(error?.errorMessage ?? 'excel import failed'))
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function loadIntoWorkbook(
|
|
193
|
+
spread: any,
|
|
194
|
+
file: File,
|
|
195
|
+
path: string,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const ext = extname(path)
|
|
198
|
+
if (ext === '.xlsx' || ext === '.xlsm') {
|
|
199
|
+
// ExcelIO is the proven path for xlsx/xlsm: parse to workbook JSON, then
|
|
200
|
+
// fromJSON into a fresh workbook before handing it to the Designer.
|
|
201
|
+
spread.fromJSON(await excelJson(file))
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (ext === '.ssjson') {
|
|
205
|
+
spread.fromJSON(JSON.parse(await file.text()) as object)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
await new Promise<void>((resolve, reject) => {
|
|
209
|
+
const fail = (error: unknown) => {
|
|
210
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
211
|
+
}
|
|
212
|
+
if (ext === '.sjs') {
|
|
213
|
+
spread.open(file, resolve, fail)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
spread.import(file, resolve, fail, {
|
|
217
|
+
fileType: workbookFileType(path),
|
|
218
|
+
includeBindingSource: true,
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resetWorkbook(spread: any): void {
|
|
224
|
+
const count = spread.getSheetCount()
|
|
225
|
+
for (let i = count - 1; i > 0; i--) spread.removeSheet(i)
|
|
226
|
+
const sheet = spread.getSheet(0)
|
|
227
|
+
if (sheet !== undefined) {
|
|
228
|
+
sheet.reset()
|
|
229
|
+
sheet.setRowCount(200)
|
|
230
|
+
sheet.setColumnCount(20)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function workbookBlob(spread: any, path: string, format?: ExportFormat): Promise<Blob> {
|
|
235
|
+
const target = format === undefined && extname(path) === '.sjs'
|
|
236
|
+
? 'sjs'
|
|
237
|
+
: format === undefined
|
|
238
|
+
? workbookFileType(path)
|
|
239
|
+
: exportFileType(format)
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const fail = (error: unknown) => {
|
|
242
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
243
|
+
}
|
|
244
|
+
if (target === 'sjs') {
|
|
245
|
+
spread.save(resolve, fail, { includeBindingSource: true })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
spread.export(resolve, fail, {
|
|
249
|
+
fileType: target,
|
|
250
|
+
includeBindingSource: true,
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const SpreadsheetHost = forwardRef<SpreadsheetHostHandle, SpreadsheetHostProps>(
|
|
256
|
+
function SpreadsheetHost({ filePath, licenseKey, ready, fileAccess, onStatus, onLoadingChange, onNewWorkbook }, ref) {
|
|
257
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
258
|
+
const designerRef = useRef<DesignerLike | null>(null)
|
|
259
|
+
const loadSeqRef = useRef(0)
|
|
260
|
+
const pathRef = useRef(filePath)
|
|
261
|
+
const [status, setStatus] = useState<LoadStatus>({ kind: 'idle' })
|
|
262
|
+
|
|
263
|
+
pathRef.current = filePath
|
|
264
|
+
|
|
265
|
+
// SpreadJS and Designer require separate license keys and both must be set
|
|
266
|
+
// before the designer is constructed.
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!ready || licenseKey === '') return
|
|
269
|
+
const sheets = (GC as any).Spread?.Sheets
|
|
270
|
+
if (sheets !== undefined) sheets.LicenseKey = licenseKey
|
|
271
|
+
const ns = designerNamespace()
|
|
272
|
+
if (ns !== undefined) ns.LicenseKey = licenseKey
|
|
273
|
+
}, [licenseKey, ready])
|
|
274
|
+
|
|
275
|
+
// Follow the OS light/dark preference for the Designer chrome. This runs
|
|
276
|
+
// before the Designer is constructed so the instance starts on the right
|
|
277
|
+
// palette, and keeps listening for live system theme changes.
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
|
280
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
281
|
+
applyDesignerTheme()
|
|
282
|
+
const onChange = (): void => applyDesignerTheme()
|
|
283
|
+
if (typeof mq.addEventListener === 'function') mq.addEventListener('change', onChange)
|
|
284
|
+
else if (typeof mq.addListener === 'function') (mq as MediaQueryList & { addListener(cb: () => void): void }).addListener(onChange)
|
|
285
|
+
return () => {
|
|
286
|
+
if (typeof mq.removeEventListener === 'function') mq.removeEventListener('change', onChange)
|
|
287
|
+
else if (typeof mq.removeListener === 'function') (mq as MediaQueryList & { removeListener(cb: () => void): void }).removeListener(onChange)
|
|
288
|
+
}
|
|
289
|
+
}, [])
|
|
290
|
+
|
|
291
|
+
// Create / destroy the designer with the host element.
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
const el = hostRef.current
|
|
294
|
+
const ns = designerNamespace()
|
|
295
|
+
if (!ready || el === null || ns?.Designer === undefined) return
|
|
296
|
+
const designer = new ns.Designer(el, cloneDefaultDesignerConfig())
|
|
297
|
+
const spread = designer.getWorkbook()
|
|
298
|
+
if (spread !== undefined) {
|
|
299
|
+
spread.options.tabStripVisible = true
|
|
300
|
+
spread.options.newTabVisible = true
|
|
301
|
+
}
|
|
302
|
+
designerRef.current = designer
|
|
303
|
+
return () => {
|
|
304
|
+
const current = designer.getWorkbook()
|
|
305
|
+
if (current !== undefined && typeof current.destroy === 'function') current.destroy()
|
|
306
|
+
if (typeof designer.destroy === 'function') designer.destroy()
|
|
307
|
+
designerRef.current = null
|
|
308
|
+
}
|
|
309
|
+
}, [ready])
|
|
310
|
+
|
|
311
|
+
// Load the selected file; a stale async result is dropped by seq. This must
|
|
312
|
+
// wait for `ready` because the Designer is created in a separate effect that
|
|
313
|
+
// runs only after the host config fetch completes.
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const designer = designerRef.current
|
|
316
|
+
if (!ready || designer === null || filePath === undefined || filePath === '') {
|
|
317
|
+
if (filePath === undefined || filePath === '') setStatus({ kind: 'idle' })
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const seq = ++loadSeqRef.current
|
|
321
|
+
setStatus({ kind: 'loading' })
|
|
322
|
+
onLoadingChange(true)
|
|
323
|
+
onStatus(`Loading ${basename(filePath)}…`, 'busy')
|
|
324
|
+
void (async () => {
|
|
325
|
+
const response = await fetch(fileAccess.fileUrl(filePath))
|
|
326
|
+
if (!response.ok) {
|
|
327
|
+
const body = await response.json().catch(() => null) as { error?: string } | null
|
|
328
|
+
throw new Error(body?.error ?? `HTTP ${response.status}`)
|
|
329
|
+
}
|
|
330
|
+
const buffer = await response.arrayBuffer()
|
|
331
|
+
const name = basename(filePath)
|
|
332
|
+
const file = new File([buffer], name, { type: response.headers.get('content-type') ?? 'application/octet-stream' })
|
|
333
|
+
const spread = designer.getWorkbook()
|
|
334
|
+
if (spread === undefined) throw new Error('Designer workbook is not available')
|
|
335
|
+
spread.suspendPaint()
|
|
336
|
+
try {
|
|
337
|
+
await loadIntoWorkbook(spread, file, filePath)
|
|
338
|
+
} finally {
|
|
339
|
+
spread.resumePaint()
|
|
340
|
+
}
|
|
341
|
+
if (seq !== loadSeqRef.current) return
|
|
342
|
+
spread.refresh?.()
|
|
343
|
+
})().then(() => {
|
|
344
|
+
if (seq !== loadSeqRef.current) return
|
|
345
|
+
setStatus({ kind: 'idle' })
|
|
346
|
+
onLoadingChange(false)
|
|
347
|
+
onStatus(`Loaded ${basename(filePath)}`, 'idle')
|
|
348
|
+
}).catch((error: unknown) => {
|
|
349
|
+
if (seq !== loadSeqRef.current) return
|
|
350
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
351
|
+
setStatus({ kind: 'error', message })
|
|
352
|
+
onLoadingChange(false)
|
|
353
|
+
onStatus(`Load failed: ${message}`, 'error')
|
|
354
|
+
})
|
|
355
|
+
}, [fileAccess, filePath, ready, onLoadingChange, onStatus])
|
|
356
|
+
|
|
357
|
+
async function save(): Promise<void> {
|
|
358
|
+
const designer = designerRef.current
|
|
359
|
+
const path = pathRef.current
|
|
360
|
+
if (designer === null || path === undefined || path === '') return
|
|
361
|
+
onLoadingChange(true)
|
|
362
|
+
onStatus(`Saving ${basename(path)}…`, 'busy')
|
|
363
|
+
try {
|
|
364
|
+
const blob = await workbookBlob(designer.getWorkbook(), path)
|
|
365
|
+
const result = await fileAccess.save(blob, path)
|
|
366
|
+
onStatus(
|
|
367
|
+
result === 'download' ? `Saved ${basename(path)} as download` : `Saved ${basename(path)}`,
|
|
368
|
+
'idle',
|
|
369
|
+
)
|
|
370
|
+
} catch (error) {
|
|
371
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
372
|
+
onStatus(`Save failed: ${message}`, 'error')
|
|
373
|
+
} finally {
|
|
374
|
+
onLoadingChange(false)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function exportAs(format: ExportFormat): Promise<void> {
|
|
379
|
+
const designer = designerRef.current
|
|
380
|
+
if (designer === null) return
|
|
381
|
+
const path = pathRef.current ?? 'workbook.sjs'
|
|
382
|
+
const name = replaceExt(path, format)
|
|
383
|
+
onLoadingChange(true)
|
|
384
|
+
onStatus(`Exporting ${name}…`, 'busy')
|
|
385
|
+
try {
|
|
386
|
+
const blob = await workbookBlob(designer.getWorkbook(), path, format)
|
|
387
|
+
downloadBlob(blob, name)
|
|
388
|
+
onStatus(`Exported ${name}`, 'idle')
|
|
389
|
+
} catch (error) {
|
|
390
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
391
|
+
onStatus(`Export failed: ${message}`, 'error')
|
|
392
|
+
} finally {
|
|
393
|
+
onLoadingChange(false)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function newWorkbook(): void {
|
|
398
|
+
const designer = designerRef.current
|
|
399
|
+
if (designer === null) return
|
|
400
|
+
resetWorkbook(designer.getWorkbook())
|
|
401
|
+
loadSeqRef.current += 1
|
|
402
|
+
setStatus({ kind: 'idle' })
|
|
403
|
+
onNewWorkbook()
|
|
404
|
+
onStatus('New workbook', 'idle')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
useImperativeHandle(ref, () => ({ save, exportAs, newWorkbook }), [onLoadingChange, onNewWorkbook, onStatus])
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<div className="dsh-spreadjs-host">
|
|
411
|
+
<div ref={hostRef} />
|
|
412
|
+
{filePath === undefined || filePath === '' ? (
|
|
413
|
+
<div className="dsh-spreadjs-empty">New workbook ready. Use Export to save a copy.</div>
|
|
414
|
+
) : null}
|
|
415
|
+
{status.kind === 'loading' ? <div className="dsh-spreadjs-loading">Loading…</div> : null}
|
|
416
|
+
{status.kind === 'error' ? <div className="dsh-spreadjs-error">{status.message}</div> : null}
|
|
417
|
+
</div>
|
|
418
|
+
)
|
|
419
|
+
},
|
|
420
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BetterSidebar file viewer for SpreadJS workbooks.
|
|
3
|
+
*
|
|
4
|
+
* ui-all mounts dsh-better-sidebar; this component is registered through its
|
|
5
|
+
* `registerFileViewer` API. Files are read from the sidebar `/sidebar/file`
|
|
6
|
+
* media route and written back through `/sidebar/upload`, so the viewer works
|
|
7
|
+
* inside the active session's workspace without the generic web editor plugin.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
10
|
+
import { SpreadsheetHost, type SpreadsheetFileAccess, type StatusTone } from './SpreadsheetHost.tsx'
|
|
11
|
+
import {
|
|
12
|
+
sidebarFileUrl,
|
|
13
|
+
sidebarUploadUrl,
|
|
14
|
+
type SidebarFileViewerProps,
|
|
15
|
+
} from './better-sidebar.ts'
|
|
16
|
+
|
|
17
|
+
interface ConfigResponse {
|
|
18
|
+
licenseKey: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function basename(path: string): string {
|
|
22
|
+
return path.split(/[\\/]/).pop() ?? path
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SpreadsheetViewer(props: SidebarFileViewerProps): React.JSX.Element {
|
|
26
|
+
const { scope, path } = props
|
|
27
|
+
const [licenseKey, setLicenseKey] = useState('')
|
|
28
|
+
const [configReady, setConfigReady] = useState(false)
|
|
29
|
+
const [status, setStatus] = useState('')
|
|
30
|
+
const [statusTone, setStatusTone] = useState<StatusTone>('idle')
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let alive = true
|
|
34
|
+
void fetch('/spreadjs/api/config')
|
|
35
|
+
.then(async response => {
|
|
36
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
37
|
+
return await response.json() as ConfigResponse
|
|
38
|
+
})
|
|
39
|
+
.then(config => {
|
|
40
|
+
if (!alive) return
|
|
41
|
+
setLicenseKey(config.licenseKey ?? '')
|
|
42
|
+
setConfigReady(true)
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {
|
|
45
|
+
if (alive) setConfigReady(true)
|
|
46
|
+
})
|
|
47
|
+
return () => {
|
|
48
|
+
alive = false
|
|
49
|
+
}
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
const fileAccess = useMemo<SpreadsheetFileAccess>(() => ({
|
|
53
|
+
fileUrl: (target) => sidebarFileUrl(scope, target),
|
|
54
|
+
save: async (blob, target) => {
|
|
55
|
+
const response = await fetch(sidebarUploadUrl(scope, target), {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
body: blob,
|
|
58
|
+
})
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const body = await response.json().catch(() => null) as { error?: { message?: string } } | null
|
|
61
|
+
throw new Error(body?.error?.message ?? `HTTP ${response.status}`)
|
|
62
|
+
}
|
|
63
|
+
return 'saved'
|
|
64
|
+
},
|
|
65
|
+
}), [scope.cwd, scope.sessionId])
|
|
66
|
+
|
|
67
|
+
const handleStatus = useCallback((next: string, tone: StatusTone = 'idle') => {
|
|
68
|
+
setStatus(next)
|
|
69
|
+
setStatusTone(tone)
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const noop = useCallback(() => {}, [])
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="dsh-spreadjs-panel" role="region" aria-label={`SpreadJS: ${basename(path)}`}>
|
|
76
|
+
<div className="dsh-spreadjs-editor">
|
|
77
|
+
<SpreadsheetHost
|
|
78
|
+
filePath={path}
|
|
79
|
+
licenseKey={licenseKey}
|
|
80
|
+
ready={configReady}
|
|
81
|
+
fileAccess={fileAccess}
|
|
82
|
+
onStatus={handleStatus}
|
|
83
|
+
onLoadingChange={noop}
|
|
84
|
+
onNewWorkbook={noop}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
{status !== '' && (
|
|
88
|
+
<div className={`dsh-spreadjs-statusbar dsh-spreadjs-status-${statusTone}`}>{status}</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal public face of the `dsh-better-sidebar` client service.
|
|
3
|
+
*
|
|
4
|
+
* The ui-all bundle exposes this service as `ctx.betterSidebar`; external
|
|
5
|
+
* plugins register file viewers with `registerFileViewer`. The plugin keeps
|
|
6
|
+
* its own types here so the client bundle does not need to import another
|
|
7
|
+
* package's runtime module.
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentType } from 'react'
|
|
10
|
+
|
|
11
|
+
/** One request's session scope, mirroring better-sidebar's SessionScope. */
|
|
12
|
+
export interface SidebarSessionScope {
|
|
13
|
+
sessionId: string
|
|
14
|
+
cwd?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How the sidebar host loads bytes for a file viewer. */
|
|
18
|
+
export type SidebarFileFetchStrategy = 'mediaUrl' | 'custom' | 'none' | 'fsRead' | 'binary-download'
|
|
19
|
+
|
|
20
|
+
/** Props handed to a registered file viewer component. */
|
|
21
|
+
export interface SidebarFileViewerProps {
|
|
22
|
+
ctx: unknown
|
|
23
|
+
store: unknown
|
|
24
|
+
scope: SidebarSessionScope
|
|
25
|
+
path: string
|
|
26
|
+
title: string
|
|
27
|
+
viewerId: string
|
|
28
|
+
mediaUrl?: string
|
|
29
|
+
content?: string
|
|
30
|
+
customData?: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Registration payload consumed by `betterSidebar.registerFileViewer`. */
|
|
34
|
+
export interface SidebarFileViewerDescriptor {
|
|
35
|
+
id: string
|
|
36
|
+
title?: string | (() => string)
|
|
37
|
+
exts: readonly string[]
|
|
38
|
+
priority?: number
|
|
39
|
+
fetchStrategy: SidebarFileFetchStrategy
|
|
40
|
+
component: ComponentType<SidebarFileViewerProps>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Client service published by dsh-better-sidebar. */
|
|
44
|
+
export interface SidebarService {
|
|
45
|
+
registerFileViewer(descriptor: SidebarFileViewerDescriptor): () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ABSOLUTE_WINDOWS = /^[a-zA-Z]:[\\/]/
|
|
49
|
+
|
|
50
|
+
function pathSegments(path: string): string[] {
|
|
51
|
+
return path.split(/[\\/]/).filter(part => part !== '')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isAbsolute(path: string): boolean {
|
|
55
|
+
return path.startsWith('/') || path.startsWith('\\') || ABSOLUTE_WINDOWS.test(path)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolve a better-sidebar path against the session cwd when needed. */
|
|
59
|
+
export function sidebarAbsolutePath(path: string, cwd?: string): string {
|
|
60
|
+
if (isAbsolute(path)) return path
|
|
61
|
+
if (cwd === undefined || cwd === '') return path
|
|
62
|
+
return `${cwd.replace(/[\\/]+$/, '')}/${path}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pathBase(path: string): string {
|
|
66
|
+
const segments = pathSegments(path)
|
|
67
|
+
return segments.at(-1) ?? path
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pathDir(path: string): string {
|
|
71
|
+
const segments = pathSegments(path)
|
|
72
|
+
if (segments.length <= 1) return path.startsWith('/') ? '/' : '.'
|
|
73
|
+
const base = segments.slice(0, -1).join('/')
|
|
74
|
+
if (ABSOLUTE_WINDOWS.test(path) && segments.length === 2) return `${segments[0]}:/`
|
|
75
|
+
if (path.startsWith('/')) return `/${base}`
|
|
76
|
+
if (path.startsWith('\\\\')) return `\\\\${base}`
|
|
77
|
+
return base
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Raw-bytes media URL served by the sidebar host for one file. */
|
|
81
|
+
export function sidebarFileUrl(scope: SidebarSessionScope, path: string): string {
|
|
82
|
+
const target = sidebarAbsolutePath(path, scope.cwd)
|
|
83
|
+
const params = new URLSearchParams({ sessionId: scope.sessionId, path: target })
|
|
84
|
+
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
|
85
|
+
return `/sidebar/file?${params.toString()}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Raw upload URL that writes a blob back to the file's directory. */
|
|
89
|
+
export function sidebarUploadUrl(scope: SidebarSessionScope, path: string): string {
|
|
90
|
+
const target = sidebarAbsolutePath(path, scope.cwd)
|
|
91
|
+
const params = new URLSearchParams({
|
|
92
|
+
sessionId: scope.sessionId,
|
|
93
|
+
dir: pathDir(target),
|
|
94
|
+
relativePath: pathBase(target),
|
|
95
|
+
})
|
|
96
|
+
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
|
97
|
+
return `/sidebar/upload?${params.toString()}`
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-spreadjs-editor -- browser half.
|
|
3
|
+
*
|
|
4
|
+
* Registers the SpreadJS file viewer into `dsh-better-sidebar`, which is
|
|
5
|
+
* bundled by `@linxin666/dsh-web-all` (ui-all). The plugin has no generic
|
|
6
|
+
* web-editors dependency: opening and saving go through better-sidebar's
|
|
7
|
+
* session `/sidebar/file` and `/sidebar/upload` routes.
|
|
8
|
+
*/
|
|
9
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
|
+
import { SpreadsheetViewer } from './SpreadsheetViewer.tsx'
|
|
11
|
+
import type { SidebarService } from './better-sidebar.ts'
|
|
12
|
+
import { injectStyles } from './styles.ts'
|
|
13
|
+
|
|
14
|
+
export const name = 'dsh-spreadjs-editor'
|
|
15
|
+
|
|
16
|
+
/** Optional service discovery: the plugin stays idle if better-sidebar is absent. */
|
|
17
|
+
export const inject: string[] = []
|
|
18
|
+
|
|
19
|
+
const SIDEBAR_VIEWER = {
|
|
20
|
+
id: 'spreadjs',
|
|
21
|
+
title: 'SpreadJS',
|
|
22
|
+
exts: ['xlsx', 'xlsm', 'csv', 'sjs', 'ssjson'],
|
|
23
|
+
fetchStrategy: 'mediaUrl',
|
|
24
|
+
priority: 0,
|
|
25
|
+
component: SpreadsheetViewer,
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export function apply(ctx: ClientContext): void {
|
|
29
|
+
ctx.effect(() => injectStyles(), 'dsh-spreadjs-editor: styles')
|
|
30
|
+
|
|
31
|
+
ctx.effect(() => {
|
|
32
|
+
const fiber = ctx.inject(['betterSidebar'], (child) => {
|
|
33
|
+
child.effect(() => {
|
|
34
|
+
const service = child.get('betterSidebar') as SidebarService
|
|
35
|
+
let dispose: (() => void) | undefined
|
|
36
|
+
try {
|
|
37
|
+
dispose = service.registerFileViewer({ ...SIDEBAR_VIEWER })
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('[dsh-spreadjs-editor] betterSidebar registration failed:', error)
|
|
40
|
+
}
|
|
41
|
+
return () => {
|
|
42
|
+
try { dispose?.() } catch { /* already disposed */ }
|
|
43
|
+
}
|
|
44
|
+
}, 'dsh-spreadjs-editor: betterSidebar registration')
|
|
45
|
+
})
|
|
46
|
+
return () => {
|
|
47
|
+
try { fiber.dispose() } catch { /* already disposed */ }
|
|
48
|
+
}
|
|
49
|
+
}, 'dsh-spreadjs-editor: betterSidebar adapter')
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stylesheet injection for the client bundle. SpreadJS ships its own CSS and
|
|
3
|
+
* the editor adds a small stylesheet; both are inlined as text by the build
|
|
4
|
+
* plugin and injected here at factory materialization (framework style).
|
|
5
|
+
*/
|
|
6
|
+
import spreadSheetsCss from '@grapecity-software/spread-sheets/styles/gc.spread.sheets.excel2013white.css'
|
|
7
|
+
import designerCss from '@grapecity-software/spread-sheets-designer/styles/gc.spread.sheets.designer.light.min.css'
|
|
8
|
+
import viewerCss from './viewer.css'
|
|
9
|
+
|
|
10
|
+
const STYLES = [spreadSheetsCss, designerCss, viewerCss]
|
|
11
|
+
|
|
12
|
+
/** Inject the stylesheets; returns a disposer that removes the tags. */
|
|
13
|
+
export function injectStyles(): () => void {
|
|
14
|
+
const tags: HTMLStyleElement[] = []
|
|
15
|
+
if (typeof document !== 'undefined' && document.head !== null) {
|
|
16
|
+
for (const css of STYLES) {
|
|
17
|
+
const tag = document.createElement('style')
|
|
18
|
+
tag.dataset.plugin = 'dsh-spreadjs-editor'
|
|
19
|
+
tag.textContent = css
|
|
20
|
+
document.head.appendChild(tag)
|
|
21
|
+
tags.push(tag)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return () => {
|
|
25
|
+
for (const tag of tags) tag.remove()
|
|
26
|
+
}
|
|
27
|
+
}
|