mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(files): fix large file editor performance and selection toolbar placement (#7171)
* fix(files): streamline large editor updates and previews * fix(files): bound wide spreadsheet previews
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import { AllSelection, TextSelection } from '@tiptap/pm/state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bubbleMenuAnchorRange } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating'
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: { content: 'text*' },
|
||||
text: { inline: true },
|
||||
},
|
||||
})
|
||||
|
||||
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('first line'))])
|
||||
|
||||
describe('bubbleMenuAnchorRange', () => {
|
||||
it('collapses a whole-document selection to its leading position', () => {
|
||||
const selection = new AllSelection(doc)
|
||||
|
||||
expect(bubbleMenuAnchorRange(selection)).toEqual({
|
||||
from: selection.from,
|
||||
to: selection.from,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves ordinary text-selection geometry', () => {
|
||||
const selection = TextSelection.create(doc, 1, 6)
|
||||
|
||||
expect(bubbleMenuAnchorRange(selection)).toEqual({ from: 1, to: 6 })
|
||||
})
|
||||
})
|
||||
+12
-1
@@ -1,7 +1,18 @@
|
||||
import { useCallback } from 'react'
|
||||
import { posToDOMRect } from '@tiptap/core'
|
||||
import { AllSelection, type Selection } from '@tiptap/pm/state'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* A whole-document selection has a viewport-sized bounding box, which gives Floating UI no viable
|
||||
* side to flip to and leaves the toolbar clipped above the editor. Anchor that semantic selection to
|
||||
* the document's leading position; every ordinary selection keeps its complete range geometry.
|
||||
*/
|
||||
export function bubbleMenuAnchorRange(selection: Selection): { from: number; to: number } {
|
||||
if (selection instanceof AllSelection) return { from: selection.from, to: selection.from }
|
||||
return { from: selection.from, to: selection.to }
|
||||
}
|
||||
|
||||
/**
|
||||
* A Floating UI virtual element anchored to the current selection. The rect is recomputed on every
|
||||
* call rather than cached by selection: the same `from`/`to` maps to a different screen position as
|
||||
@@ -11,7 +22,7 @@ import type { Editor } from '@tiptap/react'
|
||||
function selectionVirtualElement(editor: Editor) {
|
||||
const { view, state } = editor
|
||||
if (!view.dom.isConnected) return null
|
||||
const { from, to } = state.selection
|
||||
const { from, to } = bubbleMenuAnchorRange(state.selection)
|
||||
const rect = posToDOMRect(view, from, to)
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, type ComponentProps } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { TextEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor'
|
||||
|
||||
interface MockMonacoProps {
|
||||
onChange?: (value: string | undefined) => void
|
||||
onMount?: (editor: unknown, monaco: unknown) => void
|
||||
options?: unknown
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
content: 'initial',
|
||||
editorProps: null as MockMonacoProps | null,
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => (props: MockMonacoProps) => {
|
||||
state.editorProps = props
|
||||
return <div data-testid='monaco-editor' />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content',
|
||||
() => ({
|
||||
useEditableFileContent: () => ({
|
||||
content: state.content,
|
||||
setDraftContent: (content: string) => {
|
||||
state.content = content
|
||||
},
|
||||
isStreamInteractionLocked: false,
|
||||
isContentLoading: false,
|
||||
hasContentError: false,
|
||||
saveImmediately: vi.fn(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge',
|
||||
() => ({ useSelectionCopyBridge: vi.fn() })
|
||||
)
|
||||
|
||||
vi.mock('@/hooks/use-add-to-chat', () => ({ useAddToChat: () => vi.fn() }))
|
||||
|
||||
const file: WorkspaceFileRecord = {
|
||||
id: 'file-1',
|
||||
workspaceId: 'workspace-1',
|
||||
name: 'example.txt',
|
||||
key: 'workspace/file-1',
|
||||
path: '/workspace/file-1',
|
||||
size: 7,
|
||||
type: 'text/plain',
|
||||
uploadedBy: 'user-1',
|
||||
uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
const props: ComponentProps<typeof TextEditor> = {
|
||||
file,
|
||||
workspaceId: file.workspaceId,
|
||||
canEdit: true,
|
||||
previewMode: 'editor',
|
||||
disableStreamingAutoScroll: false,
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
let editorValue = 'initial'
|
||||
const getValue = vi.fn(() => editorValue)
|
||||
const applyEdits = vi.fn((edits: Array<{ text: string }>) => {
|
||||
editorValue = edits[0]?.text ?? editorValue
|
||||
})
|
||||
const model = {
|
||||
getValue,
|
||||
setValue: vi.fn((value: string) => {
|
||||
editorValue = value
|
||||
}),
|
||||
applyEdits,
|
||||
getFullModelRange: vi.fn(() => ({})),
|
||||
}
|
||||
const editor = {
|
||||
getModel: vi.fn(() => model),
|
||||
addCommand: vi.fn(),
|
||||
getSelection: vi.fn(() => null),
|
||||
onContextMenu: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onDidDispose: vi.fn(),
|
||||
}
|
||||
const monaco = {
|
||||
KeyMod: { CtrlCmd: 1 },
|
||||
KeyCode: { KeyS: 2 },
|
||||
}
|
||||
|
||||
return { editor, monaco, model, getValue, applyEdits }
|
||||
}
|
||||
|
||||
function renderEditor(): { rerender: () => void; root: Root } {
|
||||
const root = createRoot(document.createElement('div'))
|
||||
act(() => root.render(<TextEditor {...props} />))
|
||||
return {
|
||||
rerender: () => act(() => root.render(<TextEditor {...props} file={{ ...file }} />)),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
describe('TextEditor content synchronization', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
state.content = 'initial'
|
||||
state.editorProps = null
|
||||
})
|
||||
|
||||
it('does not reread the complete Monaco model after a local edit', () => {
|
||||
const view = renderEditor()
|
||||
const { editor, monaco, getValue } = createEditor()
|
||||
|
||||
act(() => {
|
||||
state.editorProps?.onMount?.(editor, monaco)
|
||||
})
|
||||
const initialOptions = state.editorProps?.options
|
||||
getValue.mockClear()
|
||||
|
||||
act(() => {
|
||||
state.editorProps?.onChange?.('local edit')
|
||||
})
|
||||
view.rerender()
|
||||
|
||||
expect(getValue).not.toHaveBeenCalled()
|
||||
expect(state.editorProps?.options).toBe(initialOptions)
|
||||
act(() => view.root.unmount())
|
||||
})
|
||||
|
||||
it('still reconciles an external update when the editor has no local changes', () => {
|
||||
const view = renderEditor()
|
||||
const { editor, monaco, getValue, applyEdits } = createEditor()
|
||||
|
||||
act(() => {
|
||||
state.editorProps?.onMount?.(editor, monaco)
|
||||
})
|
||||
getValue.mockClear()
|
||||
|
||||
state.content = 'server update'
|
||||
view.rerender()
|
||||
|
||||
expect(getValue).toHaveBeenCalledOnce()
|
||||
expect(applyEdits).toHaveBeenCalledWith([{ range: {}, text: 'server update' }])
|
||||
act(() => view.root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -33,6 +34,44 @@ import { useSelectionCopyBridge } from './use-selection-copy-bridge'
|
||||
/** File ids observed rendering as Sim pages this session (see the sticky lock). */
|
||||
const KNOWN_PAGE_FILE_IDS = new Set<string>()
|
||||
|
||||
const TEXT_EDITOR_OPTIONS = {
|
||||
largeFileOptimizations: true,
|
||||
maxTokenizationLineLength: 20_000,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
padding: { top: 24, bottom: 24 },
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
renderLineHighlight: 'line',
|
||||
occurrencesHighlight: 'singleFile',
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 6,
|
||||
horizontalScrollbarSize: 6,
|
||||
},
|
||||
quickSuggestions: false,
|
||||
suggestOnTriggerCharacters: false,
|
||||
wordBasedSuggestions: 'currentDocument',
|
||||
parameterHints: { enabled: false },
|
||||
codeLens: false,
|
||||
lightbulb: {
|
||||
enabled: 'off' as MonacoEditorTypes.ShowLightbulbIconMode,
|
||||
},
|
||||
inlayHints: { enabled: 'off' },
|
||||
contextmenu: false,
|
||||
fixedOverflowWidgets: true,
|
||||
glyphMargin: false,
|
||||
stickyScroll: { enabled: false },
|
||||
bracketPairColorization: { enabled: false },
|
||||
unicodeHighlight: { ambiguousCharacters: false },
|
||||
} satisfies MonacoEditorTypes.IStandaloneEditorConstructionOptions
|
||||
|
||||
const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [
|
||||
{ token: 'comment', foreground: '606060', fontStyle: 'italic' },
|
||||
{ token: 'string', foreground: '3ab872' },
|
||||
@@ -382,6 +421,7 @@ export const TextEditor = memo(function TextEditor({
|
||||
}: TextEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const monacoEditorRef = useRef<Parameters<OnMount>[0] | null>(null)
|
||||
const lastEditorValueRef = useRef('')
|
||||
const lastSyncedContentRef = useRef('')
|
||||
const hasAutoFocusedRef = useRef(false)
|
||||
const contentRef = useRef('')
|
||||
@@ -456,11 +496,14 @@ export const TextEditor = memo(function TextEditor({
|
||||
useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading)
|
||||
|
||||
useEffect(() => {
|
||||
if (lastEditorValueRef.current === content) return
|
||||
|
||||
const editor = monacoEditorRef.current
|
||||
if (!editor) return
|
||||
const model = editor.getModel()
|
||||
if (!model) return
|
||||
const monacoValue = model.getValue()
|
||||
lastEditorValueRef.current = monacoValue
|
||||
if (monacoValue === content) return
|
||||
|
||||
if (isStreamInteractionLocked || monacoValue === lastSyncedContentRef.current) {
|
||||
@@ -491,6 +534,7 @@ export const TextEditor = memo(function TextEditor({
|
||||
model.applyEdits([{ range: model.getFullModelRange(), text: content }])
|
||||
}
|
||||
suppressScrollListenerRef.current = false
|
||||
lastEditorValueRef.current = content
|
||||
lastSyncedContentRef.current = content
|
||||
}
|
||||
}, [content, isStreamInteractionLocked])
|
||||
@@ -563,9 +607,12 @@ export const TextEditor = memo(function TextEditor({
|
||||
|
||||
const model = editor.getModel()
|
||||
const currentContent = contentRef.current
|
||||
if (model && currentContent && model.getValue() !== currentContent) {
|
||||
model.setValue(currentContent)
|
||||
if (model) {
|
||||
if (model.getValue() !== currentContent) {
|
||||
model.setValue(currentContent)
|
||||
}
|
||||
lastSyncedContentRef.current = currentContent
|
||||
lastEditorValueRef.current = currentContent
|
||||
}
|
||||
|
||||
if (autoFocus && !hasAutoFocusedRef.current) {
|
||||
@@ -588,6 +635,7 @@ export const TextEditor = memo(function TextEditor({
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
const nextValue = value ?? ''
|
||||
lastEditorValueRef.current = nextValue
|
||||
contentRef.current = nextValue
|
||||
setDraftContent(nextValue)
|
||||
},
|
||||
@@ -633,6 +681,10 @@ export const TextEditor = memo(function TextEditor({
|
||||
|
||||
const isStreaming = isStreamInteractionLocked
|
||||
const isEditorReadOnly = isStreamInteractionLocked || !canEdit
|
||||
const editorOptions = useMemo(
|
||||
() => ({ ...TEXT_EDITOR_OPTIONS, readOnly: isEditorReadOnly }),
|
||||
[isEditorReadOnly]
|
||||
)
|
||||
|
||||
const previewType = resolvePreviewType(file.type, file.name)
|
||||
const isIframeRendered = previewType === 'html' || previewType === 'svg'
|
||||
@@ -697,44 +749,7 @@ export const TextEditor = memo(function TextEditor({
|
||||
defaultValue={content}
|
||||
language={monacoLanguage}
|
||||
theme={monacoTheme}
|
||||
options={{
|
||||
readOnly: isEditorReadOnly,
|
||||
largeFileOptimizations: true,
|
||||
maxTokenizationLineLength: 20_000,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
padding: { top: 24, bottom: 24 },
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
renderLineHighlight: 'line',
|
||||
occurrencesHighlight: 'singleFile',
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 6,
|
||||
horizontalScrollbarSize: 6,
|
||||
},
|
||||
quickSuggestions: false,
|
||||
suggestOnTriggerCharacters: false,
|
||||
wordBasedSuggestions: 'currentDocument',
|
||||
parameterHints: { enabled: false },
|
||||
codeLens: false,
|
||||
lightbulb: {
|
||||
enabled: 'off' as MonacoEditorTypes.ShowLightbulbIconMode,
|
||||
},
|
||||
inlayHints: { enabled: 'off' },
|
||||
contextmenu: false,
|
||||
fixedOverflowWidgets: true,
|
||||
glyphMargin: false,
|
||||
stickyScroll: { enabled: false },
|
||||
bracketPairColorization: { enabled: false },
|
||||
unicodeHighlight: { ambiguousCharacters: false },
|
||||
}}
|
||||
options={editorOptions}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
className='h-full'
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as XLSX from 'xlsx'
|
||||
import {
|
||||
readXlsxPreviewData,
|
||||
XLSX_MAX_COLUMNS,
|
||||
XLSX_MAX_ROWS,
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data'
|
||||
|
||||
describe('readXlsxPreviewData', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('bounds conversion to the rows the preview can display', () => {
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
['header-a', 'header-b'],
|
||||
['row-1-a', 'row-1-b'],
|
||||
['row-2-a', 'row-2-b'],
|
||||
])
|
||||
sheet['!ref'] = 'A1:B200000'
|
||||
const toJson = vi.spyOn(XLSX.utils, 'sheet_to_json')
|
||||
|
||||
const result = readXlsxPreviewData(XLSX, sheet)
|
||||
const options = toJson.mock.calls[0][1] as {
|
||||
range: { s: { r: number }; e: { r: number } }
|
||||
}
|
||||
|
||||
expect(options.range.e.r - options.range.s.r).toBe(XLSX_MAX_ROWS)
|
||||
expect(result.headers).toEqual(['header-a', 'header-b'])
|
||||
expect(result.rows).toHaveLength(XLSX_MAX_ROWS)
|
||||
expect(result.rows.slice(0, 2)).toEqual([
|
||||
['row-1-a', 'row-1-b'],
|
||||
['row-2-a', 'row-2-b'],
|
||||
])
|
||||
expect(result.rowTruncated).toBe(true)
|
||||
expect(result.columnTruncated).toBe(false)
|
||||
})
|
||||
|
||||
it('does not mark a sheet at the existing display boundary as truncated', () => {
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
['header'],
|
||||
...Array.from({ length: XLSX_MAX_ROWS }, (_, index) => [`row-${index}`]),
|
||||
])
|
||||
|
||||
const result = readXlsxPreviewData(XLSX, sheet)
|
||||
|
||||
expect(result.rows).toHaveLength(XLSX_MAX_ROWS)
|
||||
expect(result.rowTruncated).toBe(false)
|
||||
expect(result.columnTruncated).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds conversion for extremely wide declared ranges', () => {
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
['header-a', 'header-b'],
|
||||
['row-1-a', 'row-1-b'],
|
||||
])
|
||||
sheet['!ref'] = 'A1:XFD2'
|
||||
const toJson = vi.spyOn(XLSX.utils, 'sheet_to_json')
|
||||
|
||||
const result = readXlsxPreviewData(XLSX, sheet)
|
||||
const options = toJson.mock.calls[0][1] as {
|
||||
range: { s: { c: number }; e: { c: number } }
|
||||
}
|
||||
|
||||
expect(options.range.e.c - options.range.s.c + 1).toBe(XLSX_MAX_COLUMNS)
|
||||
expect(result.headers).toEqual(['header-a', 'header-b'])
|
||||
expect(result.rows).toEqual([['row-1-a', 'row-1-b']])
|
||||
expect(result.rowTruncated).toBe(false)
|
||||
expect(result.columnTruncated).toBe(true)
|
||||
})
|
||||
})
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import type { WorkSheet } from 'xlsx'
|
||||
|
||||
export const XLSX_MAX_ROWS = 1_000
|
||||
export const XLSX_MAX_COLUMNS = 200
|
||||
|
||||
interface XlsxModule {
|
||||
utils: Pick<typeof import('xlsx').utils, 'decode_range' | 'sheet_to_json'>
|
||||
}
|
||||
|
||||
interface XlsxPreviewData {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
rowTruncated: boolean
|
||||
columnTruncated: boolean
|
||||
}
|
||||
|
||||
export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPreviewData {
|
||||
const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1')
|
||||
const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS)
|
||||
const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1)
|
||||
const previewRows = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
range: {
|
||||
s: declaredRange.s,
|
||||
e: { r: lastPreviewRow, c: lastPreviewColumn },
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
headers: previewRows[0] ?? [],
|
||||
rows: previewRows.slice(1),
|
||||
rowTruncated: declaredRange.e.r > lastPreviewRow,
|
||||
columnTruncated: declaredRange.e.c > lastPreviewColumn,
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -8,19 +8,23 @@ import type { WorkBook } from 'xlsx'
|
||||
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
|
||||
import {
|
||||
readXlsxPreviewData,
|
||||
XLSX_MAX_COLUMNS,
|
||||
XLSX_MAX_ROWS,
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data'
|
||||
import { DataTable } from './data-table'
|
||||
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
|
||||
import { useDocPreviewBinary } from './use-doc-preview-binary'
|
||||
|
||||
const logger = createLogger('XlsxPreview')
|
||||
|
||||
const XLSX_MAX_ROWS = 1_000
|
||||
|
||||
interface XlsxSheet {
|
||||
name: string
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
truncated: boolean
|
||||
rowTruncated: boolean
|
||||
columnTruncated: boolean
|
||||
}
|
||||
|
||||
export const XlsxPreview = memo(function XlsxPreview({
|
||||
@@ -83,16 +87,14 @@ export const XlsxPreview = memo(function XlsxPreview({
|
||||
const workbook = workbookRef.current!
|
||||
const name = sheetNames[activeSheet]
|
||||
const sheet = workbook.Sheets[name]
|
||||
const allRows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
|
||||
const headers = (allRows[0] ?? []) as string[]
|
||||
const dataRows = allRows.slice(1) as string[][]
|
||||
const truncated = dataRows.length > XLSX_MAX_ROWS
|
||||
const { headers, rows, rowTruncated, columnTruncated } = readXlsxPreviewData(XLSX, sheet)
|
||||
if (!cancelled) {
|
||||
setCurrentSheet({
|
||||
name,
|
||||
headers,
|
||||
rows: truncated ? dataRows.slice(0, XLSX_MAX_ROWS) : dataRows,
|
||||
truncated,
|
||||
rows,
|
||||
rowTruncated,
|
||||
columnTruncated,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -134,9 +136,14 @@ export const XlsxPreview = memo(function XlsxPreview({
|
||||
</div>
|
||||
<div ref={scrollRef} className='flex-1 overflow-auto p-6'>
|
||||
<DataTable headers={currentSheet.headers} rows={currentSheet.rows} />
|
||||
{currentSheet.truncated && (
|
||||
{(currentSheet.rowTruncated || currentSheet.columnTruncated) && (
|
||||
<p className='mt-3 text-center text-[12px] text-[var(--text-muted)]'>
|
||||
Showing first {XLSX_MAX_ROWS.toLocaleString()} rows. Download the file to view all data.
|
||||
{currentSheet.rowTruncated && currentSheet.columnTruncated
|
||||
? `Showing first ${XLSX_MAX_ROWS.toLocaleString()} rows and ${XLSX_MAX_COLUMNS.toLocaleString()} columns.`
|
||||
: currentSheet.rowTruncated
|
||||
? `Showing first ${XLSX_MAX_ROWS.toLocaleString()} rows.`
|
||||
: `Showing first ${XLSX_MAX_COLUMNS.toLocaleString()} columns.`}{' '}
|
||||
Download the file to view all data.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user