@witan/xlsx-view
v0.1.1
Published
Spreadsheet Viewer SDK for rendering Excel files with Witan API
Maintainers
Readme
@witan/xlsx-view
A client SDK for rendering Excel spreadsheets from the Witan API. The Witan API renders spreadsheet cells as image tiles server-side, and this SDK composites those tiles into an interactive viewer.
Note: This package requires a Witan API subscription. The SDK connects to Witan's WebSocket API to stream pre-rendered spreadsheet tiles.
Features
- Tile-based rendering: Server renders cells as images, client composites them for perfect visual fidelity
- Framework-agnostic core:
XlsxClientclass works with any framework - React integration: Ready-to-use
XlsxViewcomponent with hooks - HiDPI support: Automatic device pixel ratio handling for crisp rendering
- Efficient caching: LRU tile cache with proper ImageBitmap cleanup
- Priority loading: Tiles closest to viewport center load first
- WebSocket streaming: Real-time tile updates with automatic reconnection
- Adaptive format: WebP tiles when browser supports it, PNG fallback
- Skeleton loading: Smooth loading states while tiles stream in
- Customizable: CSS variables for theming, render slots for custom UI
Installation
pnpm add @witan/xlsx-viewQuick Start
Prerequisites
- A Witan API subscription (get one at witanlabs.com)
- An uploaded XLSX file (via the Witan API)
- An authentication token for the Witan API
React
import { XlsxView, xlsxViewStyles } from "@witan/xlsx-view/react";
// Include default styles (or define your own CSS variables)
const styleSheet = document.createElement("style");
styleSheet.textContent = xlsxViewStyles;
document.head.appendChild(styleSheet);
function SpreadsheetViewer({
fileId,
revisionId,
}: {
fileId: string;
revisionId: string;
}) {
// getToken should return a valid Witan API access token
const getToken = async () => {
const response = await fetch("/api/witan-token");
const { token } = await response.json();
return token;
};
return (
<XlsxView
apiOrigin="https://api.witanlabs.com" // Your Witan API endpoint
fileId={fileId}
revisionId={revisionId}
getAccessToken={getToken}
/>
);
}Core (Framework-Agnostic)
import { XlsxClient } from "@witan/xlsx-view";
const client = new XlsxClient({
apiOrigin: "https://api.witanlabs.com", // Your Witan API endpoint
fileId: "your-file-id",
revisionId: "your-revision-id",
getAccessToken: () => fetchWitanToken(), // Return your Witan API token
});
client.on("connectionChange", ({ status }) => {
console.log("Connection:", status);
});
client.on("metadataLoaded", ({ metadata }) => {
console.log(
"Sheets:",
metadata.sheets.map((s) => s.name),
);
});
client.on("tileLoaded", ({ key }) => {
// Re-render when tiles arrive
const tiles = client.getTilesForViewport(viewport);
render(tiles);
});
client.connect();
// Set viewport when container is ready
client.setViewport({ scrollX: 0, scrollY: 0, width: 800, height: 600 });
// Cleanup
client.dispose();React API
XlsxView
Main component for rendering spreadsheets.
interface XlsxViewProps {
// Connection (required)
apiOrigin: string;
fileId: string;
revisionId: string;
getAccessToken: () => Promise<string>;
// State (hybrid: uncontrolled by default, controlled if props provided)
selection?: Selection | null;
defaultSelection?: Selection;
onSelectionChange?: (selection: Selection | null) => void;
activeSheet?: string;
defaultSheet?: string;
onSheetChange?: (sheetName: string) => void;
// UI customization
formulaBar?: (props: FormulaBarProps) => ReactNode;
sheetTabs?: (props: SheetTabsProps) => ReactNode;
selectionAction?: (props: SelectionActionProps) => ReactNode;
// Callbacks
onError?: (error: Error) => void;
onReady?: () => void; // Called when viewer is ready (metadata loaded, tiles rendered)
onHyperlinkClick?: (link: HyperlinkInfo) => void; // Called when a hyperlink is clicked
// Styling
className?: string;
}
// Imperative handle (via ref)
interface XlsxViewHandle {
/** Scroll a cell into view. Returns true if successful, false if not ready. */
scrollToCell: (row: number, col: number) => boolean;
}Usage Examples
Uncontrolled (simplest):
<XlsxView
apiOrigin="https://api.example.com"
fileId="abc123"
revisionId="rev456"
getAccessToken={getToken}
/>Controlled selection:
const [selection, setSelection] = useState<Selection | null>(null);
<XlsxView
{...connectionProps}
selection={selection}
onSelectionChange={setSelection}
/>;Custom UI slots:
<XlsxView
{...connectionProps}
// Custom sheet tabs
sheetTabs={({ sheets, activeSheet, onSheetChange }) => (
<MyCustomTabs
sheets={sheets}
active={activeSheet}
onChange={onSheetChange}
/>
)}
// Custom action button on selection
selectionAction={({ selection, position }) => (
<button style={{ position: "absolute" }}>Analyze Selection</button>
)}
/>Programmatic scrolling with ref:
import { useRef, useState } from "react";
import { XlsxView, type XlsxViewHandle } from "@witan/xlsx-view/react";
function SpreadsheetWithNavigation() {
const viewRef = useRef<XlsxViewHandle>(null);
const [ready, setReady] = useState(false);
const navigateToCell = (row: number, col: number) => {
// scrollToCell scrolls the minimum amount needed to bring the cell into view.
// If the cell is already visible, no scrolling occurs.
viewRef.current?.scrollToCell(row, col);
};
return (
<>
<button onClick={() => navigateToCell(50, 10)} disabled={!ready}>
Go to K51
</button>
<XlsxView
ref={viewRef}
{...connectionProps}
onReady={() => setReady(true)}
/>
</>
);
}Hooks
useXlsxClient
Access the XlsxClient instance and state.
const {
client, // XlsxClient instance
status, // ConnectionStatus
metadata, // ViewSheetsResponse | null
activeSheet, // string | null
selection, // Selection | null
error, // Error | null
setActiveSheet,
setSelection,
} = useXlsxClient({ apiOrigin, fileId, revisionId, getAccessToken });useViewport
Manage viewport state and scroll handling.
const {
viewport, // { scrollX, scrollY, width, height }
setContainerRef, // Ref callback for scroll container
handleScroll, // onScroll handler
scrollTo, // (x, y) => void
} = useViewport();useSelection
Handle cell selection with mouse interactions.
const {
selection, // Selection | null
isDragging, // boolean
handleMouseDown, // Event handler
handleMouseMove, // Event handler
handleMouseUp, // Event handler
} = useSelection({
client,
viewport,
initialSelection,
selection,
onSelectionChange,
});Hyperlink handling:
import type { HyperlinkInfo } from "@witan/xlsx-view/react";
<XlsxView
{...connectionProps}
onHyperlinkClick={(link: HyperlinkInfo) => {
if (link.type === "external") {
// Custom external link handling
window.open(link.target, "_blank");
} else {
// Internal link - link.target is like "Sheet2!A1"
console.log("Navigate to:", link.target);
}
}}
/>;
// Default behavior (when onHyperlinkClick not provided):
// - External links open in new tab
// - Internal links scroll to target cell (and switch sheets if needed)Sub-components
For custom layouts, individual components are exported:
import {
TileCanvas,
ColumnHeaders,
RowHeaders,
SelectionOverlay,
HyperlinkOverlay,
LoadingSkeleton,
parseInternalLink,
} from "@witan/xlsx-view/react";Core API
XlsxClient
Framework-agnostic engine for managing spreadsheet state.
class XlsxClient extends EventEmitter<XlsxClientEvents> {
// Readonly state
readonly status: ConnectionStatus;
readonly metadata: ViewSheetsResponse | null;
readonly activeSheet: string | null;
readonly selection: Selection | null;
readonly dpr: number;
constructor(options: XlsxClientOptions);
// Lifecycle
connect(): void;
dispose(): void;
// Navigation
setActiveSheet(sheetName: string): void;
setViewport(viewport: Viewport): void;
// Selection
setSelection(selection: Selection | null): void;
// Cell data (instant cached access from prefetched visible region)
getCell(row: number, col: number): CellData | undefined;
hasCell(row: number, col: number): boolean;
// Coordinate conversion
pixelToCell(x: number, y: number): CellPosition | null;
getCellBounds(row: number, col: number): CellBounds | null;
getSheetDimensions(): { width: number; height: number };
getPositions(): SheetPositions | null;
getActiveSheetMetadata(): SheetMetadata | null;
// Tiles
getTilesForViewport(viewport: Viewport): Map<string, TileState>;
}Options
interface XlsxClientOptions {
apiOrigin: string; // API server origin
fileId: string; // File identifier
revisionId: string; // Revision identifier
getAccessToken: () => Promise<string>; // Token refresh function
maxCacheSize?: number; // Tile cache size (default: 200)
maxConcurrent?: number; // Concurrent requests (default: 6)
}Events
type XlsxClientEvents = {
connectionChange: { status: ConnectionStatus; error?: Error };
metadataLoaded: { metadata: ViewSheetsResponse };
sheetChange: { sheet: string };
selectionChange: { selection: Selection | null };
tileLoaded: { key: string };
cellsLoaded: { range: RangeBounds };
error: { error: Error };
};
// Subscribe to events
const unsubscribe = client.on("tileLoaded", ({ key }) => {
console.log("Tile loaded:", key);
});
// Unsubscribe
unsubscribe();Utilities
import {
computeSheetPositions, // Compute position arrays from sheet metadata
getTilePosition, // Get pixel position of a tile
getTileSize, // Get pixel size of a tile
tileKey, // Generate cache key for a tile
parseTileKey, // Parse cache key back to coordinates
} from "@witan/xlsx-view";Styling
Theme the viewer using CSS custom properties:
.xlsx-view {
/* Headers */
--xv-header-bg: #f5f5f5;
--xv-header-bg-selected: #e9e9eb;
--xv-header-text: #666666;
--xv-header-border: #e0e0e0;
--xv-col-header-height: 24px;
--xv-row-header-width: 28px;
/* Selection */
--xv-selection-border: #166534;
--xv-selection-fill: rgba(22, 101, 52, 0.08);
--xv-selection-width: 2px;
/* Grid */
--xv-gridline-color: #e0e0e0;
--xv-cell-bg: #ffffff;
}
/* Custom theme example */
.my-dark-theme .xlsx-view {
--xv-header-bg: #2d2d2d;
--xv-header-text: #cccccc;
--xv-selection-border: #0066cc;
--xv-cell-bg: #1e1e1e;
}Including Default Styles
The package exports default CSS as a string:
import { xlsxViewStyles, loadingSkeletonStyles } from "@witan/xlsx-view/react";
// Option 1: Inject into document
const style = document.createElement("style");
style.textContent = xlsxViewStyles + loadingSkeletonStyles;
document.head.appendChild(style);
// Option 2: Import in CSS bundler (if supported)
// Add to your CSS: @import '@witan/xlsx-view/styles.css';Types
Core Types
type ConnectionStatus =
| "disconnected"
| "connecting"
| "connected"
| "reconnecting"
| "error";
interface Viewport {
scrollX: number;
scrollY: number;
width: number;
height: number;
}
interface Selection {
cell: CellPosition; // Anchor cell
range: RangeBounds | null; // Multi-cell range (null for single cell)
}
interface CellPosition {
row: number; // 0-indexed
col: number; // 0-indexed
}
interface CellBounds {
x: number;
y: number;
width: number;
height: number;
}Tile Types
type TileStatus = "pending" | "loading" | "loaded" | "error";
interface TileState {
status: TileStatus;
bitmap?: ImageBitmap; // Only when loaded
width?: number; // CSS pixels
height?: number; // CSS pixels
error?: Error; // Only when error
}
interface TileCoord {
tileRow: number;
tileCol: number;
}Cell Data
interface CellData {
row: number;
col: number;
text: string;
formula?: string;
error?: string;
richText?: RichTextRun[];
style?: CellStyle;
link?: {
type: "internal" | "external";
target: string;
tooltip?: string;
};
note?: {
author: string;
text: string;
};
thread?: {
resolved: boolean;
comments: {
authorId: string;
text: string;
createdAt: string;
}[];
};
}
interface HyperlinkInfo {
type: "internal" | "external";
target: string;
tooltip?: string;
cell: CellPosition;
}Sheet Metadata
interface ViewSheetsResponse {
sheets: SheetMetadata[];
activeSheetIndex: number;
defaultStyle: CellStyle;
persons?: Record<string, { name: string }>;
tileRows: number; // Rows per tile
tileCols: number; // Columns per tile
}
interface SheetMetadata {
name: string;
hidden: boolean;
usedRange?: RangeBounds;
defaultRowHeight: number;
defaultColWidth: number;
rowHeights: Record<string, number>;
colWidths: Record<string, number>;
hiddenRows: number[];
hiddenCols: number[];
merges: MergeRange[];
frozenRows: number;
frozenCols: number;
showGridLines: boolean;
tileRowCount: number;
tileColCount: number;
tileRows: number; // Rows per tile
tileCols: number; // Columns per tile
}Constants
import {
DEFAULT_COL_WIDTH, // 64px
DEFAULT_ROW_HEIGHT, // 15px
} from "@witan/xlsx-view";Architecture
The SDK uses a tile-based rendering approach with the Witan API:
┌─────────────────┐ WebSocket ┌─────────────────┐
│ Your App │ ←───────────────────────→ │ Witan API │
│ │ │ │
│ @witan/xlsx- │ - Upload XLSX files │ - Parses │
│ view SDK │ - Request tile images │ spreadsheet │
│ │ - Receive PNG/WebP tiles │ - Resolves & │
│ - Composites │ - Get cell metadata │ normalizes │
│ tiles │ │ styles │
│ - Handles UI │ │ - Renders │
│ │ │ image tiles │
└─────────────────┘ └─────────────────┘- Witan API renders cells as image tiles (50 rows x 26 columns each)
- SDK requests tiles for visible viewport via WebSocket, prioritized by distance from center
- Tiles are cached as
ImageBitmapobjects for efficient compositing - Headers are rendered client-side on separate canvases
- Selection is drawn on an overlay canvas above tiles
Caching & Immutability
The SDK operates on fileId/revisionId pairs. Revisions are immutable — when a file changes, a new revisionId is created. This enables aggressive caching:
- Tiles for a given revision never change, so they can be cached indefinitely
- Multiple users viewing the same revision benefit from shared CDN cache
- Return visits to a previously-viewed revision load instantly from cache
- No cache invalidation logic needed — just cache forever
Benefits:
- Perfect visual fidelity (fonts, colors, borders match Excel exactly)
- Lower client complexity (no font measurement, text wrapping, or cell rendering)
- Smaller bundle size (no heavy grid library dependency)
- Efficient caching and memory management
Browser Support
- Chrome/Edge 80+
- Firefox 75+
- Safari 14.1+
Requires:
ImageBitmapAPI- WebSocket support
- CSS custom properties
License
MIT
