@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.
@@ -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,5 @@
1
+ /** Module declaration for the css-as-text build plugin. */
2
+ declare module '*.css' {
3
+ const content: string
4
+ export default content
5
+ }
@@ -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
+ }