hyper-xl 參考文檔
在網頁上提供與 Excel 完全一致的網格 UX 的 React 庫。在一個頁面中涵蓋所有已發佈的功能。
簡介
hyper-xl 是一個 React 網格組件。它原樣復現了 Excel 的鍵盤 / 鼠標 / 剪貼板 /
填充 / 排序·篩選 / 縮放 / 批註 / 保護行爲。數據由使用方擁有,
該庫會檢測用戶手勢並通過回調以帶類型的負載再次傳回。
即使在 10,000+ 行下也經過虛擬化以保持 60fps,以 ESM 單一包發佈,設計令牌通過
--xl-react-* CSS 自定義屬性暴露。
主要特性
- 雙軸虛擬滾動:在 10k 行 × 30 列下保持 60fps
- 與 Excel TSV 雙向兼容的剪貼板(外部 Excel ↔ 網格直接複製粘貼)
- Excel 等價鍵盤映射 + 右鍵上下文菜單
- 多列排序,按列值篩選(複選框面板)
- 10% ~ 400% 工作表縮放,Ctrl+滾輪 + 右下角小部件
- 按單元格的字體 / 對齊 / 填充 / 邊框可視格式 + 編輯 toolbar
- 單元格級 read-only 保護(阻止編輯 / 粘貼 / 填充 / 刪除)
- 選區實時 SUM / AVG / COUNT 狀態欄
安裝
已發佈到 npm 註冊表。包含預構建的捆綁包, 無需額外構建步驟即可直接使用。
npm i hyper-xl
# 或
pnpm add hyper-xl
# 或
yarn add hyper-xl
對等依賴
react^18 || ^19react-dom^18 || ^19-
exceljs^4.4.0:可選 (peerDependenciesMeta.exceljs.optional: true)。 僅在調用exportToXlsx/importFromXlsx/exportMultiSheetXlsx/ImportDialog時才需要安裝。如果只使用 CSV / TSV 或只使用網格,則 無需安裝。ExcelJS 通過await import('exceljs')延遲加載,因此不會包含在主 捆綁包中,根入口('hyper-xl')的類型聲明 文件也不會暴露 ExcelJS 類型。如果需要底層輔助函數 (buildWorkbookFromSnapshot/parseWorkbookToSnapshot/cellFormatToExcelStyle/cellFormatFromExcelStyle),請 從'hyper-xl/exceljs'子路徑 import。該 路徑會暴露 ExcelJS 類型,因此必須已安裝 ExcelJS。
快速開始
最小的網格:只需傳入列定義和行數據即可。
import { XlReact, type Column, type Row } from 'hyper-xl';
import 'hyper-xl/styles.css';
import 'hyper-xl/themes/light.css';
const columns: Column[] = [
{ id: 'name', accessor: (r) => r.data.name },
{ id: 'qty', accessor: (r) => r.data.qty, dataType: 'number' },
];
const rows: Row[] = [
{ id: 1, data: { name: 'Container A', qty: 12 } },
{ id: 2, data: { name: 'Container B', qty: 8 } },
];
export function Page() {
return <XlReact columns={columns} rows={rows} />;
}
可編輯的受控網格示例
只要接上 onCellChange,網格就會進入編輯模式。使用方需將變更反映到自身
狀態(useState / Redux / Zustand 等)中。
import { useState } from 'react';
import { XlReact, type Column, type Row, type CellChange } from 'hyper-xl';
const columns: Column[] = [
{ id: 'name', accessor: (r) => r.data.name },
{ id: 'qty', accessor: (r) => r.data.qty, dataType: 'number' },
];
export function EditableGrid() {
const [rows, setRows] = useState<Row[]>([
{ id: 1, data: { name: 'Container A', qty: 12 } },
{ id: 2, data: { name: 'Container B', qty: 8 } },
]);
const onCellChange = (change: CellChange) => {
setRows((prev) =>
prev.map((r, i) =>
i === change.coord.row
? { ...r, data: { ...r.data, [change.columnId]: change.nextValue } }
: r,
),
);
};
return <XlReact columns={columns} rows={rows} onCellChange={onCellChange} />;
}
用 useReducer 統一處理 onCellChange + onCellsClear
在實際應用中,將編輯 / 刪除 / 粘貼 / 填充全部彙集到同一個 reducer 中處理,可以與 undo 棧在語義上保持一致。
import { useReducer } from 'react';
import {
XlReact,
type Row,
type CellChange,
type CellsClearPayload,
} from 'hyper-xl';
type Action =
| { kind: 'set'; row: number; columnId: string; value: unknown }
| { kind: 'clear'; ranges: CellsClearPayload['ranges'] };
function reducer(state: Row[], action: Action): Row[] {
switch (action.kind) {
case 'set':
return state.map((r, i) =>
i === action.row
? { ...r, data: { ...r.data, [action.columnId]: action.value } }
: r,
);
case 'clear': {
const next = state.map((r) => ({ ...r, data: { ...r.data } }));
for (const range of action.ranges) {
const r0 = Math.min(range.start.row, range.end.row);
const r1 = Math.max(range.start.row, range.end.row);
// 列 ID 映射從 columns 定義中獲取(省略)
for (let r = r0; r <= r1; r++) {
// next[r].data[columnId] = undefined;
}
}
return next;
}
}
}
樣式 & 令牌
該庫僅以 standalone 文件的形式發佈 CSS。JS 入口不會以副作用方式 import CSS,因此即使與 Next.js 等 “no global CSS from node_modules” 打包器也不會 衝突。
| 路徑 | 必需 | 內容 |
|---|---|---|
hyper-xl/styles.css |
必需 |
--xl-react-* 令牌默認值 + 庫內部 BEM 類規則
|
hyper-xl/themes/light.css |
可選 | 淺色主題令牌覆蓋 |
CSS 令牌覆蓋示例
由於所有可視元素都由 --xl-react-* CSS 變量驅動,使用方無需改動捆綁包,
只需覆蓋單個令牌即可。
/* 將受保護單元格的條紋顏色改爲品牌色 */
.xl-react-grid {
--xl-react-readonly-stripe: rgba(255, 200, 0, 0.18);
--xl-react-selection-border: #ff4081;
--xl-react-selection-bg: rgba(255, 64, 129, 0.12);
}
XlReact 組件
XlReact 是該庫的核心網格組件。它以完全受控模式
運行,因此網格絕不會擁有行數據。傳入 columns 和
rows,並接上與所需功能對應的回調即可。
除網格外,工具欄·對話框等輔助組件(CellFormatToolbar,
CellMergeToolbar, ConditionalFormatToolbar,
FindReplaceDialog, ValidationDropdown,
ExportButton, ImportDialog, PivotBuilder,
PivotChart, SheetTabBar, PrintPreview,
FormulaBar 等)也都從 root 一併 export — 按需挑選使用即可。
<XlReact
columns={columns}
rows={rows}
rowHeight={28}
columnWidth={120}
onCellChange={(change) => applyEdit(change)}
onCellsClear={(payload) => clearRanges(payload.ranges)}
freezeFirstRow
showSelectionStats
/>
完整的 prop 列表見 XlReactProps 章節。
列 & 行
Column
-
idstring
穩定的標識符。排序 / 篩選 / 重排序的負載會攜帶此 id。
-
accessor(row: Row) => T
返回該列的單元格值。
-
dataType'text' | 'number'
決定默認編輯器的輸入過濾與有效性樣式。默認值
'text'。設爲'number'時,編輯過程中會拒絕非數字鍵輸入。 -
requiredboolean
當 accessor 返回
null/undefined/''/NaN時會應用 invalid 樣式。0/false爲 有效值。這只是視覺提示,不會阻止 commit。 -
validate(value: T, row: Row) => boolean | string
基於值的驗證。返回非空字符串時爲 invalid + 消息,
false則 爲無消息的 invalid。 -
cellRenderer / cellEditor(props) => ReactNode
自定義單元格渲染器 / 編輯器。編輯器會接收
onCommit/onCancel。 -
widthnumber
初始列寬(px)。會被用戶拖拽調整大小所覆蓋。
-
readOnlyboolean
將該列的所有單元格標記爲保護狀態。與網格的
cellProtectionprop 取並集。 -
validation{ listKey: string; strict?: boolean }
將列關聯到
validationLists中命名的列表,以 啓用下拉選擇。當strict爲true時,會將不在列表中的值以 invalid 樣式標記。詳情請參閱 數據驗證 (下拉) 章節。 -
autoCompleteboolean
§2.3:在編輯模式下,會將同一列已有值中以輸入值爲 prefix 的第一個候選 以內聯 ghost-text 形式提示。通過
Tab/→(當光標 位於末尾時)採納,並通過Esc僅關閉候選。忽略大小寫 (prefix 匹配):採納時使用原始大小寫。默認值false。
Row
-
idstring | number
穩定的標識符。刪除 / 重新排序的載荷會使用該 id。
-
dataRecord<string, unknown>
不透明的行數據。網格不會直接讀取,只會調用
Column.accessor。 -
heightnumber
初始行高(px)。
自定義單元格渲染器:進度條
const progress: Column<number> = {
id: 'progress',
accessor: (r) => r.data.progress as number,
dataType: 'number',
cellRenderer: ({ value }) => (
<div style={{ position: 'relative', height: '100%' }}>
<div
style={{
position: 'absolute',
inset: 0,
width: `${Math.max(0, Math.min(100, value))}%`,
background: 'rgba(11, 83, 148, 0.18)',
}}
/>
<span style={{ position: 'relative' }}>{value}%</span>
</div>
),
};
自定義編輯器:選擇框
const statusCol: Column<'todo' | 'doing' | 'done'> = {
id: 'status',
accessor: (r) => r.data.status as 'todo' | 'doing' | 'done',
cellEditor: ({ value, onCommit, onCancel }) => (
<select
autoFocus
defaultValue={value}
onBlur={(e) => onCommit(e.target.value as typeof value)}
onKeyDown={(e) => {
if (e.key === 'Escape') onCancel();
}}
>
<option value="todo">Todo</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
</select>
),
};
受控數據模型
網格絕不擁有行 / 列。它檢測用戶手勢並暴露帶類型的載荷,然後信任使用方會更新自己的狀態。你只需爲要使用的功能接入相應的回調即可。
| 手勢 | 回調 | 載荷 |
|---|---|---|
| 提交編輯 | onCellChange |
CellChange { coord, columnId, prevValue, nextValue }
|
| 刪除選區 | onCellsClear |
CellsClearPayload { ranges } |
| 選區變更 | onSelectionChange |
SelectionSnapshot { active, ranges } |
| 請求編輯 (F2 / dblclick) | onEditRequest |
CellCoord |
單元格選區
功能
- 點擊指定活動單元格。F2 / 雙擊進入編輯模式。
- Enter(↓) / Tab(→) / Shift+Enter(↑) / Shift+Tab(←) 移動。Esc 取消編輯。
- 拖拽選擇矩形範圍。Shift+點擊 / Shift+方向鍵擴展範圍。
- Ctrl+A 在數據區域 → 整個工作表之間切換。Shift+Ctrl+方向鍵擴展至數據末尾。
- Ctrl+點擊 / Ctrl+拖拽添加非連續範圍。
- 點擊行 / 列標題選擇整個軸。
API
-
onSelectionChange(snapshot: SelectionSnapshot) => void
每當用戶看到的選區(活動單元格或範圍列表)發生變化時調用。初次掛載時不會調用。
將選區狀態同步到外部
import { useState } from 'react';
import { XlReact, type SelectionSnapshot } from 'hyper-xl';
export function Page() {
const [sel, setSel] = useState<SelectionSnapshot | null>(null);
return (
<>
<XlReact columns={cols} rows={rows} onSelectionChange={setSel} />
<p>
活動:({sel?.active.row ?? '-'}, {sel?.active.col ?? '-'}) · 範圍:{' '}
{sel?.ranges.length ?? 0}
</p>
</>
);
}
相關類型
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
active: CellCoord;
ranges: ReadonlyArray<SelectionRange>;
}
編輯 & 驗證
編輯進入模式
- edit:F2 / 雙擊。初始 draft = 當前值,全選。
- overwrite:可打印字符鍵。draft = 鍵入的字符。
- clear:Backspace。draft = 空字符串。
Enter / Tab / focus loss 提交 → onCellChange。Esc 取消。
在編輯模式下 Alt+Enter 會在當前光標位置插入 \n 並
保持編輯狀態(§2.1)。IME 組字過程中不會應用換行。
若要在單元格顯示中以視覺方式保留換行,該單元格必須
設置了 align.wrap: true(pre-wrap)。
若列以 autoComplete: true 開啓,則會從同一列的值池中以前綴
匹配的候選項以 ghost-text 形式顯示,可通過 Tab 或 →(光標位於
末尾時)採用,通過 Esc 僅關閉候選項(§2.3)。
驗證
Column.required:對空值應用 invalid 樣式。不會阻止 commit。Column.validate(value, row):基於值的驗證。true/false/ 消息字符串。Column.dataType: 'number':默認編輯器會拒絕非數字按鍵輸入。
API
-
onCellChange(change: CellChange) => void
要啓用編輯必須提供。若沒有,網格將隱式爲只讀。
-
onCellsClear(payload: CellsClearPayload) => void
在非空選區上按下 Delete 鍵時調用。
-
onEditRequest(coord: CellCoord) => void
用於在 F2 / dblclick 時通知。與實際的 mutation 流水線相互獨立。
-
readOnlyboolean
即使已接入
onCellChange,也能強制阻止進入編輯的開關。
required + validate 組合示例
required 檢查空值,validate 檢查領域規則。
若兩者都 invalid,則以 union 方式應用。
const qty: Column<number> = {
id: 'qty',
accessor: (r) => r.data.qty as number,
dataType: 'number',
required: true,
validate: (value, row) => {
if (value < 0) return '數量必須大於等於 0';
const capacity = row.data.capacity as number;
if (value > capacity) return `超出容量(${capacity})`;
return true;
},
};
處理 CellChange 的 reducer
const onCellChange = (change: CellChange) => {
// change = { coord: { row, col }, columnId, prevValue, nextValue }
setRows((prev) =>
prev.map((r, i) =>
i === change.coord.row
? { ...r, data: { ...r.data, [change.columnId]: change.nextValue } }
: r,
),
);
};
剪貼板 (TSV)
動作
- Ctrl+C:將選區以 TSV 寫入 OS 剪貼板 + 顯示 marching ants 虛線 。
-
Ctrl+X:複製後通過
onCellsClear清空源單元格。 -
Ctrl+V:讀取剪貼板並解析 TSV 後按單元格逐個
觸發
onCellChange。單個單元格源會填滿目標範圍。 -
右鍵 ▸ 選擇性粘貼:
僅通過
onPasteSpecialRequest通知。對話框由宿主渲染。
API
- onCopy(payload: ClipboardCopyPayload) => void
- onCut(payload: ClipboardCopyPayload) => void
-
onPasteRequest(payload: PasteRequestPayload) => void
每次 Ctrl+V 都會發出的通知。它不會取代自動 paste — 只要不是
readOnly且已接上onCellChange, 自動 paste(onCellChange)仍會一併執行。 若要關閉自動 paste 並自行處理,請與readOnly搭配使用(見下方範例)。 - onPasteSpecialRequest(payload: PasteSpecialRequestPayload) => void
onCellChange 所攜帶的 nextValue 始終爲
string(剪貼板文本)。數字 / 日期列請在 reducer 內部進行 coerce。
數字列 paste 時進行 coerce
const onCellChange = (change: CellChange) => {
const col = columns.find((c) => c.id === change.columnId);
let next = change.nextValue;
if (col?.dataType === 'number' && typeof next === 'string') {
const trimmed = next.trim();
next = trimmed === '' ? null : Number(trimmed);
}
applyEdit({ ...change, nextValue: next });
};
通過 onPasteRequest 直接應用 paste (onCellChange 禁用)
<XlReact
columns={columns}
rows={rows}
readOnly /* 禁用自動 paste */
onPasteRequest={async (payload) => {
const text = await navigator.clipboard.readText();
const tsv = text.split('\n').map((line) => line.split('\t'));
applyTsvAt(payload.coord, tsv);
}}
/>
填充手柄 & 快捷鍵
功能
- 將活動單元格的填充手柄(右下角的小方塊)拖拽至相鄰單元格。
- 單個值 → 重複。兩個值作爲種子 → 線性序列 (例如:1,2 → 3,4,5)。
- 日期序列會按 step(日/月/年)自動檢測。
- 雙擊手柄 → 自動填充至左側列數據的末尾。
- Ctrl+D 從上向下填充。
- Ctrl+R 從左向右填充。
- Ctrl+Enter 將整個選區填充爲活動值。
所有被填充的單元格都會流經 onCellChange。受保護的單元格會被自動排除。
將填充與外部副作用綁定
onCellChange 在 fill / paste / typed-edit 中均以相同方式調用。
若需要區分,可將進入同一 reducer 的 burst 合併處理。
let scheduled = false;
const queue: CellChange[] = [];
const onCellChange = (change: CellChange) => {
queue.push(change);
if (!scheduled) {
scheduled = true;
queueMicrotask(() => {
flush(queue.splice(0));
scheduled = false;
});
}
};
撤銷 / 重做
動作
- Ctrl+Z 撤銷,Ctrl+Y / Ctrl+Shift+Z 重做。
- 單元格值變更(編輯、粘貼、填充、刪除、剪切)以
cell-edits命令記錄。行/列的插入·刪除·重排·排序等結構變更目前不在棧的範圍內(因爲需要使用方的行順序快照)。 - 逆操作會通過
onCellChange重放 → reducer 必須編寫爲使得(coord, prev)是 clear 的逆操作。 - 默認值:100 條目 / 8 MiB。規範保證最少 50 條目。
API
-
enableUndoboolean
當接入
onCellChange時默認爲true。在外部管理 歷史記錄時可設爲false以退出。 - undoMaxEntriesnumber
- undoMaxBytesnumber
使用外部 BoundedUndoStack (enableUndo=false)
import { BoundedUndoStack, type CellChange } from 'hyper-xl';
const undoStack = new BoundedUndoStack({ maxEntries: 200, maxBytes: 16 * 1024 * 1024 });
const onCellChange = (change: CellChange) => {
undoStack.push({ kind: 'edit', change });
applyEdit(change);
};
const onUndo = () => {
const entry = undoStack.pop();
if (entry?.kind === 'edit') {
applyEdit({
...entry.change,
prevValue: entry.change.nextValue,
nextValue: entry.change.prevValue,
});
}
};
行 / 列操作
調整大小
- 拖拽標題邊界調節寬度 / 高度。
- 雙擊列標題邊界 → 內容自適應(AutoFit)。雙擊行標題邊界 → 重置爲默認高度。
- 選中多個標題時批量應用。
- 最小值通過
minColumnWidth/minRowHeight控制。
插入 / 刪除 / 重新排序
每個手勢都會暴露帶類型的載荷。由於網格並不擁有 rows /
columns,因此由使用方直接 mutate 數組。
-
onRowsInsert(payload: RowsInsertPayload) => void
{ atIndex, position: 'above' | 'below', count }。未設置 prop 時菜單 項本身會被隱藏。 -
onRowsDelete(payload: RowsDeletePayload) => void
{ rowIds, rowIndices }:連續或多選。 - onColumnsInsert(payload: ColumnsInsertPayload) => void
- onColumnsDelete(payload: ColumnsDeletePayload) => void
-
onRowsReorder(payload: RowsReorderPayload) => void
{ rowIds, rowIndices, targetIndex }。targetIndex是移除 之後的目標位置:以splice(targetIndex, 0, ...moved)應用。 - onColumnsReorder(payload: ColumnsReorderPayload) => void
-
行 / 列隱藏內部狀態(非回調)
隱藏 / 重新顯示由網格內部的 visibility 狀態處理 (與凍結窗格一樣沒有使用方回調)。可通過右鍵菜單的「隱藏 / 取消隱藏」 或
Ctrl+9·Ctrl+0(隱藏)、Ctrl+Shift+9·Ctrl+Shift+0(重新顯示) 操作,座標映射由網格自行校正。相關 payload (RowsHidePayload等)僅供內部使用,不會 root export。
凍結窗格
-
freezeFirstRow / freezeFirstColboolean
等同於
freezeRowCount: 1/freezeColCount: 1。 -
freezeRowCount / freezeColCountnumber
凍結前 N 個行 / 列。會覆蓋
freezeFirstRow / freezeFirstCol。
將 onRowsInsert / onRowsDelete 映射到使用方 reducer
const onRowsInsert = (p: RowsInsertPayload) => {
setRows((prev) => {
const idx = p.position === 'above' ? p.atIndex : p.atIndex + 1;
const inserted = Array.from({ length: p.count }, (_, i) => ({
id: crypto.randomUUID(),
data: {},
}));
const next = prev.slice();
next.splice(idx, 0, ...inserted);
return next;
});
};
const onRowsDelete = (p: RowsDeletePayload) => {
const toDelete = new Set(p.rowIds);
setRows((prev) => prev.filter((r) => !toDelete.has(r.id)));
};
標題拖拽重新排序 (使用 rowsReorder.targetIndex)
const onRowsReorder = (p: RowsReorderPayload) => {
setRows((prev) => {
const moving = new Set(p.rowIndices);
const remaining = prev.filter((_, i) => !moving.has(i));
const moved = p.rowIndices.map((i) => prev[i]);
remaining.splice(p.targetIndex, 0, ...moved);
return remaining;
});
};
行層級結構 (Grouping)
數據模型
在 Row 上增加兩個可選字段以表示層級。兩者均爲可選項,因此既有的扁平
數據仍可照常工作。
-
Row.levelnumber (可選)
樹深度 (0 = 根節點)。通過相鄰的
level連續序列推斷父節點,因此通常 無需parentId,只要保持 pre-order 即可。 -
Row.parentIdRow['id'] | null (可選)
顯式的父節點 id。指定後會覆蓋推斷結果,未知的 id 將被忽略並回退到層級推斷。
受控模式
網格不持有 collapse 狀態。由消費方持有
collapsedIds: Set<RowId>,並用純函數輔助方法
computeRowOutline 推導出可見行 / 大綱數組後交給網格:
這與 sortState / filterState 採用相同的受控 prop 模式。
XlReact prop
-
rowOutlineReadonlyArray<RowOutlineCell | null | undefined>
長度必須與
rows.length相同。每一項均爲{ level, hasChildren, collapsed },null/undefined項會將對應行排除在縮進 / 控件 之外。 -
onRowOutlineToggle(rowIndex: number, next: 'collapse' | 'expand') => void
點擊 ▶ / ▼ 時被調用。若不用
useCallback固定其標識, 每次渲染都會導致首列單元格重新渲染。 -
rowOutlineIndentPxnumber (默認 16)
每一層的左側縮進寬度。父節點/葉子節點都以相同寬度預留展開/摺疊槽位, 以保持對齊。
純函數輔助方法
-
buildRowTree(rows)RowTree
一次性算出父/子/層級映射。重複的
id僅採用首次出現的 (之後忽略)。可用useMemo按rowsidentity 緩存。 -
computeRowOutline(rows, collapsedIds, tree?){ visibleRows: Row[]; outline: RowOutline }
以 O(N) 單次遍歷同時生成可見行與大綱元數據。請將結果直接傳給
<XlReact rows={visibleRows} rowOutline={outline} />。 -
toggleRowCollapse(collapsedIds, rowId)Set<RowId>
不修改
Set,而是返回新副本 (添加 id ↔ 移除 id)。 用於onRowOutlineToggle處理函數。 -
collapseAtLevels(rows, levels, tree?)Set<RowId>
收集所有具有指定深度子節點的行 id。適合用作初始
collapsedIds種子 (例如將全部摺疊至團隊級別)。 -
collectRowDescendantIds(rows, rowId, tree?)Set<RowId>
指定行的所有後代 id 的 Set。用於對子樹批量摺疊 / 展開。
類型
- RowOutlineCell{ level: number; hasChildren: boolean; collapsed: boolean }
- RowOutlineReadonlyArray<RowOutlineCell | null>
- RowTree{ childrenByParent; parentById; levelById; indexById }
- RowIdRow['id']
部門 → 團隊 → 員工 多級分組 (消費方 reducer)
import { useCallback, useMemo, useState } from 'react';
import {
XlReact,
computeRowOutline,
toggleRowCollapse,
collapseAtLevels,
type Row,
type RowId,
} from 'hyper-xl';
const source: Row[] = [
{ id: 'sales', data: { name: '銷售部' }, level: 0 },
{ id: 'kr', data: { name: '國內銷售' }, level: 1 },
{ id: 'kim', data: { name: '金銷售' }, level: 2 },
{ id: 'lee', data: { name: '李銷售' }, level: 2 },
{ id: 'global', data: { name: '海外銷售' }, level: 1 },
{ id: 'park', data: { name: '樸銷售' }, level: 2 },
];
export function Org() {
const [collapsed, setCollapsed] = useState<Set<RowId>>(
() => collapseAtLevels(source, [1]), // 啓動時摺疊至團隊級別。
);
const { visibleRows, outline } = useMemo(
() => computeRowOutline(source, collapsed),
[collapsed],
);
const onToggle = useCallback(
(rowIndex: number) => {
const row = visibleRows[rowIndex];
if (row) setCollapsed((prev) => toggleRowCollapse(prev, row.id));
},
[visibleRows],
);
return (
<XlReact
columns={[{ id: 'name', accessor: (r) => r.data.name }]}
rows={visibleRows}
rowOutline={outline}
onRowOutlineToggle={onToggle}
rowOutlineIndentPx={18}
/>
);
}
注意
- 展開/摺疊控件僅在首個數據列 (columnIndex 0) 中渲染。 它與合併/固定行可無衝突共存。
-
控件點擊會通過
stopPropagation阻止單元格激活 / 進入編輯。 -
虛擬化只關注
visibleRows的長度,因此無論 collapse 狀態如何,大型樹 也能保持性能。 -
源數組假定爲 pre-order (父 → 子順序)。否則請顯式指定
parentId。
排序 & 篩選
排序
- 在單列上點擊表頭會在
asc→desc→none之間循環。 - Shift+點擊可擴展爲多列排序:得到的
SortState是排序鍵的有序數組。 - 右鍵 ▸ 排序 ▸ 升序 / 降序 對應
onSortAscending/onSortDescending。 - 右鍵 ▸ 排序 ▸ 自定義 對應
onSortCustomRequest。 - 網格不會對行重新排序。由消費方響應 callback 進行排序。
篩選
- 各列表頭的漏斗按鈕 → 唯一值複選框下拉框。
- 狀態是 sparse 的:當某列的所有選項都被選中時,該列會從
FilterState中移除。 - 必須傳入
filterPanelRows(篩選前的源數據) 才能得到 Excel-correct 的唯一值列表。 - 右鍵 ▸ 篩選 ▸ 按所選值篩選 / 清除篩選 分別對應各自獨立的回調。
API
-
sortState / onSortStateChange
SortState | (next: SortState) => void
受控排序。傳入
onSortStateChange即可激活點擊排序的表頭箭頭。sortState用於顯示當前排序狀態和計算下一個狀態(非激活條件)。 -
filterState / onFilterStateChange
FilterState | (next: FilterState) => void
受控篩選。傳入
onFilterStateChange即可激活表頭篩選按鈕。filterState用於顯示當前篩選狀態和計算下一個狀態(非激活條件)。 -
filterPanelRowsReadonlyArray<Row>
用於篩選下拉框的 unfiltered 源數據 (可選)。
在消費方一側應用排序
import { useMemo, useState } from 'react';
import { XlReact, type SortState } from 'hyper-xl';
const [sortState, setSortState] = useState<SortState>([]);
const sortedRows = useMemo(() => {
if (sortState.length === 0) return rows;
return [...rows].sort((a, b) => {
for (const key of sortState) {
const col = columns.find((c) => c.id === key.columnId);
if (!col) continue;
const av = col.accessor(a);
const bv = col.accessor(b);
if (av === bv) continue;
const dir = key.direction === 'asc' ? 1 : -1;
return (av > bv ? 1 : -1) * dir;
}
return 0;
});
}, [rows, sortState]);
return (
<XlReact
columns={columns}
rows={sortedRows}
sortState={sortState}
onSortStateChange={setSortState}
/>
);
用 FilterState 收窄行
import { useMemo, useState } from 'react';
import { XlReact, valueToFilterKey, type FilterState } from 'hyper-xl';
const [filterState, setFilterState] = useState<FilterState>({});
const filteredRows = useMemo(() => {
const filters = Object.entries(filterState);
if (filters.length === 0) return rows;
return rows.filter((r) =>
filters.every(([columnId, { selectedValues }]) => {
const col = columns.find((c) => c.id === columnId);
if (!col) return true;
return selectedValues.has(valueToFilterKey(col.accessor(r)));
}),
);
}, [rows, filterState]);
<XlReact
columns={columns}
rows={filteredRows}
filterState={filterState}
onFilterStateChange={setFilterState}
filterPanelRows={rows}
/>
排序 / 篩選相關類型
type SortDirection = 'asc' | 'desc';
interface SortColumnEntry { columnId: string; direction: SortDirection; }
type SortState = ReadonlyArray<SortColumnEntry>;
interface ColumnValueFilter { selectedValues: ReadonlySet<string>; }
type FilterState = Readonly<Record<string, ColumnValueFilter>>;
const BLANK_FILTER_KEY = '__xl_react_blank__';
虛擬化 & 性能
特性
- 行 / 列虛擬化始終開啓。無需 opt-in。
- 單元格組件通過
React.memo複用。 - 滾動通過
requestAnimationFrame進行 coalesce。 - 大規模 paste / fill(10k+) 通過
processInChunks輔助方法分塊。 - Undo 棧以條目數 + 字節數爲上限 (撤銷)。
API
-
overscannumber
在視口外預先渲染的行 / 列數量。
- rowHeightnumber
- columnWidthnumber
用 processInChunks 異步應用 1 萬行
import { processInChunks } from 'hyper-xl';
await processInChunks(
largePasteCells,
(cell) => applyEdit(cell),
{ chunkSize: 500 },
);
鍵盤 & 上下文菜單
鍵盤映射
| 按鍵 | 操作 |
|---|---|
← ↑ → ↓ | 移動一格 |
Tab / Shift+Tab | 右 / 左 |
Enter / Shift+Enter | 下 / 上 |
Home | 行的首個單元格 |
Ctrl+Home | A1 |
End → 方向鍵 | 跳到數據末尾 |
Ctrl + 方向鍵 | 數據區域末端 |
Page Up / Down | 按屏上/下 |
Alt+Page Up / Down | 按屏左/右 |
F2 | 編輯活動單元格 |
Esc | 取消編輯 (若顯示 AutoComplete 候選項,則先僅關閉候選項) |
Alt+Enter | 在編輯模式單元格內部換行 (§2.1) |
Tab / → | 在編輯模式下采納 AutoComplete 候選項 (→ 僅在光標位於末尾時生效,§2.3) |
Delete | 刪除值 |
Ctrl+1 | onCellFormatRequest |
Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z | 撤銷 / 重做 / 重做 |
Ctrl+C / X / V | 複製 / 剪切 / 粘貼 |
Ctrl+Shift+C / V | 複製格式 / 粘貼格式:Format Painter (§7.3) |
Ctrl+D / R / Enter | 填充 (向下 / 向右 / 選區) |
Ctrl+F / Ctrl+H | 查找 / 替換對話框 (§9) |
Ctrl+G / F5 | 定位單元格對話框 |
Ctrl+Space / Shift+Space | 選擇活動列 / 活動行 |
Ctrl+9 / Ctrl+0 | 隱藏選中的行 / 列 |
Ctrl+Shift+9 / Ctrl+Shift+0 | 重新顯示選區周圍隱藏的行 / 列 |
Ctrl+; / Ctrl+Shift+; | 輸入今天日期 / 當前時間 (onCellChange 路徑) |
右鍵上下文菜單
菜單項會根據右鍵目標 (行 / 列 / 單元格) 而有所不同。每個菜單項不會直接 mutate 網格, 而是暴露類型化的回調。接線回調後菜單項即被激活,未設置時菜單項本身會被隱藏。
- 剪切 / 複製 / 粘貼 / 選擇性粘貼…
- 在行 / 列的上·下·左·右插入
- 刪除行 / 列
- 排序 ▸ 升序 / 降序 / 自定義…
- 篩選 ▸ 按所選值篩選 / 清除篩選
- 凍結窗格 (以活動單元格爲基準,內部狀態)
- 單元格格式… (
onCellFormatRequest) - 插入批註… / 超鏈接… (
onInsertNoteRequest/onInsertHyperlinkRequest)
查找 & 替換
特性
- 網格內置:無需額外接線即可工作。聚焦於表格後用快捷鍵打開。
- 選項:區分大小寫 · 全部匹配(整個單元格內容) · 正則表達式(支持
$1反向引用)。 - 範圍:整個工作表 / 選區。選中 2 個以上單元格時,範圍默認值爲選區。
- 查找全部:顯示匹配單元格列表(行優先),點擊即可跳轉·滾動到對應單元格。
-
替換 / 全部替換通過現有
onCellChange路徑處理。會合併爲 1 次撤銷操作, 數字列與編輯一樣會對值進行轉換,受保護的單元格(單元格保護)會 被跳過並通過onProtectedAction({ action: 'replace' })通知。 -
搜索對象是原始值的字符串(而非應用了數字格式的顯示字符串):以便替換後的
值能原樣通過
onCellChange回傳。
快捷鍵
| 鍵 | 動作 |
|---|---|
Ctrl+F / Cmd+F | 搜索對話框 |
Ctrl+H (Windows/Linux) | 替換對話框 |
⌥⌘F (Cmd+Opt+F, macOS) | 替換:與 macOS 的 Cmd+H(隱藏窗口)衝突,故使用替代快捷鍵 |
Enter / Shift+Enter | 查找下一個 / 上一個 |
Esc | 關閉 |
禁用
可通過 enableFindReplace={false} 關閉內置對話框。關閉後網格不會攔截
Ctrl+F,瀏覽器默認搜索即可工作,並且會 export 純函數輔助工具(findMatches / replaceInValue)和
FindReplaceDialog 組件,以便自行接入搜索 UI。
API
-
enableFindReplaceboolean
啓用內置搜索 / 替換。默認值
true。 -
findMatches(args: FindMatchesArgs) => FindMatch[]
行優先的匹配列表。無效的正則表達式會返回
InvalidRegexError。 -
replaceInValue(value, query, replacement, options) => string | null
對單個單元格值的替換結果(未匹配時爲
null)。
用純引擎直接搜索 / 替換
import { findMatches, replaceInValue } from 'hyper-xl';
const matches = findMatches({
rows,
columns,
query: 'box',
options: { matchCase: false, wholeCell: false, useRegex: false },
scope: 'sheet',
});
// matches[0] === { coord, columnId, value }
const next = replaceInValue('Box 12', 'box', 'Crate', {
matchCase: false,
wholeCell: false,
useRegex: false,
});
// next === 'Crate 12'
工作表縮放
特性
- 縮放是線性的:行高、列寬、gutter、表頭 lane、字體大小全部一同縮放。
- 採用尺寸相乘而非 CSS transform:虛擬化座標與字體渲染保持像素對齊。
- 右下角小部件:
−/+/ 滑塊 / 百分比按鈕(點擊時重置爲 100%)。 Ctrl + 휠以光標周圍爲中心進行放大 / 縮小。- 受控(
zoom+onZoomChange)/ 非受控(defaultZoom)均支持。
API
-
zoom / defaultZoomnumber
1.0爲 100%。範圍0.1~4.0。 -
onZoomChange(zoom: number) => void
受控 / 非受控兩端都會觸發。
-
showZoomControlboolean
是否顯示右下角小部件。默認
true。 - zoomMin / zoomMaxnumber
受控 zoom + 外部切換
import { useState } from 'react';
const [zoom, setZoom] = useState(1);
return (
<>
<button onClick={() => setZoom(1)}>100%</button>
<button onClick={() => setZoom(2)}>200%</button>
<XlReact
columns={columns}
rows={rows}
zoom={zoom}
onZoomChange={setZoom}
zoomMin={0.5}
zoomMax={3}
/>
</>
);
視圖模式(分割 / 新窗口 / 全屏)
特性
showGridlines·showHeaders這兩個 boolean prop 會在網格根節點上附加 modifier 類,通過 CSS 即時生效。showHeaders={false}會用visibility: hidden隱藏列表頭 lane 與 row gutter。爲保持虛擬化座標 / hit-test 算式不變,佈局空間會被保留。SplitPaneView用 CSS 網格繪製 1 / 2(左右·上下)/ 4 面板佈局,並同步成對面板之間的滾動。- 滾動同步採用"期望座標標記"方式:即使同一幀內兩對面板同時滾動,也不會阻礙彼此的同步(反饋循環防護)。
useFullscreen(ref)封裝了 Fullscreen API 與 vendor-prefix 回退。若組件在持有 fullscreen 鎖的情況下卸載,會自動調用exitFullscreen()。useWorkbookBroadcast<T>(channelName)是通過 BroadcastChannel 在同源窗口之間進行的消息同步。自身不會收到自己的消息,在不支持的環境中會安全地降級爲 no-op。openSheetInNewWindow()是用於將當前 URL 在新窗口中打開、以便在同一 broadcast 頻道中開啓第二個監聽者的輔助工具。SplitPaneView爲@experimental:未來將以網格的顯式scrollToAPI 取代對 DOM 類名的耦合。
API
-
showGridlinesboolean
默認
true。爲false時會將單元格右側·底部邊框顏色透明化,以畫布形態顯示。表頭·選區·凍結分割線·用戶自定義單元格邊框保持不變。 -
showHeadersboolean
默認
true。爲false時列表頭 lane 與 row gutter 在視覺上消失。佈局空間會被保留,若宿主希望在整個區域填充數據,可在外緣用clip-path/overflow做 crop 處理。 -
SplitPaneView{ mode, renderPane, rowSplit?, colSplit?, ariaLabel?, className? }
mode='none'|'horizontal'|'vertical'|'quad'。renderPane(paneId)爲每個面板(tl/tr/bl/br)返回XlReact(或任意內容)。成對面板之間的滾動會自動同步。 -
useFullscreen(ref){ isFullscreen, isSupported, request, exit, toggle }
對
ref所指向的元素調用 Fullscreen API。會自動訂閱fullscreenchange事件,因此用戶用 ESC 退出時也會同步。 -
useWorkbookBroadcast<T>(channelName){ latest, broadcast, isSupported }
向同源的其他標籤頁收發任意 payload。注意:payload 會以結構化克隆在主線程上序列化,因此若每次按鍵都發送 10 萬行的工作簿,UI 會卡死。大型工作簿請以 diff 或命令式消息發送。
-
openSheetInNewWindow(options?)Window | null
window.open封裝。默認 target 爲'xl-react-new-window'(可複用),默認 features 爲寬/高 1100×700。被彈窗攔截時返回null。
2 分割 + 新窗口同步
import { useEffect } from 'react';
import {
XlReact,
SplitPaneView,
useWorkbookBroadcast,
openSheetInNewWindow,
type Row,
} from 'hyper-xl';
function WorkbookView({ columns, rows, setRows }) {
const sync = useWorkbookBroadcast<{ rows: Row[] }>('workbook/main');
useEffect(() => { sync.broadcast({ rows }); }, [rows]);
useEffect(() => {
if (sync.latest) setRows(sync.latest.rows);
}, [sync.latest]);
return (
<>
<button onClick={() => openSheetInNewWindow()}>打開新窗口</button>
<SplitPaneView
mode="horizontal"
renderPane={() => <XlReact columns={columns} rows={rows} />}
/>
</>
);
}
全屏切換
import { useRef } from 'react';
import { XlReact, useFullscreen } from 'hyper-xl';
function FullscreenGrid({ columns, rows }) {
const ref = useRef<HTMLDivElement>(null);
const fs = useFullscreen(ref);
return (
<div ref={ref}>
{fs.isSupported && (
<button onClick={() => fs.toggle()}>
{fs.isFullscreen ? '關閉全屏' : '全屏'}
</button>
)}
<XlReact columns={columns} rows={rows} />
</div>
);
}
打印(預覽 / 分頁 / 頁眉·頁腳)
特性
- 4 層分離。純函數
paginate()引擎(與 DOM 無關)、resolvePlaceholders()頁眉/頁腳語法、usePrintController狀態鉤子、PrintPreview預覽模態框。各層可獨立使用。 - 貪心式頁面打包。將列從左→右填充,超出寬度則換新的列帶,在每個列帶內將行從上→下填充。頁面順序爲 Excel 默認的"先向下,再向右"(Down, then Over)。
- 佔位符語法(Excel 兼容)。
&P頁碼、&N總頁數、&D日期、&T時間、&F文件名、&A工作表名。&&爲字面量&。未知的&X會原樣通過(樣式標記由宿主解釋)。 - 3 區域頁眉/頁腳。左 / 中 / 右三個區域。每個區域獨立解釋佔位符。若全部爲空,頁面上/下帶本身會消失,正文區域隨之擴展。
- 打印區域(printArea)。若用
SelectionRange指定,則該矩形之外會從分頁中完全排除。爲null時爲整個網格。 - 行/列重複(repeat)。在每頁頂部/左側重複輸出的表頭帶。會自動從索引中排除,使其不被納入正文,從而防止重複輸出。
- 縮放(scale)。鉗制在 0.1 ~ 4.0。以 transform-scale 縮小內容,因此紙張尺寸不變,但一頁能容納更多內容。
- 分頁符預覽疊加層。
PageBreakOverlay接收paginate()的rowPageBreaks/colPageBreaks,以絕對定位在實時網格上繪製虛線(穿透指針事件)。 - 實際打印。
startPrint()在<body>上切換xl-react-printing類並調用window.print()。@media print規則會隱藏預覽以外的所有元素,併爲每個.xl-react-print-page應用 page-break-after。afterprint事件 / 4 秒超時中先到的一方會自動移除該類(含防止重複調用的防護)。 - 無障礙。模態框具備
role="dialog"+aria-label、自動聚焦,可通過 ESC / 點擊背景關閉。點擊頁面卡片時,模態框頁腳中的"當前頁"會刷新。
API
-
paginate(input)PaginationResult
純函數。接收行/列數量 · 維度 · 選項 · 頁眉/頁腳帶像素,返回頁面數組、頁面邊界索引、紙張/可用區域尺寸。不依賴 React,因此可在 PDF 導出等其他渲染器中原樣複用。
-
resolvePrintOptions(options?)ResolvedPrintOptions
爲稀疏(sparse)選項對象填充模塊默認值,將 scale 鉗制在 [0.1, 4.0],並對範圍進行歸一化。供預覽與控制器鉤子共享。
-
resolvePlaceholders(template, ctx)string
展開單個區域文本中的
&X標記。ctx爲{ pageNumber, totalPages, date?, filename?, sheetName?, formatDate?, formatTime? }。默認日期格式爲 ISO-8601,時間爲HH:MM;可通過Intl.DateTimeFormat等自由替換。 -
usePrintController({ initial? })PrintController
{ options, resolved, isOpen, update, setPrintArea, setRepeatRows, setRepeatCols, open, close, reset }。將選項作爲消費者狀態管理的薄封裝。update 始終爲 sparse 補丁。 -
PrintPreview{ open, onClose, rows, columns, rowHeights?, colWidths?, defaultRowHeight?, defaultColWidth?, options, onOptionsChange?, formatValue?, onPrint?, labels?, rowLabel?, columnLabel?, headerBandPx?, footerBandPx? }
預覽模態框。左側設置側邊欄(紙張·方向·縮放·邊距·頁眉/頁腳·重複行/列·打印區域·網格線/表頭打印切換)+ 右側頁面卡片滾動器。未指定
formatValue時,會將column.accessor(row)轉爲字符串進行渲染:與<XlReact>相同的默認行爲。 -
PageBreakOverlay{ pagination, rowHeights?, colWidths?, defaultRowHeight, defaultColWidth, offsetTop?, offsetLeft?, className? }
在實時網格上繪製虛線分頁符的疊加層。在
position: relative容器內以pointer-events: none浮起,因此不會阻斷選取/滾動。 -
startPrint(onAfter?)void
無需預覽也可調用的入口點。切換 body 類 →
window.print()→afterprint觸發或 4 秒安全計時器:兩者中先到的一方移除類 + 調用onAfter(防止重複調用)。 -
PAPER_SIZES_MM · DEFAULT_MARGINS_MM · MM_TO_PX · DEFAULT_PRINT_OPTIONS · DEFAULT_PRINT_PREVIEW_LABELSconst
面向高級用戶的常量。紙張預設、默認邊距(19mm 均等)、96dpi 換算常量、選項默認值、韓語標籤預設。
預覽 + 控制器鉤子 + 直接打印
import { useState } from 'react';
import {
XlReact,
PrintPreview,
usePrintController,
startPrint,
} from 'hyper-xl';
function Workbook({ columns, rows }) {
const print = usePrintController({
initial: {
paperSize: 'A4',
orientation: 'portrait',
header: { center: '月度出庫明細表' },
footer: { right: '&D &T &P / &N' },
repeatRows: { start: 0, end: 0 }, // 第 1 行(表頭)每頁重複
filename: 'shipments.xlsx',
sheetName: 'Sheet1',
},
});
return (
<>
<button onClick={print.open}>打印預覽</button>
<button onClick={() => startPrint()}>立即打印</button>
<XlReact columns={columns} rows={rows} />
<PrintPreview
open={print.isOpen}
onClose={print.close}
rows={rows}
columns={columns}
options={print.options}
onOptionsChange={print.update}
/>
</>
);
}
分頁符預覽疊加層
import { useMemo } from 'react';
import { XlReact, PageBreakOverlay, paginate } from 'hyper-xl';
function GridWithPageBreaks({ columns, rows, options }) {
const pagination = useMemo(
() => paginate({
rowCount: rows.length,
colCount: columns.length,
defaultRowHeight: 24,
defaultColWidth: 100,
options,
}),
[rows.length, columns.length, options],
);
return (
<div style={{ position: 'relative' }}>
<XlReact columns={columns} rows={rows} />
<PageBreakOverlay
pagination={pagination}
defaultRowHeight={24}
defaultColWidth={100}
/>
</div>
);
}
直接解釋頁眉·頁腳佔位符(PDF / 服務端渲染複用)
import { resolvePlaceholders, paginate } from 'hyper-xl';
const pagination = paginate({
rowCount: 500,
colCount: 12,
defaultRowHeight: 24,
defaultColWidth: 100,
options: { paperSize: 'A4', orientation: 'landscape' },
});
pagination.pages.forEach((page) => {
const footer = resolvePlaceholders('&F · &P / &N', {
pageNumber: page.pageIndex + 1,
totalPages: pagination.pages.length,
filename: 'export.xlsx',
});
// → "export.xlsx · 1 / 4"
});
單元格格式(font / alignment / fill / border)+ 編輯 UI
特性
cellFormats以映射或函數注入。網格不擁有格式狀態。CellFormatToolbar是庫提供的 UI,它接收選區和可寫的CellFormatsMap,並通過onCellFormatsChange返回新的 map。- 將字體 family / size / bold / italic / underline / strikethrough / color 以內聯樣式應用。
- 按單元格渲染橫向·縱向對齊、換行、縮進、背景色、上/下/左/右邊框。
- 橫向對齊支持
'left'/'center'/'right'/'justify'(兩端對齊)/'distributed'(均勻分佈:最後一行也均勻對齊)。縱向支持'top'/'middle'/'bottom'/'distributed'(stretch)。 - 邊框中
Box、Top、Right、Bottom、Left僅應用於選區外緣矩形,只有All纔會應用到範圍內部所有單元格的邊界。 - map 鍵爲當前視圖的 0-based 座標。自行處理行/列插入·刪除·重排的應用需在同一時刻對 map 也進行 shift/prune。
- 函數 resolver 適合以 O(1) 渲染 id-keyed 狀態,但默認 toolbar 不會直接更新 resolver。
- 正在編輯的單元格會暫時抑制格式樣式,以免在編輯器疊加層背後看到原始值。
numberFormat字段將單元格值轉換爲顯示字符串(參見數字格式)。可通過CellFormatToolbar的數字格式下拉菜單和小數位數增減按鈕編輯,且僅對沒有cellRenderer的單元格在渲染時點應用。- 格式刷:用 Ctrl+Shift+C 將活動單元格的格式保存到易失的內存緩衝區,用 Ctrl+Shift+V 應用到當前選區(保留值 / 公式,§7.3)。緩衝區爲空時粘貼無動作;若從無格式的單元格複製後粘貼,目標單元格的格式會被清除(Excel 行爲)。在
CellFormatToolbar上接入onFormatPainterToggle+formatPainterArmedprops,即可暴露相同行爲的 toolbar 按鈕。若cellFormats爲函數 resolver 形式,則 painter 爲靜默 no-op,瀏覽器默認快捷鍵照常工作。
顯示與編輯
import { useState } from 'react';
import {
CellFormatToolbar,
XlReact,
cellFormatKey,
type CellFormatsMap,
type SelectionSnapshot,
} from 'hyper-xl';
const initialFormats: CellFormatsMap = {
[cellFormatKey(0, 0)]: {
font: { bold: true, color: '#ffffff' },
align: { horizontal: 'center', vertical: 'middle' },
fill: { backgroundColor: '#1f2937' },
},
[cellFormatKey(1, 2)]: {
align: { horizontal: 'right' },
border: { bottom: { style: 'double', color: '#0f172a' } },
},
};
function Sheet() {
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const [cellFormats, setCellFormats] = useState<CellFormatsMap>(initialFormats);
return (
<>
<CellFormatToolbar
selection={selection}
cellFormats={cellFormats}
onCellFormatsChange={setCellFormats}
/>
<XlReact
columns={columns}
rows={rows}
cellFormats={cellFormats}
onSelectionChange={setSelection}
/>
</>
);
}
// 函數形式:每個可見單元格都會調用,因此請保持 O(1)。
// toolbar 編輯 UI 需與可寫的 CellFormatsMap 一起使用。
<XlReact
columns={columns}
rows={rows}
cellFormats={(rowIndex, columnIndex) =>
rowIndex === 0 ? { font: { bold: true } } : undefined
}
/>
API
-
cellFormatsCellFormatResolver | CellFormatsMap
鍵爲 0-based 的
${row}:${col}。使用輔助工具cellFormatKey可將字符串鍵的生成集中化。 -
CellFormatToolbarcomponent
接收
selection、cellFormats、onCellFormatsChange,將字體·對齊·填充·邊框的變更應用到選區。cellFormats必須爲CellFormatsMap。 -
applyCellFormatPatch(formats, ranges, patch) => CellFormatsMap
用於從 toolbar 等外部 UI 向選區應用 nullable patch 的純 工具函數。
-
applyCellBorderPatch(formats, ranges, placement, side) => CellFormatsMap
outline/top/right/bottom/left僅應用於選區外緣線。all會應用到範圍內部 gridline,但爲避免相鄰單元格重複 繪製同一條線,由其中一側單元格擁有該 edge。
表頭 / 數字 / 狀態 / 合計行格式 + toolbar
const initialFormats = useMemo(() => {
const map: CellFormatsMap = {};
columns.forEach((_, c) => {
map[cellFormatKey(0, c)] = {
font: { bold: true, color: '#fff' },
align: { horizontal: 'center', vertical: 'middle' },
fill: { backgroundColor: '#1f2937' },
};
map[cellFormatKey(totalRowIndex, c)] = {
font: { bold: true },
fill: { backgroundColor: '#fef9c3' },
border: { top: { style: 'thick', color: '#ca8a04' } },
};
});
map[cellFormatKey(2, 3)] = {
align: { horizontal: 'center' },
fill: { backgroundColor: '#fee2e2' },
font: { color: '#991b1b', strikethrough: true },
};
return map;
}, [columns]);
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const [formats, setFormats] = useState<CellFormatsMap>(initialFormats);
<CellFormatToolbar
selection={selection}
cellFormats={formats}
onCellFormatsChange={setFormats}
/>
<XlReact
columns={columns}
rows={rows}
cellFormats={formats}
onSelectionChange={setSelection}
/>
數字格式 (Number Format)
特性
formatCellValue(value, format, locale?)是不依賴 UI 的純函數。可在沒有網格的情況下單獨使用。- 僅當指定了
cellFormat.numberFormat且該列沒有cellRenderer時,纔在渲染時應用。cellRenderer始終優先。 - 編輯過程中始終顯示原始值。格式轉換僅用於顯示,所保存的值不會改變。
CellFormatToolbar內置了數字格式下拉框(常規·數字·貨幣·會計·百分比·指數·分數·日期·時間·文本)以及增加/減少小數位按鈕,可對選區應用格式代碼。可通過numberFormatsprop 替換列表。- 支持常規(General) / 整數 / 小數 / 千分位 / 貨幣(₩·$) / 會計 / 百分比 / 科學計數 / 分數 / 日期·時間格式。
- 可解析 4 段自定義代碼
양수;음수;0;텍스트、強制補 0(00.0)、單位後綴(0"톤")、顏色令牌([Red])、數字佔位符0/#/?。 - 確定性契約:分組分隔符(
,)和小數點(.)與區域設置無關,固定爲 ASCII。區域設置僅影響基於Intl.DateTimeFormat(UTC) 的月份·星期名稱。 - 日期可同時接受
Date對象、Excel 序列號(以 1899-12-30 爲基準) 和 ISO 字符串。 - 無法解析爲數字的值會原樣通過文本段(
@)。
顯示與使用
import {
XlReact,
cellFormatKey,
formatCellValue,
NUMBER_FORMAT_PRESETS,
increaseDecimals,
decreaseDecimals,
type CellFormatsMap,
} from 'hyper-xl';
// 1) 作爲純函數單獨使用
formatCellValue(1500000, '₩ #,##0'); // "₩ 1,500,000"
formatCellValue(0.3625, '0.0%'); // "36.3%"
formatCellValue('2026-05-22', 'YYYY년 MM월 DD일'); // "2026년 05월 22일"
formatCellValue(-98000, '#,##0;[Red](#,##0)'); // "(98,000)" (負數段)
// 2) 應用到網格單元格:在 cellFormats 中指定 numberFormat
const cellFormats: CellFormatsMap = {
[cellFormatKey(1, 1)]: { numberFormat: NUMBER_FORMAT_PRESETS.CURRENCY_KRW },
[cellFormatKey(1, 2)]: { numberFormat: '0.0%' },
};
<XlReact columns={columns} rows={rows} cellFormats={cellFormats} />
// 3) 增減小數位輔助函數(接收格式代碼並返回格式代碼)
increaseDecimals('#,##0'); // "#,##0.0"
decreaseDecimals('#,##0.00'); // "#,##0.0"
API
-
formatCellValue(value, format?, locale?) => string
將值按格式代碼轉換爲顯示字符串的純函數。若沒有
format則使用常規(General) 格式,locale默認值爲'en-US'。 -
NUMBER_FORMAT_PRESETSRecord<NumberFormatPreset, string>
常用格式代碼常量的集合。
GENERAL、INTEGER、NUMBER、THOUSANDS、CURRENCY_KRW、CURRENCY_USD、ACCOUNTING_KRW、PERCENT、SCIENTIFIC、FRACTION、DATE_ISO、DATE_KO、TIME_HM、DATETIME、TEXT等。 -
increaseDecimals / decreaseDecimals(format) => string
將格式代碼的小數位增加或減少一位,返回新的格式代碼。這是與 Excel 的 “增加/減少小數位” 按鈕對應的 API 級輔助函數。
-
adjustFormatDecimals(format, delta) => string
按
delta增減小數位的底層輔助函數。increaseDecimals/decreaseDecimals對其進行封裝。 -
CellFormatToolbar · numberFormats{ label, value }[]
替換工具欄數字格式下拉框的預設列表。
value是格式 代碼,空字符串('') 映射爲常規(General),會清除單元格的numberFormat。省略時使用默認的韓語預設列表。
單元格樣式 / 主題預設 (Cell Styles)
特性
- 單元格樣式是帶名稱的
CellFormat捆綁。應用後會扁平化爲網格已經繪製的cellFormats條目,因此沒有新的網格狀態或渲染路徑(與單元格格式·條件格式相同的消費者受控模型)。 - 內置預設 15 種:標題·表頭 / 好·差·一般 / 輸入·輸出·計算 / 警告·備註 / 小計·合計 / 強調 1~3(主題色)。遵循 Excel 默認主題色。通過
BUILTIN_CELL_STYLES(映射) /BUILTIN_CELL_STYLE_LIST(保序數組) 暴露。 createCellStyleRegistry(custom?)在內置預設之上疊加用戶樣式(相同id時用戶優先)。註冊表是不可變(frozen) 的:defineCellStyle/removeCellStyle返回新註冊表,可直接放入 React 狀態(保存 / 複用)。applyCellStyle的應用方式:'replace'(默認:將單元格格式替換爲樣式,Excel 行爲) ·'merge'(按 facet 覆蓋:僅添加/覆蓋,不刪除) ·undefined·空格式(清除爲標準)。replace 對每個單元格使用深拷貝副本,因此之後修改樣式定義也不會改變已繪製的單元格。- 應用時會複製樣式的格式,因此網格不會保留單元格↔樣式鏈接。所以與 Excel 不同,修改樣式定義不會自動更新已應用的單元格。要傳播變更請重新應用。
buildTableStyleFormats(P3):一次性應用表頭行 · 條紋正文 · 合計行來生成表格樣式。CellStyleToolbar是接收selection+cellFormats的受控畫廊下拉框(色板預覽)。可通過向CellFormatToolbar傳入cellStyleRegistryprop 來整合同一個下拉框(與合併·條件格式相同的 fold-in 模式)。
用法
import { useState } from 'react';
import {
XlReact,
CellFormatToolbar,
createCellStyleRegistry,
applyNamedCellStyle,
type CellFormatsMap,
type SelectionSnapshot,
} from 'hyper-xl';
// 內置預設 + 已保存的用戶樣式("브랜드")。
const registry = createCellStyleRegistry([
{
id: 'brand',
label: '브랜드',
category: 'custom',
format: {
fill: { backgroundColor: '#1f3864' },
font: { bold: true, color: '#ffd966' },
align: { horizontal: 'center' },
},
},
]);
function Sheet() {
const [formats, setFormats] = useState<CellFormatsMap>({});
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
return (
<>
{/* 將 "셀 스타일 ▾" 畫廊整合到格式工具欄:點擊後更新 cellFormats。 */}
<CellFormatToolbar
selection={selection}
cellFormats={formats}
onCellFormatsChange={setFormats}
cellStyleRegistry={registry}
/>
<XlReact
columns={columns}
rows={rows}
cellFormats={formats}
onSelectionChange={setSelection}
/>
</>
);
}
// 或者不用工具欄,直接用純輔助函數應用:
// const next = applyNamedCellStyle(formats, selection.ranges, registry, 'total');
API
-
createCellStyleRegistry(custom?: NamedCellStyle[]) => CellStyleRegistry
創建在內置預設之上疊加用戶樣式的不可變(frozen) 註冊表。相同的
id會被用戶樣式覆蓋。BUILTIN_CELL_STYLE_REGISTRY是 僅包含預設的現成註冊表。 -
NamedCellStyle{ id; label?; category?; builtin?; format }
帶名稱的
CellFormat捆綁。id是註冊表鍵,format是要應用的格式。category用於畫廊分區分組。 -
defineCellStyle / removeCellStyle(registry, …) => CellStyleRegistry
返回添加·替換或移除樣式後的新註冊表(輸入不可變)。 移除未知 id 是返回相同引用的 no-op。
-
resolveCellStyle / getCellStyle / listCellStyles查詢輔助函數
分別返回 id →
CellFormat、id →NamedCellStyle、註冊表 → 插入順序數組(支持按 category / builtin 篩選)。 -
applyCellStyle(formats, ranges, format?, options?) => CellFormatsMap
將樣式
CellFormat合成到選區。options.mode爲'replace'(默認) /'merge'。若format爲undefined(或空格式) 則清除區域的格式(標準)。絕不 修改輸入映射。 -
applyNamedCellStyle(formats, ranges, registry, id, options?) => CellFormatsMap
從註冊表解析 id 並應用的快捷輔助函數。未知 id 是原樣(相同 引用)返回輸入映射的 no-op,因此錯誤的 id 不會清除格式。
-
buildTableStyleFormats(formats, range, options) => CellFormatsMap
一次性應用表頭 · 條紋正文(
band/bandAlt) · 合計行 來生成表格樣式的 P3 輔助函數。 -
CellStyleToolbarcomponent
Excel 風格的 “單元格樣式” 畫廊下拉框。接收
selection·cellFormats·onCellFormatsChange的受控 組件,通過registry·applyMode·labels進行自定義。也可通過向CellFormatToolbar傳入cellStyleRegistry來整合同一個畫廊。
條件格式 (Conditional Formatting)
特性
evaluateConditionalFormats(rules, rows, columns, options?)是不依賴網格狀態的純函數。用各列accessor返回的原始值進行比較,因此與數字顯示轉換(number format) 無關,且易於單元測試。- 返回值爲
{ formats, decorations }。formats是以"row:col"爲鍵的CellFormatsMap,可直接連接到cellFormatsprop。 - 支持的規則:值比較(大於等於/小於等於/介於)、前 N·後 N(個數或 %)、高於·低於平均值、重複·唯一、文本(包含/開頭/結尾)、日期(今天/昨天/過去 7 天/本·上週/本·上月)、色階(Color Scale)、數據條、圖標集。
- 色階將紅→黃→綠漸變還原爲
fill.backgroundColor(默認爲 Excel 的 min / 第 50 百分位 / max 三色)。 - 數據條和圖標集無法用
CellFormat表示,因此分離爲decorations。通過makeConditionalCellRenderer生成的cellRenderer繪製到單元格,因此網格核心不會被修改。 - 規則按數組順序應用優先級(索引 0 最高)。同一單元格被多條規則命中時,按 CSS 屬性逐項由靠前的規則勝出。若
stopIfTrue被匹配,該單元格不再應用後續規則。 - 前 N·後 N、平均值、色階等基於範圍的規則將目標列的全部單元格作爲一個池來計算(與 Excel 的 "適用範圍" 相同)。
- 日期規則可注入
options.now進行確定性求值(測試·SSR)。周起始日爲options.weekStartsOn(0=日, 1=月)。 makeConditionalCellRenderer因cellRenderer沒有索引,所以通過row.id/column.id反查單元格。請向求值器和渲染器傳入相同的 rows / columns 數組(順序·id 一致)。請用useMemo穩定返回的渲染器以及用它生成的列。若渲染器標識改變,列對象會重新生成,導致逐單元格 memo 失效,所有裝飾單元格都會重新渲染。- 與數字格式的組合:若單元格被設置了
cellRenderer,則會繞過網格的numberFormat顯示路徑。因此若要讓數據條 / 圖標旁的值也保留數字格式,請向makeConditionalCellRenderer通過options.cellFormats傳入與網格相同的cellFormats。這樣值會經formatCellValue轉換,與Cell.tsx顯示方式一致(例如顯示爲₩1,234,567而非1234567)。優先級順序爲options.baseRenderer→numberFormat→ 原始值。 - 求值器爲了範圍統計(百分位·前 N 等)會掃描目標列的全部行(而非僅可見區域)。在大型網格中請用
useMemo積極緩存,必要時預先縮小規則的適用範圍。
用法
import { useMemo } from 'react';
import {
XlReact,
cellFormatKey,
evaluateConditionalFormats,
makeConditionalCellRenderer,
type ConditionalRule,
} from 'hyper-xl';
const rows = [
{ id: 1, data: { item: '컨테이너 A', stock: 120, rate: 92, trend: 12 } },
{ id: 2, data: { item: '컨테이너 B', stock: 45, rate: 58, trend: -4 } },
{ id: 3, data: { item: '컨테이너 C', stock: 200, rate: 76, trend: 3 } },
];
// 求值器與網格必須共享相同的列數組(相同 id·順序)。
const columns = [
{ id: 'item', width: 150, accessor: (r) => r.data.item },
{ id: 'stock', width: 150, accessor: (r) => r.data.stock, dataType: 'number' },
{ id: 'rate', width: 110, accessor: (r) => r.data.rate, dataType: 'number' },
{ id: 'trend', width: 110, accessor: (r) => r.data.trend, dataType: 'number' },
];
const rules: ConditionalRule[] = [
// 色階:將處理率以紅→黃→綠表示。
{ type: 'colorScale', columns: ['rate'] },
// 值比較:處理率低於 60 時用加粗紅字(與色階填充一起)。
{ type: 'cellValue', columns: ['rate'], operator: 'lessThan', value: 60,
format: { font: { bold: true, color: '#b91c1c' } } },
// 數據條:將庫存量在單元格內以條形顯示。
{ type: 'dataBar', columns: ['stock'], color: '#3b82f6' },
// 圖標集:將趨勢分爲 ▼ ● ▲ 三個區間。
{ type: 'iconSet', columns: ['trend'], iconSet: 'triangles' },
];
function Sheet() {
// 純求值器:規則 + 行 + 列 → { formats, decorations }。
const result = useMemo(() => evaluateConditionalFormats(rules, rows, columns), []);
// 求值器格式 + 消費者的數字格式(庫存列加千分位 + 單位後綴)。
// 因爲觸及不同的單元格,所以用映射展開安全地合併。
const cellFormats = useMemo(() => ({
...result.formats,
...Object.fromEntries(
rows.map((_, r) => [cellFormatKey(r, 1), { numberFormat: '#,##0"개"' }]),
),
}), [result]);
// 數據條 / 圖標無法用 CellFormat 表示 → 用 cellRenderer 渲染。
// 一併傳入 cellFormats,條形旁的值也會遵循單元格的 numberFormat。
const render = useMemo(
() => makeConditionalCellRenderer(result, rows, columns, { cellFormats }),
[result, cellFormats],
);
const decorated = useMemo(
() =>
columns.map((c) =>
c.id === 'stock' || c.id === 'trend' ? { ...c, cellRenderer: render } : c,
),
[render],
);
// 合併後的 cellFormats 由網格和裝飾渲染器共享。
return <XlReact columns={decorated} rows={rows} cellFormats={cellFormats} />;
}
API
-
evaluateConditionalFormats(rules, rows, columns, options?) => { formats, decorations }
將規則列表還原爲逐單元格
CellFormat映射(formats) 和數據條 / 圖標裝飾映射(decorations) 的純函數。兩者都是 以"row:col"爲鍵的 sparse 映射。 -
ConditionalRuleunion
cellValue·topBottom·average·duplicate/unique·text·date·colorScale·dataBar·iconSet的判別聯合。 所有規則共享columns?(目標列 id,省略時爲全部) 和stopIfTrue?。 -
EvaluateOptions{ now?: Date; weekStartsOn?: 0 | 1 }
日期規則的基準時刻與周起始日。注入
now即可進行確定性 求值。 -
makeConditionalCellRenderer(result, rows, columns, options?) => cellRenderer
生成將求值器的數據條 / 圖標裝飾繪製到單元格的
cellRenderer。 沒有裝飾的單元格會以值顯示通過,因此應用到所有列也是安全的。 可用options.baseRenderer包裹現有渲染器,或 傳入options.cellFormats(與網格相同) 使值應用單元格的numberFormat。 -
ConditionalCellRendererOptions{ baseRenderer?; cellFormats? }
makeConditionalCellRenderer的選項。值顯示優先級爲baseRenderer→cellFormats的numberFormat→ 原始值,若存在baseRenderer則cellFormats被 忽略。 -
ConditionalDataBar / ConditionalIconcomponent
直接渲染裝飾時使用的表現型組件。可在自定義
cellRenderer中 組合。 -
resolveConditionalDecoration(decorations, rowIndex, columnIndex) => ConditionalDecoration | undefined
按索引查詢裝飾的輔助函數(與
resolveCellFormat相同的模式)。 -
ConditionalFormatToolbarcomponent
Excel 風格的 “條件格式” 下拉框。是與
CellMergeToolbar相同的 受控組件,接收selection·columns·rules·onRulesChange,針對選區的列 添加/刪除規則。菜單:突出顯示單元格(大於·小於·介於,輸入值) · 前/後 · 高於·低於平均值 · 重複·唯一 · 數據條·色階·圖標集 · 清除規則(選區 / 全部)。新規則添加到數組前部,具有最高優先級。也可通過向CellFormatToolbar傳入conditionalRules/onConditionalRulesChange/columns來整合到格式工具欄 (與合併控件相同的模式)。 -
規則構建輔助函數build* / clear* / selectionColumnIds
自行構建工具欄時使用的純輔助函數:
selectionColumnIds、buildDataBarRule·buildColorScaleRule·buildIconSetRule·buildCellValueRule·buildTopBottomRule·buildAverageRule·buildDuplicateRule、appendRule、clearRulesForColumns·clearAllRules、DEFAULT_HIGHLIGHT_FORMAT。
自定義單元格渲染器 (Custom Cell Renderer)
特性
- 顯示(Display) 與編輯(Edit) 分離:
cellRenderer僅用於屏幕顯示,cellEditor僅用於通過 F2 · 雙擊 · 輸入進入的編輯。兩者均可選,若沒有則回退到默認文本顯示 / 默認輸入編輯器(既有行爲不變)。 - 兩級指定:列級(
Column.cellRenderer·Column.cellEditor) 和單元格級(cellRenderersprop)。單元格級會覆蓋列級。 cellRenderers與cellFormats·cellAnnotations是相同的解析器模式:以"row:col"爲鍵的 sparse 映射,或(rowIndex, columnIndex) => CellRenderer | undefined函數。- 顯示渲染器 props:
{ value, row, column, rowIndex, columnIndex, isEditing }。但顯示渲染器在編輯期間不會被調用(編輯中的單元格會被清空,改由編輯器覆蓋層繪製),因此isEditing始終爲false—isEditing: true僅在編輯渲染器中傳遞。編輯渲染器在此基礎上增加{ onCommit, onCancel, mode, initialDraft }。 - 編輯器作爲組件掛載(自有 fiber):可用
useState等 Hook 自由管理草稿狀態。相反顯示渲染器是作爲純函數調用的,所以不要在頂層使用 Hook(若需要狀態請渲染子組件)。通過輸入進入時,mode='overwrite'·initialDraft中會帶入該按鍵。(網格會恢復編輯器區域的user-select,因此輸入·文本選擇能正常工作。) onCommit(next, nav?)會保留類型:輸入的值(數字 · 對象等)不經強制轉換地流入onCellChange。可用nav('enter' | 'tab' | 'shift-tab' | 'shift-enter' | 'none') 像內置編輯器一樣控制提交後的活動單元格移動。若next與當前值相同則不會發生提交。- 虛擬滾動兼容:單元格通過
React.memo比較,若cellRenderer標識相同則跳過重新渲染。請用模塊作用域或useMemo穩定渲染器。若每次渲染都傳入新函數,所有單元格都會重新繪製。解析器函數型也建議採用返回相同引用的模式。 - 外部點擊時取消:網格無法得知自定義編輯器的草稿,因此點擊其他單元格時會取消編輯(內置輸入在 blur 時保存)。若要像 Excel 那樣在點擊外部時也保存,請在編輯器的
onBlur中直接調用onCommit。該提交會先於取消處理。 - 只讀列(
readOnly) 即使有cellEditor編輯器也不會打開。設置了validation列表的列以列表選擇器優先。合併區域僅在錨點單元格渲染 · 編輯。
用法
import { useState } from 'react';
import {
XlReact,
type CellRendererProps,
type CellEditorProps,
type CellRenderers,
type Column,
} from 'hyper-xl';
const rows = [
{ id: 1, data: { task: '卸貨', progress: 65, status: '進行中' } },
{ id: 2, data: { task: '出港', progress: 100, status: '完成' } },
{ id: 3, data: { task: '待機', progress: 15, status: '延遲' } },
];
// 顯示渲染器:通過 props 接收值/行/列/索引/是否處於編輯中。
// 將進度顯示爲帶顏色的進度條:100%=綠色,30% 以下=紅色,其餘藍色。
function ProgressBar({ value }: CellRendererProps) {
const pct = Math.max(0, Math.min(100, Number(value) || 0));
const color = pct >= 100 ? '#16a34a' : pct < 30 ? '#dc2626' : '#2563eb';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, height: 6, background: '#e5e7eb', borderRadius: 3 }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 3 }} />
</div>
<span style={{ color: '#9ca3af', fontSize: 12 }}>{pct}%</span>
</div>
);
}
// 狀態徽章:將狀態字符串顯示爲帶顏色的標籤。
const BADGE: Record<string, string> = {
進行中: '#2563eb',
完成: '#16a34a',
延遲: '#dc2626',
};
function StatusBadge({ value }: CellRendererProps) {
const c = BADGE[String(value)] ?? '#6b7280';
return (
<span style={{ color: c, background: `${c}1a`, padding: '2px 8px', borderRadius: 10, fontSize: 12 }}>
{String(value)}
</span>
);
}
// 編輯渲染器:與顯示(進度條)分離的數字輸入編輯器(0~100)。
// 編輯器作爲組件掛載,因此可自由使用 useState 之類的鉤子。
// onCommit(next, nav?) 按原類型提交,onCancel 取消。
function ProgressEditor({ value, mode, initialDraft, onCommit, onCancel }: CellEditorProps) {
const seed = mode === 'overwrite' && initialDraft ? initialDraft : String(Number(value) || 0);
const [draft, setDraft] = useState(seed);
const commit = (nav?: 'enter') =>
onCommit(Math.max(0, Math.min(100, Math.round(Number(draft) || 0))), nav);
return (
<div
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) commit();
}}
>
<input
type="number"
min={0}
max={100}
value={draft}
autoFocus
onFocus={mode === 'edit' ? (e) => e.target.select() : undefined}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit('enter');
if (e.key === 'Escape') onCancel();
}}
style={{ width: 64 }}
/>
<span>%</span>
</div>
);
}
const columns: Column[] = [
{ id: 'task', width: 120, accessor: (r) => r.data.task },
{
id: 'progress',
width: 220,
accessor: (r) => r.data.progress,
// 列級渲染器:應用於該列的所有單元格。
cellRenderer: (p) => <ProgressBar {...p} />,
cellEditor: (p) => <ProgressEditor {...p} />,
},
{
id: 'status',
width: 120,
accessor: (r) => r.data.status,
cellRenderer: (p) => <StatusBadge {...p} />,
},
];
// 單元格級覆蓋:"row:col" 映射或 (rowIndex, columnIndex) => renderer 函數。
const cellRenderers: CellRenderers = {
'2:0': ({ value }) => <strong style={{ color: '#dc2626' }}>{String(value)}</strong>,
};
function Sheet() {
return (
<XlReact
columns={columns}
rows={rows}
cellRenderers={cellRenderers}
onCellChange={(c) => console.log(c.coord, c.nextValue)}
/>
);
}
API
-
cellRenderersCellRenderers
單元格級顯示渲染器。
"row:col"鍵的映射或(rowIndex, columnIndex) => CellRenderer | undefined函數。同一 單元格同時存在列渲染器和單元格渲染器時,單元格渲染器優先。 -
Column.cellRenderer / Column.cellEditorCellRenderer<T> / CellEditor<T>
列級顯示 · 編輯渲染器。
cellEditor繪製通過 F2 · 雙擊 · 輸入 進入的編輯 UI。 -
CellRendererProps<T>{ value; row; column; rowIndex; columnIndex; isEditing }
顯示渲染器接收的 props。
row·column以引用方式 傳遞原始對象,isEditing表示該單元格是否處於編輯中。 -
CellEditorProps<T>extends CellRendererProps<T> { onCommit; onCancel; mode?; initialDraft? }
編輯渲染器接收的 props。
onCommit(next, nav?)保留類型並 提交(值相同則忽略),onCancel()取消編輯。mode('edit' | 'overwrite' | 'clear')與initialDraft區分進入方式(F2 vs 輸入)。 -
CellRenderer<T> / CellEditor<T>(props) => ReactNode
分別接收
CellRendererProps·CellEditorProps並返回節點 的函數類型。 -
CellRenderersCellRenderersMap | CellRendererResolver
cellRenderersprop 的類型。CellRenderersMap是Record<"row:col", CellRenderer>,CellRendererResolver是(rowIndex, columnIndex) => CellRenderer | undefined。 -
CellEditCommitNav'enter' | 'shift-enter' | 'tab' | 'shift-tab' | 'none'
onCommit的第二個參數。指定提交後活動單元格的移動方向,與內置編輯器 一致(默認'none')。 -
cellRendererKey / resolveCellRenderer(rowIndex, columnIndex) => string · (cellRenderers, rowIndex, columnIndex) => CellRenderer | undefined
鍵構建器與解析器輔助函數(與
cellFormatKey·resolveCellFormat相同的模式)。映射 · 函數兩種形態均可處理。
單元格合併 (Merge / Unmerge / Merge & Center)
特性
merges是矩形範圍(SelectionRange)的數組。網格不擁有合併狀態,僅負責渲染。- 每個合併區域繪製爲左上角的單個錨點單元格,並橫向·縱向 span 整個區域。被遮蓋的單元格不會被渲染。
- 點擊合併區域內部時,整個區域被選中,active 單元格固定在錨點上。
- 方向鍵移動會跳過合併邊界,使光標不會被困在錨點中,而是移出區域之外。
- 即使錨點行滾動到屏幕之外,只要與虛擬化窗口重疊,span 就會繼續渲染。
- 合併 / 取消合併 / 合併並居中控件,只要將
merges/onMergesChange傳給CellFormatToolbar,就會集成到格式工具欄中。若想單獨使用,也照樣提供CellMergeToolbar。 - Merge & Center 在合併的同時對錨點應用水平居中對齊。直接使用傳給
CellFormatToolbar的cellFormats/onCellFormatsChange。 - 網格默認保留被遮蓋單元格的值。若想像 Excel 那樣僅保留左上角並清空其餘,可選用
onMergeClearCovered,由消費方直接清除收到的範圍(也可用coveredCellRanges輔助函數計算)。僅清空值,被遮蓋單元格的cellFormats保持原樣。 - 一次合併會依次調用多個回調(
onMergesChange→ 若居中則onCellFormatsChange→onMergeClearCovered)。使用撤銷功能的應用應將它們打包爲一個事務,以便一次性回退。 - 合併座標是當前視圖的 0-based。自行處理行/列插入·刪除·重排的應用,必須在同一時間點也對
merges數組進行 shift/prune。
顯示與編輯
import { useState } from 'react';
import {
CellFormatToolbar,
XlReact,
type CellFormatsMap,
type SelectionRange,
type SelectionSnapshot,
} from 'hyper-xl';
const columns = [
{ id: 'name', width: 160, accessor: (r) => r.data.name },
{ id: 'q1', width: 90, accessor: (r) => r.data.q1 },
{ id: 'q2', width: 90, accessor: (r) => r.data.q2 },
{ id: 'q3', width: 90, accessor: (r) => r.data.q3 },
];
function Sheet() {
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const [rows, setRows] = useState([
{ id: 0, data: { name: '2026 季度營收', q1: '', q2: '', q3: '' } },
{ id: 1, data: { name: '集裝箱 A', q1: 1500, q2: 1800, q3: 1650 } },
]);
const [merges, setMerges] = useState<SelectionRange[]>([
// 橫跨第一行整行的標題橫幅。
{ start: { row: 0, col: 0 }, end: { row: 0, col: 3 } },
]);
const [cellFormats, setCellFormats] = useState<CellFormatsMap>({
'0:0': { align: { horizontal: 'center' }, font: { bold: true } },
});
// 像 Excel 那樣合併時僅保留左上角值並清空被遮蓋的單元格(可選)。
// 庫默認保留數據,因此由消費方直接清除。
// (僅清空值。被遮蓋單元格的 cellFormats 保持原樣。)
const clearCovered = (ranges: SelectionRange[]) => {
setRows((prev) =>
prev.map((row, r) => {
const hit = ranges.some(
(g) => r >= g.start.row && r <= g.end.row,
);
if (!hit) return row;
const data = { ...row.data };
for (const g of ranges) {
if (r < g.start.row || r > g.end.row) continue;
for (let c = g.start.col; c <= g.end.col; c++) {
const id = columns[c]?.id;
if (id) data[id] = null;
}
}
return { ...row, data };
}),
);
};
return (
<>
{/* 傳入 merges / onMergesChange 後,合併控件會集成到格式工具欄中。 */}
<CellFormatToolbar
selection={selection}
cellFormats={cellFormats}
onCellFormatsChange={setCellFormats}
merges={merges}
onMergesChange={setMerges}
onMergeClearCovered={clearCovered}
/>
<XlReact
columns={columns}
rows={rows}
merges={merges}
cellFormats={cellFormats}
onSelectionChange={setSelection}
/>
</>
);
}
API
-
mergesReadonlyArray<SelectionRange>
消費方擁有的合併區域數組。每個範圍渲染爲左上角的單個錨點單元格, 其餘單元格被遮蓋。省略則不渲染合併圖層。
-
CellMergeToolbarcomponent (merges, onMergesChange, onMergeClearCovered)
傳入
merges/onMergesChange後,合併 / 取消合併 / 合併並居中控件會集成到格式工具欄中。onMergeClearCovered會返回 因合併而被遮蓋的單元格範圍,清空收到的範圍即可像 Excel 那樣僅保留左上角的值。 若想單獨使用合併,可使用具有相同 props 的CellMergeToolbar。 -
coveredCellRanges(range) => SelectionRange[]
從合併區域中扣除左上角錨點後,將被遮蓋的單元格以最多 2 個矩形返回的 純輔助函數。用於在自定義合併 UI 中直接計算清除範圍。
-
mergeSelection(merges, ranges) => SelectionRange[]
將選區範圍添加爲合併區域,並吸收重疊的現有合併的純工具函數。
-
unmergeSelection(merges, ranges) => SelectionRange[]
移除與選區範圍相交的合併區域。
-
normalizeMerges(merges) => SelectionRange[]
對範圍進行歸一化,並整理重複·單個單元格·重疊區域(先進入者優先)。
單元格批註 (read-only 工具提示)
特性
- 有批註的單元格在右上角顯示一個小三角形指示器。
- 懸停時顯示標準工具提示:進入/離開延遲,Esc 關閉。
- read-only 數據:網格不提供批註編輯 UI。
- 合併 / 拆分 / 刪除行·列時,由消費方從自身數據源中移除對應條目。
兩種形態
// 函數形態
<XlReact
cellAnnotations={(rowIndex, columnIndex) =>
rowIndex === 0 ? '表頭備註' : undefined
}
/>
// 映射形態 (`${row}:${col}` 鍵)
<XlReact cellAnnotations={{ '0:1': '試試懸停', '3:4': '重要' }} />
函數會對每個可見單元格在每次渲染時調用。請保持 O(1)。較重的數據源請用
useMemo materialize 爲映射形態。
API
- cellAnnotationsCellAnnotationResolver | CellAnnotationsMap
-
annotationShowDelayMsnumber
工具提示顯示延遲(默認
500)。 -
annotationHideDelayMsnumber
指針離開後的隱藏延遲(默認
100)。
從列元數據構建批註映射
import { useMemo } from 'react';
import { XlReact, cellAnnotationKey, type CellAnnotationsMap } from 'hyper-xl';
const annotations: CellAnnotationsMap = useMemo(() => {
const map: Record<string, string> = {};
rows.forEach((row, rIdx) => {
columns.forEach((col, cIdx) => {
const note = row.data[`${col.id}__note`] as string | undefined;
if (note) map[cellAnnotationKey(rIdx, cIdx)] = note;
});
});
return map;
}, [rows, columns]);
<XlReact columns={columns} rows={rows} cellAnnotations={annotations} />;
輔助函數:cellAnnotationKey / resolveCellAnnotation
import { cellAnnotationKey, resolveCellAnnotation } from 'hyper-xl';
cellAnnotationKey(0, 1); // '0:1'
resolveCellAnnotation(map, 0, 1); // 'Hover me' | undefined
單元格保護 (read-only)
覆蓋範圍
保護基於位置(行 / 列索引),並與 Column.readOnly 取 union。受保護的
單元格會全部阻止以下操作:
edit:F2、雙擊、輸入覆蓋、Backspace 初始化clear:對非空選區按 Deletepaste:Ctrl+V、native paste、右鍵粘貼fill:填充手柄、Ctrl+D、Ctrl+R、Ctrl+Entercut:Ctrl+X 的 clear-halfmove:Shift+拖拽移動中源或目標包含受保護的單元格rowDelete/columnDelete:刪除包含受保護單元格的行 / 列
消費方通過 onCellChange 重新發回的公式重新計算不會被阻止:保護僅攔截用戶意圖。多單元格手勢(paste / fill)只
應用於未受保護的部分,onProtectedAction 回調會報告被跳過的單元格。
API
-
cellProtection(rowIndex, columnIndex) => boolean
對要拒絕用戶 mutation 的單元格返回
true。 -
onProtectedAction(info: ProtectedActionInfo) => void
{ action, coords }:至少有 1 個單元格被跳過時觸發。
受保護的單元格以細微的條紋背景顯示。顏色可通過
--xl-react-readonly-stripe 覆蓋。
表頭行保護 + toast 消息
<XlReact
columns={columns}
rows={rows}
cellProtection={(rowIndex) => rowIndex === 0}
onProtectedAction={(info) => {
toast(`此單元格已受保護 (${info.action} · ${info.coords.length}個)`);
}}
/>
ProtectedAction 類型
type CellProtectionPredicate = (rowIndex: number, columnIndex: number) => boolean;
type ProtectedAction =
| 'edit' | 'clear' | 'paste' | 'fill' | 'cut' | 'move'
| 'replace' | 'rowDelete' | 'columnDelete';
interface ProtectedActionInfo {
action: ProtectedAction;
coords: ReadonlyArray<CellCoord>;
}
選區聚合
行爲
- 僅當選區覆蓋 2 個以上單元格時才渲染。
- SUM / AVG 忽略非數字單元格。
- AVG 分母排除空單元格。
- COUNT 與 Excel
COUNTA相同(非空單元格)。 - MIN / MAX 已預先計算並暴露:可用於自定義 readout。
API
- showSelectionStatsboolean
-
selectionStatsLocalestring | string[]
BCP-47 區域設置(例如
'ko-KR')。
用 computeAggregates 構建自定義狀態欄
import { useMemo, useState } from 'react';
import {
XlReact,
computeAggregates,
type SelectionSnapshot,
} from 'hyper-xl';
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const aggregates = useMemo(
() => computeAggregates(selection?.ranges ?? [], rows, columns),
[selection, rows, columns],
);
<XlReact
columns={columns}
rows={rows}
showSelectionStats={false}
onSelectionChange={setSelection}
/>
<footer>
SUM={aggregates.sum?.toLocaleString('ko-KR')} ·
AVG={aggregates.avg?.toFixed(2)} ·
COUNT={aggregates.count}
</footer>
數據驗證 (下拉框)
特性
-
用
validationLists定義命名列表,並通過Column.validation.listKey關聯到列。列表數據歸消費方 所有,網格只讀取。 -
條目同時支持字符串(
'活躍')或對象({ value, label }): 對象在下拉框中顯示label,但在單元格中存儲value(例如代碼)。 - 活動列表單元格會顯示 ▾ 箭頭,當條目超過 8 個時會自動出現搜索框 (label · value 部分匹配,忽略大小寫)。
-
選擇值後單元格選區仍保持不變(與 Excel 一致:不會自動移動)。commit 通過
onCellChange流轉,且僅在值發生變化時才記錄到撤銷棧。 -
當
validation.strict爲true時,不在列表中的非空值會以 invalid 樣式顯示。空值則歸required管轄。 -
在受保護的單元格(
Column.readOnly/cellProtection)中,光標 會被隱藏,嘗試打開時會觸發onProtectedAction。
打開下拉框
- 活動列表單元格的 ▾ 光標點擊
- Alt + ↓
- F2 或雙擊:如果是列表列,則打開下拉框而非文本編輯器。
- 打開後:↑ / ↓ 移動(到末尾停止) · Enter 選擇 · Esc 或點擊外部關閉。
API
-
validationListsRecord<string, ValidationList>
listKey→ 列表。列表爲字符串或{ value, label }項的數組。 -
Column.validation{ listKey: string; strict?: boolean }
將列關聯到列表。
strict會將列表外的值標記爲 invalid。
將狀態 / 始發港列設爲下拉框
const columns = [
{ id: 'name', accessor: (r) => r.data.name },
{
id: 'status',
accessor: (r) => r.data.status,
validation: { listKey: 'statusList', strict: true },
},
{
id: 'port',
accessor: (r) => r.data.port,
validation: { listKey: 'portList' }, // 對象列表
},
];
<XlReact
columns={columns}
rows={rows}
onCellChange={applyChange}
validationLists={{
statusList: ['活動', '審覈', '掛起', '停產'],
// 下拉框中存儲 label,單元格中存儲 value(代碼)
portList: [
{ value: 'KRPUS', label: '釜山港 (KRPUS)' },
{ value: 'JPTYO', label: '東京港 (JPTYO)' },
],
}}
/>
用列表輔助函數直接處理驗證 / 篩選
import {
resolveColumnList,
filterOptions,
isValueInList,
} from 'hyper-xl';
// 列 + validationLists → ResolvedValidationOption[] | null
// (如果不是列表單元格則爲 null)
const options = resolveColumnList(column, validationLists);
// 與搜索框相同的部分匹配篩選 (label · value,忽略大小寫)
const matches = filterOptions(options ?? [], 'kr');
// 與 strict 驗證相同的規則 (空值始終通過)
const ok = isValueInList('KRPUS', options ?? []);
公式引擎 (Formula Engine: 四則運算 + 單元格引用)
特性
-
純函數分詞器 → 解析器 → 求值器 +
FormulaSheet輔助函數。 網格僅用於顯示,當使用方通過onCellChange將 raw 輸入傳遞給工作表後,工作表會沿依賴圖刷新結果。 -
支持的語法:整數 / 小數、一元符號(
-A1)、 四則運算(+ − * /)、括號、A1 形式的相對/絕對單元格引用(含多字符列 :AA1、AB10、$A$1、$A1、A$1)。 -
自動填充(拖拽·Ctrl+D·Ctrl+R·Ctrl+Enter)會按移動量自動平移單元格引用的相對部分,
而帶
$的絕對部分則保持不變。 這與 Excel 的行爲一致。庫通過shiftFormulaRefs將相同的變換暴露出來,使外部也能調用。 -
錯誤代碼:
#DIV/0!(除以 0)、#CIRCULAR!(循環 引用)、#REF!(無效引用)、#VALUE!(非數值 操作數)、#NAME?(不支持的標識符或函數:計劃在 P2 中擴展)。 -
循環檢測通過 Kahn 拓撲排序處理。環中的所有單元格都會顯示爲
#CIRCULAR!,斷開環後即可恢復爲正常值。 由於不使用遞歸,即使是數千級的依賴鏈也不會使調用棧溢出。 -
空單元格在算術運算中視爲
0,數字字符串會自動轉換("5"→ 5)。標籤字符串會以#VALUE!傳播。 -
範圍(
A1:B5)和工作表函數(如SUM)不在本階段 範圍內,輸入時會以#NAME?拒絕。
API
-
FormulaSheetclass
通過
setRaw(coord, raw)將 raw 值存入單元格, 通過getRaw(coord)/getDisplay(coord)讀取 raw·顯示值。setRaw會返回受變更影響的座標數組。 -
parseFormula(input: string) => FormulaAst | FormulaParseError
無論是否帶有前導
=都能工作。$A$1這樣的 絕對引用是支持的。函數調用、範圍等不支持的輸入會以FormulaParseError('#NAME?'/'#REF!')拒絕。 -
evaluateAst(ast, resolveRef) => number | FormulaErrorCode
通過
resolveRef(coord)獲取依賴單元格的值進行求值。錯誤代碼 會傳遞性地傳播。 -
extractRefs(ast) => { row, col }[]
列出 AST 所引用的單元格座標:用於構建依賴圖。
-
a1ToCoord / coordToA1A1 ↔ {row,col} 轉換
'B3'↔{ row: 2, col: 1 }。同時允許多字符列與$絕對標記(a1ToCoord會 忽略標記,僅返回座標)。可通過coordToA1(row, col, { rowAbsolute, colAbsolute })帶上$輸出。 -
parseA1(ref: string) => ParsedCellRef | null
將 A1 引用分解爲
{ row, col, rowAbsolute, colAbsolute }: 在需要保留$標記時使用。 -
shiftFormulaRefs(formula, deltaRow, deltaCol) => string
將公式字符串中的相對單元格引用按
(deltaRow, deltaCol)平移,而帶$的絕對引用保持不變。自動填充 時庫內部使用,外部也可調用相同的變換。 -
isFormulaError(value) => value is FormulaErrorCode
5 個 Excel 錯誤字面量(
FORMULA_ERROR_CODES)的守衛。
將FormulaSheet連接到onCellChange
import { useMemo, useRef, useState } from 'react';
import { FormulaSheet, XlReact } from 'hyper-xl';
// 由使用方擁有。工作表保存 raw + 求值結果 + 依賴圖。
const sheetRef = useRef<FormulaSheet | null>(null);
if (!sheetRef.current) {
const s = new FormulaSheet();
s.setRaw({ row: 1, col: 1 }, 12); // B2
s.setRaw({ row: 1, col: 2 }, 1500); // C2
s.setRaw({ row: 1, col: 3 }, '=B2*C2'); // D2 → 18000
sheetRef.current = s;
}
const [rows, setRows] = useState(buildRowsFromSheet(sheetRef.current));
const columns = useMemo(() => (
// 單元格顯示用工作表的計算值,編輯用 raw 文本:雙擊會看到 =B2*C2。
cols.map((c, i) => ({
...c,
cellRenderer: ({ rowIndex, columnIndex }) =>
String(sheetRef.current?.getDisplay({ row: rowIndex, col: columnIndex }) ?? ''),
}))
), []);
return (
<XlReact
columns={columns}
rows={rows}
onCellChange={(change) => {
sheetRef.current?.setRaw(change.coord, change.nextValue as string | number | null);
setRows(buildRowsFromSheet(sheetRef.current!));
}}
/>
);
僅單獨使用純函數:無需 UI 即可調用求值器
import { parseFormula, evaluateAst } from 'hyper-xl';
const ast = parseFormula('=A1+B1*2');
if ('type' in ast && ast.type === 'error') {
// ast.code === '#NAME?' 等
} else {
const values = new Map([['0:0', 5], ['0:1', 7]]);
const result = evaluateAst(ast, ({ row, col }) =>
values.get(`${row}:${col}`) ?? null,
);
// result === 19
}
$絕對引用 + 自動填充時的相對引用平移
import { shiftFormulaRefs } from 'hyper-xl';
// 將 =B3*C3*(1+$B$2) 向下填充一格時,絕對引用固定,
// 相對引用按行 +1 平移。
shiftFormulaRefs('=B3*C3*(1+$B$2)', 1, 0);
// → '=B4*C4*(1+$B$2)'
shiftFormulaRefs('=$A$1+B1', 5, 2);
// → '=$A$1+D6'
// 自動填充引擎(projectFill / fillDownWithinSelection 等)內部
// 使用相同的函數。使用方無需另行調用,
// 但在需要任意變換時可加以利用。
公式輸入欄 (Formula Bar)
特性
-
這是一個受控組件。從外部注入
activeRef(A1 字符串) ·value(raw 公式或字面量),並通過onCommit(next)接收 用戶確認的值,反映到工作表模型(如FormulaSheet.setRaw)。 -
內部 draft 字符串保存在組件內部,因此每次按鍵不會導致父組件
重新渲染。當
valueprop 發生變化時(外部 paste·undo 等),僅在非編輯狀態下才覆蓋 draft。 - Enter / ✓ / 失焦 → commit,Esc / ✗ → cancel。 與 Excel 一致,失焦也按 confirm 處理。
-
中日韓 IME 組合守衛:
compositionstart↔compositionend之間的 Enter 不會按 commit 處理,並同時檢查nativeEvent.isComposing·keyCode === 229。 -
名稱框(name box)僅在連接
onNavigate時纔可編輯, 且僅對有效的 A1 引用(如'C5'、'$B$2')傳遞(row, col, ref)。無效輸入會 回彈爲activeRef。 -
readOnly會同時鎖定公式輸入框與名稱框,阻止進入編輯與 commit,但組件照常渲染(聚焦的 read-only input 按 Enter 也不會 commit),disabled則灰顯 + 阻斷交互。 -
v1 限制:不支持與網格內
CellEditor的 in-progress draft 雙向 實時同步。僅在 commit 單元格編輯後欄中的 值纔會刷新。(CellEditor draft store 共享爲後續工作。)
Props
-
activeRefstring | null
活動單元格的 A1 引用。通過
coordToA1(active.row, active.col)從SelectionSnapshot派生。若爲null,則名稱 框爲空,輸入框也以空狀態渲染。 -
valuestring | number | null
活動單元格的 raw 輸入值:公式時爲
'=A1*B1',字面量時爲 數字或字符串。從FormulaSheet.getRaw(active)或行 數據訪問器派生。 -
onCommit(value: string | null) => void
在 Enter · ✓ · 失焦時調用。空字符串會以
null傳遞,因此可直接流送給FormulaSheet.setRaw。 -
onCancel() => void
Esc · ✗ 時。draft 會恢復爲
value。 -
onNavigate({ row, col, ref }) => void
在名稱框中輸入 A1 引用並按 Enter 時調用。未設置時 名稱框爲顯示專用(readOnly)。
-
readOnly · disabledboolean
readOnly阻斷 commit,disabled爲視覺 禁用 + 阻斷交互。適合與工作表保護(protected)狀態 聯動。 -
labelsFormulaBarLabels
nameBox · formulaInput · commit · cancel · fxIcon · emptyPlaceholder。 默認值爲韓語('이름 상자' · '수식 입력줄' · '입력' · '취소' · 'fx')。
連接到SelectionSnapshot + FormulaSheet
import { useRef, useState } from 'react';
import {
FormulaBar,
FormulaSheet,
XlReact,
coordToA1,
type SelectionSnapshot,
} from 'hyper-xl';
// 使用方保存的工作表實例 (爲引用穩定性使用 ref)。
const sheetRef = useRef<FormulaSheet | null>(null);
if (!sheetRef.current) sheetRef.current = new FormulaSheet();
const [selection, setSelection] = useState<SelectionSnapshot | null>(null);
const active = selection?.active ?? null;
const activeRef = active ? coordToA1(active.row, active.col) : null;
const value = active ? sheetRef.current.getRaw(active) : null;
return (
<>
<FormulaBar
activeRef={activeRef}
value={value}
onCommit={(next) => {
if (!active) return;
sheetRef.current!.setRaw(active, next);
// 由於工作表的顯示值已刷新,因此網格行數據也重新構建。
setRows(buildRowsFromSheet(sheetRef.current!));
}}
/>
<XlReact
columns={columns}
rows={rows}
onSelectionChange={setSelection}
onCellChange={(c) => {
sheetRef.current!.setRaw(c.coord, c.nextValue as string | number | null);
setRows(buildRowsFromSheet(sheetRef.current!));
}}
/>
</>
);
爲名稱框連接導航
// 當使用方可以控制活動單元格時(例如自有 selection 模型),
// 接收 onNavigate 並用自己的 reducer 跳轉。
<FormulaBar
activeRef={activeRef}
value={value}
onCommit={handleCommit}
onNavigate={({ row, col }) => selectionDispatch({ type: 'moveTo', row, col })}
/>
導入 / 導出 (Excel · CSV · TSV)
特性
-
基於快照的 API:輔助函數只接收使用方組裝的
GridSnapshot(rows·columns· 可選的cellFormats·merges)。 它不會窺探網格組件的內部狀態,因此可在任意時點 以任意形態導出。 -
延遲加載:
exceljs位於peerDependencies(optional), 在打包器配置中也被外部化。若僅使用 CSV / TSV,則 完全無需安裝 ExcelJS。 -
對稱往返:導出默認值爲
includeHeader: false(使用方的 row 0 已 承載標題行的常見模式),導入默認值爲dropHeaderRow: false, 將標題行也納入數據,從而恢復出視覺上完全相同的網格。headerRow僅用於提取列id。 -
格式保留:字體(加粗·斜體·下劃線·顏色)、
填充(背景色)、對齊(水平·垂直)、邊框(四邊 + 對角線)、
數字格式、列寬、合併單元格均雙向映射
(
cellFormatToExcelStyle/cellFormatFromExcelStyle)。標題會自動應用加粗 + 深色背景 + 第 1 行 freeze(可通過headerStyle/freezeHeader關閉)。 -
公式往返:如果單元格值是以
=…開頭的字符串,導出時會寫入爲真正的 Excel formula 單元格 (Excel 打開文件時會重新計算),導入時會以相同字符串原樣 恢復。與FormulaSheet結合後,連顯示值也會實時 求值。 -
多工作表導出:
exportMultiSheetXlsx會將多個快照序列化到一個工作簿中。 省略sheetName時會自動賦予Sheet1·Sheet2…,重複名稱會以_2·_3後綴消解。 -
文件大小守衛:
importFromXlsx會在 解析前以ImportFileTooLargeError拒絕超過maxFileSizeBytes(默認DEFAULT_MAX_IMPORT_SIZE_BYTES = 50 MB)的 輸入。設爲0則解除。 -
驗證報告:
ImportResult.warnings中 會累積按類型分類的消息,如empty-sheet·sheet-not-found·header-missing·duplicate-header·cell-error(#REF! / #VALUE! 等)·merge-skipped。<ImportDialog>會按類型分組,顯示計數與樣例 消息。 -
列映射 UI:在對話框的“列映射”表格中,
可按原始標題編輯目標
Column.id。 結果對象會在確認時以新 id 重命名後傳遞給使用方。 以編程方式則可通過ImportOptions.columnMapping(或CsvOptions.columnMapping)選項直接傳入 映射。 -
CSV / TSV:RFC 4180 引號 / 轉義 +
BOM 剝離 + 分隔符自動檢測(
,·\t·;·|)+ 前導 0 保留("012"不會 被轉換爲數字)。無需 ExcelJS 即可工作。
API
-
exportToXlsx
(snapshot: GridSnapshot, options?: ExportOptions) => Promise<Blob>
返回單工作表 .xlsx Blob。下載通過
triggerBlobDownload觸發。 -
exportMultiSheetXlsx
(entries: MultiSheetEntry[]) => Promise<Blob>
將多個快照按工作表分組寫入一個工作簿。每個條目同時接收
snapshot與ExportOptions(每工作表的sheetName·range·columnIds等)。 -
importFromXlsx
(source: File | Blob | ArrayBuffer | Uint8Array, options?: ImportOptions) => Promise<ImportResult>
將 ExcelJS 工作簿轉換爲
ImportResult。如果大小 超過options.maxFileSizeBytes(默認 50 MB),則 拋出ImportFileTooLargeError。 -
exportToCsv / importFromCsv
(snapshot, CsvOptions?) => string · (text, CsvOptions?) => ImportResult
無需 ExcelJS 即可工作的 RFC 4180 序列化器。支持
delimiter·newline·bom·includeHeader·columnMapping。 -
buildWorkbookFromSnapshot / parseWorkbookToSnapshot
'hyper-xl/exceljs' 子路徑
直接操作 ExcelJS
Workbook實例的低層路徑: 用於自行管理延遲加載,或結合多個庫來組裝工作簿 時使用。爲避免根條目的類型聲明泄漏 ExcelJS 類型, 它被分離到單獨的子路徑中,因此請以import { buildWorkbookFromSnapshot } from 'hyper-xl/exceljs'的形式導入。 -
cellFormatToExcelStyle / cellFormatFromExcelStyle
'hyper-xl/exceljs' 子路徑 (CellFormat ↔ ExcelJS.Style)
雙向轉換。涵蓋字體·填充·對齊·邊框·數字格式。 與上面兩個輔助函數位於同一子路徑,且要求安裝 ExcelJS。
-
defaultExportFilename
(prefix?, now?) => string
<prefix>_YYYYMMDD_HHmm.xlsx(本地時間): 默認 prefix 爲xl-react。 -
triggerBlobDownload
(blob: Blob, filename: string) => void
僅在瀏覽器中工作的下載觸發器。在非瀏覽器環境中爲 no-op。
-
ExportButton
React 組件
接收
rows·columns·可選的cellFormats·merges,點擊時 下載 .xlsx。會自動管理busy狀態,並通過onError報告失敗。 -
ImportDialog
React 組件
文件選擇 / 拖拽·放置 → 選擇工作表·標題行 + 編輯列映射 + 前 10 行預覽 + 驗證報告分組 → 確認時 調用
onImport(result, file)。標籤可通過DEFAULT_IMPORT_DIALOG_LABELS整體或按鍵 覆蓋。
ExportButton + ImportDialog:與演示
相同的模式
import { useState } from 'react';
import {
ExportButton,
ImportDialog,
defaultExportFilename,
type ImportResult,
} from 'hyper-xl';
function Page() {
const [columns, setColumns] = useState(initialColumns);
const [rows, setRows] = useState(initialRows);
const [cellFormats, setCellFormats] = useState({});
const [open, setOpen] = useState(false);
const onImport = (result: ImportResult /*, file */) => {
// 用新數據整體替換網格。映射 / 警告在 result 中。
setColumns(result.columns);
setRows(result.rows);
setCellFormats(result.cellFormats);
};
return (
<>
<ExportButton
rows={rows}
columns={columns}
cellFormats={cellFormats}
filename={() => defaultExportFilename('my-report')}
/>
<button onClick={() => setOpen(true)}>導入…</button>
<ImportDialog open={open} onClose={() => setOpen(false)} onImport={onImport} />
</>
);
}
編程方式:僅使用函數
import {
exportToXlsx,
importFromXlsx,
exportToCsv,
importFromCsv,
triggerBlobDownload,
defaultExportFilename,
} from 'hyper-xl';
// xlsx:在內存中生成 Blob 後觸發瀏覽器下載
const blob = await exportToXlsx(
{ rows, columns, cellFormats, merges },
{ sheetName: '訂單', includeHeader: false },
);
triggerBlobDownload(blob, defaultExportFilename('order'));
// xlsx:接收 File 並解析(自動應用 50MB 守衛)
const result = await importFromXlsx(file, {
sheetName: '訂單',
headerRow: 1,
columnMapping: { '姓名': 'name', '數量': 'qty' },
});
// CSV:無需 ExcelJS 的純函數
// exportToCsv 默認不寫入表頭(includeHeader 默認 false),
// importFromCsv 默認將首行視爲表頭(includeHeader 默認 true)。
// 若要往返一致,請在兩側都顯式指定 includeHeader。
const csv = exportToCsv({ rows, columns }, { delimiter: ',', bom: true, includeHeader: true });
const csvResult = importFromCsv(csv, { includeHeader: true });
多工作表 + 公式往返
import { exportMultiSheetXlsx } from 'hyper-xl';
// 若在每行填入 `=B{r}*C{r}` 這樣的公式字符串,導出時會作爲真正的 Excel
// formula 單元格記錄。通過導入再次讀取時 `=…` 字符串也會原樣
// 恢復,因此可與 FormulaSheet 結合實現實時求值。
const order = [
{ id: 0, data: { name: '姓名', qty: '數量', price: '單價', total: '合計' } },
{ id: 1, data: { name: 'A', qty: 12, price: 1500, total: '=B2*C2' } },
{ id: 2, data: { name: 'B', qty: 8, price: 2300, total: '=B3*C3' } },
];
const blob = await exportMultiSheetXlsx([
{ snapshot: { rows: order, columns }, sheetName: '訂單' },
{ snapshot: { rows: inventory, columns }, sheetName: '庫存' },
]);
透視表
組成元素
-
PivotBuilder:將 4 區域拖放 UI + 結果網格整合爲一個 組件的成品。演示頁面的“透視表”標籤頁直接使用它。 -
buildPivot(rows, config):純函數。返回PivotResult。 用於製作自定義 UI 或僅將結果傳給其他網格/圖表時使用。 -
pivotResultToGrid(result, labels?):將PivotResult轉換爲可直接傳給<XlReact>的{ columns, rows, merges, freezeRowCount, freezeColCount }快照。 表頭的 colSpan / rowSpan 會映射到網格的merges, 值單元格中的數字會以Intl.NumberFormat(最多小數 4 位)自動格式化。 -
pivotAggregate(kind, values):9 種聚合的單一分發器(sum·count·countA·average·max·min·stdDev·variance·product)。 非數字(NaN、±Infinity、對象、boolean)不會污染桶, 而是被靜默忽略;variance/stdDev以 Welford 算法 按樣本方差(n − 1)形式計算。
PivotConfig 結構
-
rowsPivotField[]
行軸分組鍵。順序即樹的深度:第一個爲最頂層分組。
-
columnsPivotField[]
列軸分組鍵。與
rows對稱。 -
valuesPivotValueField[]
聚合對象。每一項爲
{ key, label?, agg },可多次添加相同的key以同時產出不同的聚合(例如:合計 · 平均)。 -
filtersPivotFilterField[]
在分組之前應用的白名單篩選。若將
selectedValues置空, 則該字段以無篩選方式通過。 -
showGrandTotalRowboolean(默認 true)
若
rows.length === 0則自動禁用:在僅有 1 個行的軸上再繪製總計 行,本體會原樣重複顯示。columns同理。 - showGrandTotalColumnboolean(默認 true)
總計不是單元格之和
總計行 / 列不是以本體單元格之和計算的。它始終在全部原始行
上重新運行 aggregate。average·
min·max·variance·stdDev·
product 不滿足分配律,因此把單元格相加會得出錯誤的值。
這也是爲何即便在單元格爲空的位置(matrix[r][c] === null),總計
仍能被正確計算的原因。
無頭使用:用 buildPivot 僅獲取結果
import { buildPivot, type PivotConfig } from 'hyper-xl';
const shipments = [
{ id: 1, data: { region: '釜山', product: '集裝箱', qty: 10 } },
{ id: 2, data: { region: '釜山', product: '散貨', qty: 5 } },
{ id: 3, data: { region: '仁川', product: '集裝箱', qty: 30 } },
{ id: 4, data: { region: '仁川', product: '散貨', qty: 7 } },
];
const config: PivotConfig = {
rows: [{ key: 'region' }],
columns: [{ key: 'product' }],
values: [{ key: 'qty', agg: 'sum' }],
filters: [],
};
const result = buildPivot(shipments, config);
// result.matrix → [[10, 5], [30, 7]]
// result.rowPaths → [['釜山'], ['仁川']]
// result.columnPaths → [['集裝箱'], ['散貨']]
// result.grandTotalRow → [40, 12] // 集裝箱 / 散貨 總和
// result.grandTotalColumn → [15, 37] // 釜山 / 仁川 總和
// result.grandTotal → [52] // 整體總和:在原始數據上重新計算
PivotBuilder:直接使用拖放 UI
import { PivotBuilder, type PivotAvailableField, type Row } from 'hyper-xl';
const rows: Row[] = /* ... 原始行 ... */;
const fields: PivotAvailableField[] = [
{ key: 'region', label: '卸貨港', type: 'text' },
{ key: 'product', label: '品種', type: 'text' },
{ key: 'team', label: '部門', type: 'text' },
{ key: 'qty', label: '數量', type: 'number' },
{ key: 'weight', label: '裝載量', type: 'number' },
];
export function PivotDemo() {
return (
<PivotBuilder
rows={rows}
availableFields={fields}
// 可選項:可通過 onConfigChange 將佈局持久化到外部存儲。
onConfigChange={(config) => console.log(config)}
/>
);
}
用 pivotResultToGrid 直接 <XlReact> 渲染
當你想製作自定義側邊欄,僅將結果以標準網格呈現時。
直接使用 PivotBuilder 內部所用的適配器即可。
import { XlReact, buildPivot, pivotResultToGrid } from 'hyper-xl';
const result = buildPivot(rows, config);
const snapshot = pivotResultToGrid(result, {
grandTotalRow: '總計',
grandTotalColumn: '總計',
});
return (
<XlReact
columns={snapshot.columns}
rows={snapshot.rows}
merges={snapshot.merges}
freezeRowCount={snapshot.freezeRowCount}
freezeColCount={snapshot.freezeColCount}
readOnly
/>
);
值顯示格式(§10A.4 P1)
每個 Value 標籤在聚合函數旁都附有顯示格式下拉框,可將
相同的聚合以 6 種模式重新表現。所有 % 模式都將分子
除以各模式對應的分母,當分母爲 null 或 0 時
單元格會變爲空(null)。網格將 % 模式單元格以
Intl.NumberFormat({ style: 'percent', maximumFractionDigits: 2 })
繪製。
-
normal默認值
原始聚合本身。
-
percentOfTotal總計 %
單元格 / 整體總計。網格的總計單元格爲 100%。
-
percentOfColumn列 %
單元格 / 該列合計。各列的總計行爲 100%。
-
percentOfRow行 %
單元格 / 該行合計。各行的總計列爲 100%。
-
percentOfParentRow父行 %
在多層級行透視中,單元格 / 上級分組合計。在最頂層行,父級與整個 數據集相同,因此結果上等同於對各列合計的比率。
-
percentOfParentColumn父列 %
列軸的對稱形式。在多層級列中具有意義。
引擎在將原始聚合原樣保留於 matrix / grandTotal* 的同時,
將用於顯示的轉換單獨填充到 displayMatrix /
displayGrandTotal*。無頭用戶讀取原始值,
網格讀取 display*,兩種視角在同一個
PivotResult 中同時共存。
值顯示格式使用示例
import { buildPivot, type PivotConfig } from 'hyper-xl';
const config: PivotConfig = {
rows: [{ key: 'region' }],
columns: [{ key: 'product' }],
values: [
// 可將同一字段以 raw + % 兩種表現形式同時呈現。
{ key: 'qty', agg: 'sum', valueDisplay: 'normal' },
{ key: 'qty', agg: 'sum', valueDisplay: 'percentOfTotal', label: 'qty 佔比' },
],
filters: [],
};
const result = buildPivot(rows, config);
// result.displayMatrix[0] → [30, 30/87, 5, 5/87] // v=0 raw, v=1 比率
// result.displayGrandTotal → [87, 1] // % 模式的總和爲 100%
分組:日期單位 · 數字區間(§10A.5 P1)
行 / 列區域的標籤上有 ⌬ 圖標,右鍵單擊(或單擊)可打開 分組彈出框。彈出框會根據字段類型展示不同的 UI:
-
date 字段PivotDateGroupUnit
年 / 季度 / 月 / 周 / 日 單選組。按所選單位的日曆邊界將 值向下取整(year → 1月1日,quarter → 季度起始月的1日,week → 該 ISO 周的週一,day → 午夜)。標籤爲
2026·2026-Q1·2026-05·2026-W22·2026-05-27格式。 -
number 字段PivotNumberBin
size寬度 + 可選的origin起始點。值會落入[origin + k·size, origin + (k+1)·size)的半開區間。 標籤爲0~100、100~200、…
在啓用了分組的標籤上,標籤旁會顯示 [季度] 或 [10]
這樣的小徽標。無法強制解析的值(空單元格、
非 ISO 格式的字符串等)不會被分組,而是落入既有的 (空白) 或
原始值桶:因此分組行與非分組行可以在同一結果中共存。
引擎在內部 readField 階段將值規範化爲桶(bucketize,
非公開實現),因此所有後續路徑(表頭樹 · 父桶 · % 模式分母)都無需改動即可
運作。也就是說,即便在按季度分組的行 × 按季度分組的列之上,§10A.4 的
percentOfParentRow 這類顯示格式也能原樣保有其意義。
分組使用示例
import { buildPivot, type PivotConfig } from 'hyper-xl';
const config: PivotConfig = {
rows: [
// 將 shipDate 字段的 raw 值(Date 或 ISO 字符串)按季度單位分組。
{ key: 'shipDate', grouping: { dateUnit: 'quarter' } },
],
columns: [{ key: 'region' }],
values: [{ key: 'qty', agg: 'sum' }],
filters: [],
};
// result.rowHeaders[0] → [{ label: '2026-Q1', ... }, { label: '2026-Q2', ... }, ...]
// result.matrix → 季度 × region 的 qty 合計
// 數字字段:100 單位 bin
const binConfig: PivotConfig = {
rows: [{ key: 'weight', grouping: { numberBin: { size: 100 } } }],
columns: [],
values: [{ key: 'qty', agg: 'count' }],
filters: [],
};
// result.rowHeaders[0] → [{ label: '0~100' }, { label: '100~200' }, ...]
排序 · 篩選(§10A.6 P1)
行 / 列區域的標籤上有 ▼ 圖標,單擊可打開 排序·篩選彈出框。彈出框含三個區域:
-
排序(per-field)PivotFieldSort
標籤升/降序(
label-asc/label-desc), 按值升/降序(value-asc/value-desc:選擇值 字段索引),以及manual自定義順序。自定義 模式通過 ▲ / ▼ 按鈕將當前顯示的分組鍵上下 移動來應用。null 聚合無論排序方向如何,始終排在最後 (Excel 行爲)。 -
標籤篩選(per-field)PivotLabelFilter
四種變體:
include(用複選框選擇明確允許的鍵),text(包含 / 前綴 / 後綴 / 匹配 / 不匹配),number(gt / lt / gte / lte / equals / notEquals / between),date(before / after / on / notOn / between)。標籤篩選以分組樹的節點 爲單位應用,子節點全部被裁掉的父節點也會一併被移除。 -
值篩選(axis-level)PivotValueFilter
通過
rowValueFilter/columnValueFilter應用於行或列 整個軸。有topN的top/bottom方向與n、aboveAverage/belowAverage(以軸平均爲基準)、threshold(數值比較)共四種。Top-N 會保留邊界值處的所有同分項(Excel 行爲)。
在應用了排序·篩選的標籤上,標籤旁會出現小徽標(↑ / ↓ / ⇅ / ⚑)。 被標籤篩選與值篩選裁掉的行/列葉子會從表頭中移除,總計與 % 顯示模式的分母也會重新計算爲只合計可見數據。也就是說,在 “高於平均 + 總計 %”組合中,被篩選掉的行也會從分母中排除。
排序 · 篩選使用示例
import { buildPivot, type PivotConfig } from 'hyper-xl';
const config: PivotConfig = {
rows: [
{
key: 'region',
// 按值合計從大到小排序
sort: { mode: 'value-desc', valueFieldIndex: 0 },
// 僅含“釜山”的標籤
labelFilter: { kind: 'text', op: 'contains', pattern: '釜山' },
},
],
columns: [{ key: 'product' }],
values: [{ key: 'qty', agg: 'sum' }],
filters: [],
// 對整個行軸應用高於平均值的篩選
rowValueFilter: { kind: 'aboveAverage', valueFieldIndex: 0 },
};
// rowHeaders[0] → 超過平均值的地區按 sum(qty) 降序排序顯示
// grandTotal / displayMatrix → 僅用留存行的數據重新計算
佈局選項 (§10A.7 P1)
通過 PivotConfig.layout 以 Excel 兼容方式控制小計位置 · 報表形式 · 空單元格顯示值。
在 PivotBuilder 面板的右上角
以三個下拉框的形式呈現。
-
subtotalPosition'top' | 'bottom' | 'none'
當行軸上存在兩級以上時,會爲每個非葉分組自動 插入小計行。
top放在分組起始之上,bottom放在 最後一個葉之後,none則在視覺上隱藏。引擎 始終通過PivotResult.rowSubtotals計算好,因此圖表/導出 的消費方無論位置設置如何都能讀取小計。 -
reportLayout'compact' | 'outline' | 'tabular'
tabular(默認): 每個葉行重複顯示所有父級標籤。outline: 每個行字段擁有自己的列,但葉行只顯示最 深的標籤,父級標籤放在單獨的分組表頭行中。compact: 將所有行級別以縮進顯示在一個列中(由於列較窄, 行表頭寬度會增大以容納較長的標籤)。 -
emptyCellDisplaystring
值爲
null的單元格(無匹配數據)如何顯示。構建器的 默認選項是''(空白)、'0'、'-';用代碼 在config.layout.emptyCellDisplay中放入任意字符串也可以。 行表頭列不受影響,始終保持空白。
它也能與 §10A.4 的 % 顯示模式自然結合。percentOfParentRow
在小計單元格中使用"上一級父分組的合計"作爲分母,因此嵌套
小計也會顯示爲相對於父級的比例而非 100%(例如: 釜山-集裝箱
小計 = 30 / 35 ≈ 85.7%)。
實現說明: 由於 VirtualGrid 的合併層只
繪製在正文(body)窗格中,所以行表頭固定區域中合併單元格的錨點不會
出現在屏幕上。pivotResultToGrid 不將小計/總合計標籤
作爲合併處理,而是直接留在相應列中,其餘行表頭列則留空。這與 Excel 的緊湊/概要小計行外觀相同。
佈局選項使用示例
import { buildPivot, pivotResultToGrid, type PivotConfig } from 'hyper-xl';
const config: PivotConfig = {
rows: [{ key: 'region' }, { key: 'product' }],
columns: [{ key: 'month' }],
values: [{ key: 'qty', agg: 'sum' }],
filters: [],
layout: {
subtotalPosition: 'bottom',
reportLayout: 'outline',
emptyCellDisplay: '-',
},
};
const result = buildPivot(rows, config);
const snapshot = pivotResultToGrid(result, undefined, config.layout);
// result.rowSubtotals: 包含 depth/path/leafFrom-leafTo + displayValues
// snapshot.rows: outline 模式 → 每個非葉分組自動插入分組表頭行
全部刷新 (§10A.8 P1)
當一個屏幕上有多個 PivotBuilder 都指向同一個源數組時,
你可能想通過一次點擊讓它們全部重新計算。每個構建器的"刷
新"按鈕只會重新計算自身,因此在多透視表環境中,
用 PivotRefreshScope 包裹這棵樹,
並用 usePivotRefreshAll() 鉤子創建批量觸發器。
-
PivotRefreshScope{ children: ReactNode }
通過 React 上下文向子樹提供單調遞增的 nonce 和
refreshAll回調。 由於同一作用域內的所有PivotBuilder都把這個 nonce 作爲useMemo依賴項一起讀取,因此一次 bump 就能讓它們全部重新 調用buildPivot。作用域之外的透視表仍 像以前一樣只響應自己的按鈕。 -
usePivotRefreshAll()() => void
供子組件(通常是頁面頂部的"全部刷新"按鈕) 調用的觸發器。在
PivotRefreshScope之外使用時會 返回一個安全的 no-op,因此在單透視表路徑/無作用域路徑中也可以 原樣掛載同一個組件。
實現說明: 作用域 nonce 與構建器的本地刷新 nonce 並行運作。一方 bump 時可能只有那一方響 應,而兩者同時變化時則只重新計算一次。由於設計上即使是數據標識 不變的 in-place mutation 也會強制重新計算, 所以直接修改源數組後再調用,屏幕也會刷新。
將兩個 PivotBuilder 綁定到一個作用域
import { PivotBuilder, PivotRefreshScope, usePivotRefreshAll } from 'hyper-xl';
function PivotPage({ rows }: { rows: Row[] }) {
return (
<PivotRefreshScope>
<RefreshAllButton />
<PivotBuilder rows={rows} availableFields={fields} initialConfig={byRegion} />
<PivotBuilder rows={rows} availableFields={fields} initialConfig={byProduct} />
</PivotRefreshScope>
);
}
function RefreshAllButton() {
const refreshAll = usePivotRefreshAll(); // 在作用域之外則爲 no-op
return <button onClick={refreshAll}>全部刷新</button>;
}
詳細信息 (Show Details) (§10A.9 P1)
雙擊值單元格會以模態框顯示該單元格所聚合的原始行。 這與 Excel 的"Show Details"行爲相同。無論單元格 位於正文(body) · 小計 · 總合計行 · 總合計列 · 左上角總計中的 哪個位置,都可以用相同的 API 反向追溯原始行。
-
onShowDetails?(details, rows) => void
攔截值單元格的雙擊並將其路由到自己的 UI(獨立工作表/標籤頁/對話框)。 如果註冊了回調,則不會打開默認模態框,
details是PivotDrillDownDetails,rows是從源數組中提取的ReadonlyArray<Row>。 -
disableShowDetails?boolean
完全關閉內置模態框的開關。在有
onShowDetails時沒有意義,當你想忽略雙擊本身時,可以同時 指定兩者或僅開啓此項。 -
PivotDetailsModalPivotDetailsModalProps
供想在構建器外部直接渲染內置模態框的無頭 消費方使用的組件。支持僅組合
buildPivot+pivotResultToGrid+pivotDrillDownAt+resolvePivotDrillDown來構建自己的 UI 而僅複用庫提供的對話框 這一路徑。 -
pivotDrillDownAt(snapshot, row, col)PivotDrillDownTarget | null
網格座標 → 下鑽目標。若爲表頭 · 標籤 · 角落單元格則爲
null。返回的目標通過kind區分爲'body' | 'subtotal' | 'grandTotalRow' | 'grandTotalColumn' | 'grandTotal'之一。 -
resolvePivotDrillDown(target, result)PivotDrillDownDetails
目標 ×
PivotResult→ 原始行索引 + 行/列路徑 + 值字段描述符。這是純函數,如有需要可 用pivotDrillDownRows(target, result, rows)一次性獲取實際的Row對象而非索引。
引擎將雙擊反向追溯所需的最小數據
直接暴露在 PivotResult 中。
rowLeafSourceIndices[r] · columnLeafSourceIndices[c]
· effectiveSourceIndices。行 · 列各葉的原始索引
是應用了各軸篩選(篩選區域 + 標籤篩選 + 值篩選)之後的結果,
effectiveSourceIndices 是兩軸篩選的交集:即
"兩側均可見"的行的索引。圖表/導出等不經過網格的
消費方也能用相同的索引進行原始追溯。
無障礙性: 模態框以 role="dialog" +
aria-modal="true" 暴露,打開時記住當前
活動元素,關閉時恢復焦點。Esc · 背景點擊 · 關閉按鈕
三者都是關閉觸發器,而 Esc 僅在對話框內部攔截,
不會破壞外部網格的取消選區快捷鍵。
雙擊正文單元格 → 通過 onShowDetails 提取原始行
import { PivotBuilder, type PivotDrillDownDetails, type Row } from 'hyper-xl';
function ShipmentPivot({ rows }: { rows: Row[] }) {
const [drill, setDrill] = useState<{ details: PivotDrillDownDetails; rows: ReadonlyArray<Row> } | null>(null);
return (
<>
<PivotBuilder
rows={rows}
availableFields={fields}
initialConfig={config}
// 一併接收雙擊單元格的 (行路徑、列路徑、值字段) + 該單元格所聚合的
// 原始行數組。可以路由到獨立工作表,
// 也可以直接掛載 PivotDetailsModal。
onShowDetails={(details, sourceRows) => setDrill({ details, rows: sourceRows })}
/>
{drill ? (
<PivotDetailsModal
details={drill.details}
sourceRows={drill.rows}
availableFields={fields}
onClose={() => setDrill(null)}
/>
) : null}
</>
);
}
預設透視表 (§10A.11 P1)
爲了能夠立即應用佈線系統中預期會經常創建的透視表配置,
一併提供 PivotConfig 預設(P1 5 種 + §10A.11 P2 3 種 = 目前 8 種)
以及可用於驗證這些預設的僞
佈線數據集(fake wiring manifest)。
直接注入 PivotBuilder 的 initialConfig 或
buildPivot 即可。
-
WIRING_PIVOT_PRESETSReadonlyArray<WiringPivotPreset>
按順序暴露預設的 frozen 數組。前三 項是"佈線申請合計"家族(按卸貨港 / 按品種 / 按部門), 第四項是以行=卸貨港 × 列=月(
shipDate的 §10A.5 月份分組) 爲基準的發運量累計,第五項是以部門×月爲基準將發運量 用percentOfRow(§10A.4) 顯示的"實際對計劃比率 (%)"。 之後 §10A.11 P2 又新增了基於航次·許可·生產進度的 3 種, 目前該數組共收錄 8 種。 -
WIRING_PIVOT_PRESET_BY_IDRecord<WiringPivotPresetId, WiringPivotPreset>
在已經通過按鈕
id· URL slug 等指定了預設的情況下, 可以直接查找並使用單個項的映射形式。各項 指向WIRING_PIVOT_PRESETS中的同一實例。 -
buildWiringShipmentDataset(opts?: { count?: number; seed?: number }) => Row[]
用確定性 Mulberry32 PRNG 生成的僞佈線清單。默認 60 行 · 種子
0xc0ffee· 範圍 2026-01-01 ~ 2026-05-31, 將shipDate分散開,足以驗證 §10A.5 日期分組(年/季度/月/周/日)。 相同的種子始終返回相同的行,因此可在 快照 / Storybook 測試中安全複用。 -
WIRING_PRESET_FIELDSReadonlyArray<PivotAvailableField>
與上述數據集一一對應的字段描述符列表(共 11 個)。按卸貨港 · 品種 · 部門 · 月 · 發運日 · 數量 · 發運量(t) · 計劃(t) · 航次 · 許可狀態 · 生產進度(%) 的順序暴露。 直接傳給
PivotBuilder的availableFields即可 讓預設與數據集原封不動地結合。
PivotBuilder 是 uncontrolled 組件,因此
initialConfig 只在掛載時應用一次。當用戶點擊芯片
切換預設時,將 React key 指定爲預設 id
來重新掛載構建器是推薦的模式。演示中的
PivotPresetShowcase 就採用這種方式。
Sub-path 構建: 只需要預設的消費方可以
通過 hyper-xl/pivot/presets 入口點導入。
不包含引擎(buildPivot) · 構建器(PivotBuilder) ·
下鑽(PivotDetailsModal)代碼,因此
打包器只會拉取一個 6KB 左右的、未被使用的小塊。主
hyper-xl 入口點會原樣 re-export 相同的符號,因此
既有代碼無需更改即可兼容。
若消費方想自行定義預設庫,請使用庫暴露的泛型
PivotPreset<Id extends string>。將自己的
id 字面量聯合作爲參數傳入,即可將 switch
收窄爲完全分支,而且無需強制轉換就能編寫出
與 WIRING_PIVOT_PRESETS 形狀相同的數組
(例如:
PivotPreset<'order-summary' | 'sla-status'>[])。
"實際對計劃比率(累計比%)": 真正意義上的"運行累計 %"
將通過 §10A.4 P2 中引入的 runningTotal /
differenceFrom 顯示來表達。P1 預設
使用當前庫支持的五種 % 模式中的 percentOfRow,
近似顯示爲"每個月在該部門的行累計中所佔的比率"。計劃(t)度量值
保持原始噸數不變,以便確認分母。
立即應用預設 + 數據集
import { PivotBuilder } from 'hyper-xl';
// 預設專用 sub-path: 不會拉入引擎/構建器/下鑽。
import {
WIRING_PIVOT_PRESETS,
WIRING_PRESET_FIELDS,
buildWiringShipmentDataset,
type WiringPivotPreset,
} from 'hyper-xl/pivot/presets';
import { useMemo, useState } from 'react';
export function WiringPresetShowcase() {
// 確定性僞清單: 相同的種子始終返回相同的行。
const rows = useMemo(() => buildWiringShipmentDataset(), []);
const [presetId, setPresetId] = useState<WiringPivotPreset['id']>(
WIRING_PIVOT_PRESETS[0]!.id,
);
const preset =
WIRING_PIVOT_PRESETS.find((p) => p.id === presetId) ?? WIRING_PIVOT_PRESETS[0]!;
return (
<>
<div role="group" aria-label="透視表預設">
{WIRING_PIVOT_PRESETS.map((p) => (
<button
key={p.id}
type="button"
aria-pressed={p.id === presetId}
title={p.description}
onClick={() => setPresetId(p.id)}
>
{p.label}
</button>
))}
</div>
{/* PivotBuilder 是 uncontrolled: 用 key 強制重新掛載以重新應用 initialConfig */}
<PivotBuilder
key={preset.id}
rows={rows}
availableFields={WIRING_PRESET_FIELDS}
initialConfig={preset.config}
/>
</>
);
}
多重聚合 (§10A.3 P2)
在值(Value)區域中可以多次添加同一字段,從而各自以不同的聚合
同時顯示。例如: sum of qty + average of qty。引擎
通過基於索引的 dispatch 跟蹤每個芯片的設置,因此即使同一字段
進入兩次也不會衝突。在構建器 UI 中,每個 Value 芯片上都附有 ⎘
複製按鈕,點擊一次即可將相同的芯片複製
到下一個槽位,並更改爲不同的 agg 或 valueDisplay。
值顯示格式: 計算模式 (§10A.4 P2)
在 P1 的 5 種 % 模式之外,又新增了 Excel 的"Show Values As"計算家族
的 7 種(整個 PivotValueDisplay 共 13 種)。
其中 index 是 (單元格 × 總計) / (行合 × 列合) 的雙軸
對稱公式,因此沒有軸概念,其餘 6 種具有軸(axis)概念,
通過 PivotValueField.valueDisplayAxis ('column'
爲默認 / 'row')決定沿哪個方向進行轉換。
對於軸有意義的模式(內部集合 PIVOT_VALUE_DISPLAY_AXIS_AWARE
— 不會作爲包導出的實現細節),
構建器 UI 中會在顯示格式旁自動顯示計算方向下拉框。
-
differenceFromPrevious差值
該軸的第一個單元格爲
null,之後的單元格爲 當前 − 之前。 若中間夾有null單元格,則鏈在該處斷開並再次爲null。 -
percentDifferenceFromPrevious% 差值
(當前 − 之前) / 之前。若之前的值爲
0或null則爲null。構建器以%格式化器繪製。 -
runningTotal累計
沿軸的累積和。
null單元格被當作 0 處理,累計 繼續進行(Excel 行爲)。 -
percentOfRunningTotal累計 %
到各單元格爲止的累計 / 軸的整體合計。最後一個單元格爲 100%。
-
rankAscending · rankDescending排名
RANK.EQ語義: 並列共享相同(較低)的排名,而null單元格被排除在排名計數之外。 -
index索引
(cell × grandTotal) / (rowTotal × columnTotal): Excel 的索引公式。若行/列部分合計爲0則爲null。
實現說明: 計算模式與 % 模式相同,
matrix 保留原始聚合,僅
displayMatrix 進行轉換。當兩者都未激活時,
displayMatrix 會直接指向 matrix,因此
沒有開銷。
文本手動分組 + 摺疊 (§10A.5 P2 / §10A.9 P2)
行/列區域的芯片上新增了 ⊕ 圖標,
用於打開手動分組彈出框。一個分組以
{ label, values: [...] } 定義,同一分組內的值會
合併爲一個分組標籤。匹配基於
引擎內部的 canonical equality(groupKeyOf,非公開實現),
因此 '1'
和 1 這樣的整數型表示也會被同樣地歸併。未匹配的
值則原樣以原始標籤顯示。
當行軸上存在兩級以上時,PivotGridSnapshot.collapsibleRowGroups
會暴露出來,告知每個非葉分組的 {depth, pathKey, leafFrom, leafTo}。
在側邊欄的分組摺疊/展開面板中
切換時,該分組的所有葉行都會被隱藏,小計行則
被強制顯示(即使 subtotalPosition: 'none' 也強制暴露)。
還一併提供"全部摺疊" / "全部展開" / "上鑽"(移除最內層行字段)操作。
摺疊狀態通過 root export 的
pivotResultToGrid(result, labels?, layout?, adapterOptions?) 的
第四個參數 adapterOptions.collapsedRowGroupKeys 傳遞。
注意:選項類型 PivotGridAdapterOptions 與鍵構建器
pivotRowGroupKey(path) 不會暴露在公開包 API(root export)中 —
collapsedRowGroupKeys(ReadonlySet<string>)中
直接填入 PivotGridSnapshot.collapsibleRowGroups[].pathKey 的值即可。
切片器 · 時間軸 (§10A.6 P2)
新增了同時篩選多個透視表的可視化控件。
用 PivotSlicerScope 包裹的子樹內的所有
PivotBuilder 都訂閱相同的切片器狀態,
通過一次點擊全部一起刷新。這相當於將 Excel 的
"Report Connections"思維模型用上下文自動化了。
-
<PivotSlicerScope>Provider
保有選擇狀態的上下文提供者。同一 scope 內的所有
PivotBuilder在調用buildPivot時 會接收切片器發佈的 row predicate 作爲附加篩選。 它與既有的PivotRefreshScope正交,因此同時 包裹兩者也是可行的。 -
<PivotSlicer field="..." rows={...} />React.FC
類別芯片面板。將
field的唯一值列爲可切換芯片, 只讓用戶選中的子集通過。默認爲 "全部選中"(不應用任何篩選)。"全部取消"會發布空 集合以有意清空透視表,而"清除篩選"則將 切片器從 scope 中移除以恢復爲全部通過。 未指定id時field將用作標識符。 -
<PivotTimeline field="..." rows={...} />React.FC
日期切片器 (年/季度/月 zoom)。在源行中可解析 的日期的 min/max 之間按選定單位分桶顯示。 單擊選擇一個桶,Shift-單擊則以 anchor 爲起點進行範圍選擇, 發佈半開區間
[startMs, endMs)。再次點擊同一個 單一桶則解除範圍。initialUnit默認值爲'month'。 -
usePivotSlicerRowPredicate()Hook
返回 scope 內所有激活切片器經 AND-結合的 row predicate。 若在 scope 之外或沒有激活的切片器,則 返回
null,以免依賴的useMemo無意義地 churn。PivotBuilder在內部調用此鉤子,將其作爲buildPivot(rows, config, { extraRowFilter })的選項字段傳遞。 -
buildPivot: options.extraRowFilter(row, index) => boolean
添加到引擎的選項。與
config.filters進行 AND-結合, 而 drill-down 的effectiveSourceIndices仍 保持以原始索引爲基準(由於不會預先剔除行,所以 "到原始 row 的映射"不會被破壞)。
若切片器持有的 field 在 row 的 data 中
不存在,則該 row 通過。這是一個安全機制,即使在一個 scope 中
混入數據 shape 不同的透視表,也能防止切片器無意中
清空不相關的透視表。若需要嚴格匹配,
請分離 scope。
佈局策略: 表頭重複 · 條紋 · 樣式預設 (§10A.7 P2)
-
layout.repeatItemLabelsboolean
在概要(
outline) / 緊湊(compact)形式中把空 着的父級標籤單元格填入所有葉行,使得排序 / 篩選後的 結果即便以打印或 CSV 導出,行表頭也不會斷裂。 -
layout.bandedRows · layout.bandedColumnsboolean
給偶數 / 奇數正文單元格交替施加背景色以提升表格可讀性。 由於通過
cellFormats傳遞給網格,所以與網格的默認 渲染流程相整合。 -
layout.stylePreset'none' | 'light' | 'medium' | 'dark'
模仿 Excel 的"Light / Medium / Dark"家族的內置調色板。 給表頭 / 小計 / 總合計 / 條紋各自施加不同的顏色。 若爲
none則cellFormats保持爲null以使用網格的默認外觀。消費方 下發的cellFormats在相同鍵上優先。
自動刷新 (§10A.8 P2)
PivotBuilder 上新增了兩個新 prop:
-
autoRefreshIntervalMsnumber | null
基於
setInterval的週期性重新計算。這是只重算該構建器 的 per-pivot 定時器 — 即便位於PivotRefreshScope之內,它也只提升自己的本地 nonce,不會向整個 scope fan-out (以避免同一 cadence 的定時器分別鋪設在 N 個透視表上的重複)。 若要讓 scope 的所有透視表以同一 cadence 一起刷新,請在 scope 層級 (例如在公共工具欄中)調用一次usePivotAutoRefresh(intervalMs, opts)鉤子 — 該鉤子會提升 scope nonce,使所有透視表一起重算。 當爲0/null/ 省略時定時器禁用。 -
refreshOnMountboolean
與 Excel 的"Refresh data when opening the file"相同,在掛載 時點發射一次 refresh。默認
false。
外部 · 動態數據源 (§10A.1 P2)
PivotBuilder 的 rows prop 仍然
接收 ReadonlyArray<Row>,但當行從其他網格 ·
DB 查詢 · 外部饋送之類的可變出處流入時,
用與 RowSource 適配器成對的 hook
連接起來,無需單獨點擊‘刷新’即可自動重新計算。
暴露了四種工廠和三種 hook。
-
staticRowSource(rows)(rows: ReadonlyArray<Row>) => RowSource
僅將既有數組包裝成
RowSource形狀的 無變更適配器。subscribe爲 no-op。當想把靜態/動態 源一起放入一個combinedRowSource時 使用。 -
dynamicRowSource(initial?)(initial?: ReadonlyArray<Row>) => DynamicRowSource
可變內存源。暴露了
add(row | rows[])、remove(predicate)、setRow(predicate, updater)、replace(rows)、clear(), 每當發生突變時發佈新的數組 reference 後通知訂閱者。 當行添加爲 0 件或 predicate 未匹配的 情況下不發送通知,因此不會發生不必要的重新計算。 -
asyncRowSource(fetcher, options?)(fetcher: () => Promise<Rows> | Rows, options?) => AsyncRowSource
DB 查詢/HTTP 抓取等異步源。掛載後立即自動 fetch (或
manual: true),refetch()手動觸發,setIntervalMs(ms)動態輪詢 cadence, 用競爭令牌阻止 stale 響應,onError錯誤匯。 調用dispose()時定時器和所有訂閱者都會 被清理。 -
combinedRowSource(...sources)(...sources: ReadonlyArray<RowSource>) => CombinedRowSource
將多個
RowSource的rows按輸入順序拼接起來的虛擬源。當輸入之一 發生變更時,結合結果也會重新發布。“基於其他網格 的透視表”場景: 即使網格 A 和網格 B 各自 持有dynamicRowSource,也能讓單個透視表聚合 兩者並集: 的標準解法。 -
useRowSource(source | rows)(source: RowSource | ReadonlyArray<Row>) => ReadonlyArray<Row>
React 鉤子。實現在
useSyncExternalStore之上, 因此在 concurrent rendering 中也能無 tearing 地返回最新快照。 若直接傳入原始數組則返回該數組,因此 很容易做出"接收數組或 source"任一方的組件。 將返回值直接傳給<PivotBuilder rows={…}>即可。 -
useDynamicRowSource(initial?)(initial?: ReadonlyArray<Row>) => DynamicRowSource
僅在掛載時用種子行創建一次
dynamicRowSource並保存在 useMemo 緩存中的便利鉤子。initial只在第一次 渲染時被讀取,之後的數組用source.replace(rows)替換。 -
useAsyncRowSource(fetcher, options?)(fetcher, options?) => AsyncRowSource
僅在掛載時創建一次
asyncRowSource,並在宿主 組件卸載時自動調用dispose()以清理 輪詢定時器和訂閱者池。fetcher用 ref 跟蹤,因此每次渲染都傳入內聯閉包也安全。 輪詢 cadence 可用source.setIntervalMs(ms)在運行時 更改。
既有的 <PivotBuilder rows={array}> API 不會
變更。RowSource 是 opt-in 的附加面,未引入
適配器的演示/消費方不受影響。演示的“外部 · 動態
數據源”部分中的 +添加行 / 移除最後一行 / 全部清空
/ 恢復初始值按鈕以同樣的模式實時展示。
import { useMemo } from 'react';
import { PivotBuilder, dynamicRowSource, useRowSource, type Row } from 'hyper-xl';
function ExternalPivot({ seed }: { seed: Row[] }) {
const source = useMemo(() => dynamicRowSource(seed), []);
const rows = useRowSource(source);
return (
<>
<button onClick={() => source.add(makeRow())}>+ 添加行</button>
<PivotBuilder rows={rows} availableFields={FIELDS} />
</>
);
}
預設新增 (§10A.11 P2)
在既有的 P1 5 個之外,新增了 3 個 P2 預設。
按航次的裝載現狀 (行: 航次,列: 卸貨港,值:
發運量+數量)、按部門的執照狀態 (行: 部門 × 執照,
列: 按月分組的 shipDate,值: count)、按部門的生產進度
(行: 部門,列: 月,值: 進度率平均 + 數量合計)。數據集中
新增了 voyage / license / productionPct
字段,以模擬按執照狀態相關聯的進度率。
透視圖 (§10A.10 P2)
PivotChart 將透視表結果渲染爲柱狀圖 / 折線圖 / 餅圖。
不依賴外部圖表庫,使用純 SVG 繪製,
會直接訂閱 PivotSlicerScope · PivotRefreshScope 的
上下文,從而與同一 scope 內的 PivotBuilder
們通過相同的切片器 / "全部刷新" 信號同步更新。
每張圖表可視化一個值字段,類別軸可在
'row'(默認)或 'column' 中
選擇其一。
-
rowsReadonlyArray<Row>
原始行。接收與
PivotBuilder相同的 shape, 內部會調用buildPivot(rows, config)。 -
configPivotConfig
透視表規格。由於圖表讀取引擎的總計切片進行可視化, 因此即使使用方關閉
showGrandTotal*開關, 圖表內部也會強制使用已開啓的副本(兄弟PivotBuilder的開關保持原樣)。需要引用 穩定性:請勿在 JSX 中以內聯方式 傳入對象字面量,應使用useState/useMemo/ 模塊常量來保存。 -
kind'bar' | 'line' | 'pie'
圖表類型。柱狀圖 / 折線圖 / 餅圖中的一種。由於一個組件同時 支持三者,用相同的
config同時呈現三張圖表即可 構成直觀的對比視圖。 -
valueFieldIndexnumber(默認 0)
config.values中的索引。超出範圍時會被鉗制爲0。 -
categoryAxis'row' | 'column'(默認 'row')
用於生成類別的軸。
'row'爲每個 row leaf 對應 1 個 點,'column'爲每個 column leaf 對應 1 個點。 若所選軸沒有字段,則顯示空狀態。 -
width · heightnumber(默認 520 · 280)
SVG viewBox 尺寸。會與 CSS 的
width: 100%一起按比例 保留(xMidYMid meet)進行縮放。 -
title · labels.emptystring
可選。用於將表頭文本和空狀態消息自定義爲中文 時使用。未指定
title時,值字段的標籤會 成爲默認標題。 -
pivotResultToChartSeries(result, options?)函數
想使用自有圖表庫(D3、recharts 等)時使用的 無頭輔助函數。從
PivotResult中提取{ label, value }[]列表。爲了在非分配性 聚合(average/min/max/variance/stdDev/product) 下也能獲得準確的 leaf 值,會讀取引擎的 grand-total 切片, 而不是對單元格求和。
柱狀圖 / 折線圖會將 null 值顯示爲間隙(折線會
斷開,柱形不會繪製)。餅圖會自動省略 null 以及
負數 / 0 切片。Excel 也
不會賦予 "負數切片" 任何含義。在混有負數的數據中
使用餅圖會出現顯示截斷,因此這種情況建議使用柱狀圖。
透視表 + 切片器 + 圖表:全部綁定在一個 scope 中
import {
PivotBuilder,
PivotChart,
PivotRefreshScope,
PivotSlicer,
PivotSlicerScope,
type PivotConfig,
type Row,
} from 'hyper-xl';
const chartConfig: PivotConfig = {
rows: [{ key: 'region' }],
columns: [],
values: [{ key: 'qty', agg: 'sum', label: '수량 합계' }],
filters: [],
};
export function Dashboard({ rows }: { rows: Row[] }) {
return (
<PivotRefreshScope>
<PivotSlicerScope>
<PivotSlicer field="region" title="양하항" rows={rows} />
{/* 세 차트가 동일 scope 안에 있어 슬라이서·새로 고침을 공유 */}
<PivotChart rows={rows} config={chartConfig} kind="bar" title="막대" />
<PivotChart rows={rows} config={chartConfig} kind="line" title="선" />
<PivotChart rows={rows} config={chartConfig} kind="pie" title="원형" />
<PivotBuilder rows={rows} availableFields={fields} initialConfig={chartConfig} />
</PivotSlicerScope>
</PivotRefreshScope>
);
}
無頭:自行映射後用 D3/recharts 繪製
import { buildPivot, pivotResultToChartSeries } from 'hyper-xl';
const result = buildPivot(rows, config);
const series = pivotResultToChartSeries(result, {
valueFieldIndex: 0,
categoryAxis: 'row',
});
// series → [{ label: '부산', value: 30 }, { label: '인천', value: 70 }, ...]
// 이 배열을 그대로 recharts/visx/D3 에 넘겨 자체 스타일링된 차트로 그릴 수 있습니다.
拖放限制 (MVP)
- 僅支持桌面端 HTML5 DnD。移動端觸摸 / 鍵盤重排序 UI 將在後續 PR 中提供。
- 不繪製放置指示線(insertion indicator)。芯片始終進入區域的末尾, 若要移動到中間,請先移到其他區域,再重新放置。
- 尚無區域變更的 ARIA live-region 公告。
工作表 / 標籤頁 (Workbook)
構成要素
-
createWorkbook(options?):創建一個擁有初始工作表列表和activeSheetId的Workbook對象。 如果所有種子工作表都是hidden: true,則第一個工作表會自動 提升爲顯示狀態。 -
workbookReducer(workbook, action):純 reducer。 處理'add' | 'delete' | 'rename' | 'recolor' | 'reorder' | 'duplicate' | 'setHidden' | 'setProtected' | 'activate'這 九種 action,對於完整性違規(重名·重複 id·刪除最後一個 工作表·隱藏最後一個顯示工作表)則以返回相同引用予以拒絕。 -
useWorkbook(options?):將workbookReducer用useReducer包裹,一次性提供穩定的回調(addSheet,deleteSheet,renameSheet,…)以及 派生值(activeSheet,visibleSheets,hiddenSheets)。 -
<SheetTabBar />:放置在網格底部的標籤條。 包含 "+"、上下文菜單(重命名 · 複製 · 顏色 · 隱藏 · 保護 · 刪除)、 隱藏工作表下拉菜單、內聯名稱編輯(Enter 確認 · Esc 取消)、 HTML5 拖動重排序。視覺屬性全部通過--xl-react-sheet-tab-*token 暴露, UI 文案可用SheetTabBarLabels替換。 捆綁包中包含DEFAULT_SHEET_TAB_BAR_LABELS(英文)和KOREAN_SHEET_TAB_BAR_LABELS(韓文)兩個預設。 -
workbookToMultiSheetEntries(workbook, getSnapshot, options?):將工作簿轉換爲exportMultiSheetXlsx所接收的MultiSheetEntry[]。只要使用方傳入一個返回每個工作表{ rows, columns, cellFormats?, merges? }快照的 解析器,便可默認跳過隱藏工作表(可用includeHidden: true強制包含),一次性導出爲 .xlsx。
庫僅持有工作表元數據
Sheet 僅有 { id, name, color?, hidden?, protected? }
五個字段。行數據·單元格格式·undo 棧·列定義等全部
由使用方以工作表 id 爲鍵保存在自有 Map 中,每當 activeSheetId
發生變化時,將對應的載荷以 rows·cellFormats
送入 <XlReact> 即可。
由於庫本身不持有載荷,因此也可以與外部數據源(服務器分
頁·動態查詢·CRDT)自由結合使用。
protected 也只是一個元標誌,實際的寫入攔截由使用方
像 readOnly={activeSheet.protected} 這樣傳入網格來
應用。
維護的不變式
-
至少一個工作表。
'delete'不會刪除工作簿中 最後剩下的工作表。 -
至少一個顯示工作表。 即使還有隱藏的工作表,
刪除(
'delete')或隱藏('setHidden')最後一個 正在顯示的工作表的 action 也會被拒絕。因爲陷入該狀態後用戶 將無法點擊任何工作表。 -
activeSheetId 始終爲顯示工作表。 當活動工作表被隱藏或
刪除時,會就地自動切換到下一個顯示工作表。
'activate'不適用於隱藏工作表。 - 拒絕重名 / 重複 id。 空白·重名的重命名爲 no-op,並以返回相同引用讓使用方能夠檢測是否被忽略。
- 複製後爲可顯示·可編輯狀態。 即使原本爲隱藏或 保護狀態,複製副本也始終以可顯示且可編輯的狀態創建。 因爲 "受保護工作表的可編輯副本" 是複製的默認意圖。
最小接線:將每個工作表的行分離保存到外部 Map
import { useEffect, useRef, useState } from 'react';
import {
XlReact,
SheetTabBar,
useWorkbook,
KOREAN_SHEET_TAB_BAR_LABELS,
type Row,
type SheetId,
} from 'hyper-xl';
function Workbook() {
const workbook = useWorkbook({
initialSheets: [{ id: 'sheet-1', name: 'Sheet1' }],
});
// 시트별 페이로드는 라이브러리 밖. 키는 sheet.id 그대로.
const [data, setData] = useState<Record<SheetId, { rows: Row[] }>>(() => ({
'sheet-1': { rows: [{ id: 'r1', data: {} }] },
}));
// 시트 추가/삭제에 맞춰 페이로드 Map을 동기화.
const prevRef = useRef(workbook.workbook);
useEffect(() => {
const prev = prevRef.current;
const next = workbook.workbook;
const added = next.sheets.filter((s) => !prev.sheets.some((p) => p.id === s.id));
const removed = prev.sheets.filter((p) => !next.sheets.some((s) => s.id === p.id));
if (added.length || removed.length) {
setData((m) => {
const out = { ...m };
for (const s of added) out[s.id] = { rows: [] }; // 빈 시트로 추가
for (const s of removed) delete out[s.id];
return out;
});
}
prevRef.current = next;
}, [workbook.workbook]);
const active = data[workbook.activeSheet.id] ?? { rows: [] };
return (
<>
<XlReact
columns={columns}
rows={active.rows}
onCellChange={(change) =>
setData((m) => {
const sheetId = workbook.activeSheet.id;
const rows = m[sheetId].rows.map((row, i) =>
i === change.coord.row
? { ...row, data: { ...row.data, [change.columnId]: change.nextValue } }
: row,
);
return { ...m, [sheetId]: { rows } };
})
}
readOnly={!!workbook.activeSheet.protected}
/>
<SheetTabBar controller={workbook} labels={KOREAN_SHEET_TAB_BAR_LABELS} />
</>
);
}
將多個工作表一次性導出爲 .xlsx
import {
workbookToMultiSheetEntries,
exportMultiSheetXlsx,
triggerBlobDownload,
} from 'hyper-xl';
async function exportWorkbook() {
// getSnapshot 回調接收的是 Sheet 對象而非 sheetId。
const entries = workbookToMultiSheetEntries(
workbook.workbook,
(sheet) => ({
rows: data[sheet.id].rows,
columns,
cellFormats: formats[sheet.id],
merges: merges[sheet.id],
}),
// includeHidden 默認值爲 false:隱藏工作表會自動排除。
{ defaults: { dateFormat: 'yyyy-mm-dd' } },
);
// exportMultiSheetXlsx 返回 Blob — 保存文件用 triggerBlobDownload。
const blob = await exportMultiSheetXlsx(entries);
triggerBlobDownload(blob, 'workbook.xlsx');
}
拖動重排序 / 與隱藏工作表的座標一致性
<SheetTabBar /> 的放置處理器會以用戶所見的
顯示工作表 順序爲基準進行重排序,再將結果轉換爲整個
sheets 數組的絕對索引來
派發 'reorder'。因此即使隱藏工作表夾雜在顯示
工作表之間,視覺拖動結果與內部順序也不會
錯位。直接派發 'reorder' 時,請注意
toIndex 是 整個 數組的索引。
API 表面
-
Workbook{ sheets: ReadonlyArray<Sheet>; activeSheetId: SheetId }
純可序列化的形態:沒有類實例或閉包,可直接保存 / 恢復。
-
Sheet{ id: SheetId; name: string; color?: string | null; hidden?: boolean; protected?: boolean }
建議
hidden/protected僅在開啓狀態下作爲鍵:爲了保持外部相等比較的整潔,不保存 false。color可用null表示「無顏色」。 -
WorkbookActiondiscriminated union
{ type: 'add' | 'delete' | … }的 9 種變體。完整性違規時 reducer 返回相同引用。 -
SheetTabBarControllersubset of WorkbookController
只挑選標籤欄實際需要的回調的窄接口。使用自有派發器的使用方只要滿足此形態,即可直接嵌入組件。
API: XlReactProps
按功能整理的完整 prop 表。詳細背景請參閱各功能章節。
數據
- columnsAnyColumn[]
- rowsRow[]
- rowHeightnumber
- columnWidthnumber
- overscannumber
- classNamestring
- readOnlyboolean
- minColumnWidth / minRowHeightnumber
選區 & 編輯
- onSelectionChange(snapshot: SelectionSnapshot) => void
- onEditRequest(coord: CellCoord) => void
- onCellChange(change: CellChange) => void
- onCellsClear(payload: CellsClearPayload) => void
凍結窗格
- freezeFirstRow / freezeFirstColboolean
- freezeRowCount / freezeColCountnumber
行 / 列操作
- onRowsInsert / onRowsDelete
- onColumnsInsert / onColumnsDelete
- onRowsReorder / onColumnsReorder
剪貼板
- onCopy / onCut
- onPasteRequest / onPasteSpecialRequest
排序 & 篩選
- sortState / onSortStateChange
- filterState / onFilterStateChange
- filterPanelRows
- onSortAscending / onSortDescending / onSortCustomRequest
- onFilterByValueRequest / onClearFilterRequest
上下文菜單意圖
- onCellFormatRequest
- onInsertNoteRequest / onInsertHyperlinkRequest
撤銷
- enableUndo / undoMaxEntries / undoMaxBytes
縮放比例
- zoom / defaultZoom / onZoomChange
- showZoomControl / zoomMin / zoomMax
查看模式
- showGridlinesboolean
- showHeadersboolean
格式
- cellFormatsCellFormatResolver | CellFormatsMap
- onCellFormatsChange(next: CellFormatsMap) => void
- borderDrawToolBorderDrawTool | null
- borderDrawSideCellBorderSide
- mergesReadonlyArray<SelectionRange>
自定義渲染器
- cellRenderersCellRenderers(映射 | 解析器)
批註
- cellAnnotations
- annotationShowDelayMs / annotationHideDelayMs
聚合
- showSelectionStats / selectionStatsLocale
保護
- cellProtection / onProtectedAction
查找 & 替換
- enableFindReplaceboolean(默認 true)
數據驗證
- validationListsRecord<string, ValidationList>
行層級結構
- rowOutlineReadonlyArray<RowOutlineCell | null | undefined>
- onRowOutlineToggle(rowIndex: number, next: 'collapse' | 'expand') => void
- rowOutlineIndentPxnumber(默認 16)
API:類型定義
從 'hyper-xl' re-export。多數定義於 src/types/,
但部分(selection · editing · contextMenu · sortFilter · protection 系列)
由各 src/XlReact/… 模塊定義,再由 src/types/index.ts
重新 re-export。
核心類型
type Column<T = unknown>;
type AnyColumn = Column<any>;
type ColumnValidationResult = boolean | string;
interface Row {
id: string | number;
data: Record<string, unknown>;
height?: number;
level?: number; // §6.4 行層級深度
parentId?: string | number | null; // §6.4 顯式父引用
}
interface CellRendererProps<T> {
value: T;
row: Row;
column: Column<T>;
rowIndex: number;
columnIndex: number;
isEditing: boolean;
}
type CellEditCommitNav =
| 'enter' | 'shift-enter' | 'tab' | 'shift-tab' | 'none';
interface CellEditorProps<T> extends CellRendererProps<T> {
onCommit: (next: T, nav?: CellEditCommitNav) => void;
onCancel: () => void;
mode?: 'edit' | 'overwrite' | 'clear';
initialDraft?: string;
}
type CellRenderer<T> = (props: CellRendererProps<T>) => ReactNode;
type CellEditor<T> = (props: CellEditorProps<T>) => ReactNode;
// cellRenderers prop: 맵 또는 리졸버
type CellRenderersMap = Record<string, CellRenderer>;
type CellRendererResolver =
(rowIndex: number, columnIndex: number) => CellRenderer | undefined;
type CellRenderers = CellRenderersMap | CellRendererResolver;
選區 & 編輯類型
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
active: CellCoord;
ranges: ReadonlyArray<SelectionRange>;
}
interface CellChange {
coord: CellCoord;
columnId: string;
prevValue: unknown;
nextValue: unknown;
}
interface CellsClearPayload {
ranges: ReadonlyArray<SelectionRange>;
}
數據驗證類型
interface ColumnValidation {
listKey: string;
strict?: boolean;
}
interface ValidationListOption { value: string; label?: string; }
type ValidationListItem = string | ValidationListOption;
type ValidationList = ReadonlyArray<ValidationListItem>;
type ValidationLists = Readonly<Record<string, ValidationList>>;
// 정규화된 옵션: value/label이 항상 존재 (헬퍼 반환 형태)
interface ResolvedValidationOption { value: string; label: string; }
上下文菜單載荷
interface RowsInsertPayload { atIndex: number; position: 'above' | 'below'; count: number; }
interface RowsDeletePayload { rowIds: ReadonlyArray<Row['id']>; rowIndices: ReadonlyArray<number>; }
interface ColumnsInsertPayload { atIndex: number; position: 'left' | 'right'; count: number; }
interface ColumnsDeletePayload { columnIds: ReadonlyArray<string>; columnIndices: ReadonlyArray<number>; }
interface RowsReorderPayload {
rowIds: ReadonlyArray<Row['id']>;
rowIndices: ReadonlyArray<number>;
targetIndex: number;
}
interface ColumnsReorderPayload {
columnIds: ReadonlyArray<string>;
columnIndices: ReadonlyArray<number>;
targetIndex: number;
}
interface ClipboardCopyPayload {
ranges: ReadonlyArray<{ start: CellCoord; end: CellCoord }>;
text: string;
}
interface PasteRequestPayload { coord: CellCoord; ranges: ReadonlyArray<SelectionRange>; }
interface PasteSpecialRequestPayload { coord: CellCoord; }
interface SortColumnPayload { columnId: string; columnIndex: number; }
interface FilterByValuePayload { coord: CellCoord; value: unknown; }
interface CellFormatRequestPayload { coord: CellCoord; }
interface CellAddressActionPayload { coord: CellCoord; }
排序 & 篩選類型
type SortDirection = 'asc' | 'desc';
interface SortColumnEntry { columnId: string; direction: SortDirection; }
type SortState = ReadonlyArray<SortColumnEntry>;
interface ColumnValueFilter { selectedValues: ReadonlySet<string>; }
type FilterState = Readonly<Record<string, ColumnValueFilter>>;
const BLANK_FILTER_KEY: string;
function valueToFilterKey(value: unknown): string;
單元格格式類型
type CellHorizontalAlign = 'left' | 'center' | 'right' | 'justify' | 'distributed';
type CellVerticalAlign = 'top' | 'middle' | 'bottom' | 'distributed';
type CellBorderLineStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'thick';
interface CellFont {
family?: string;
size?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean | 'single' | 'double';
strikethrough?: boolean;
color?: string;
}
interface CellAlign {
horizontal?: CellHorizontalAlign;
vertical?: CellVerticalAlign;
wrap?: boolean;
indent?: number;
}
interface CellFill { backgroundColor?: string; }
interface CellBorderSide { style?: CellBorderLineStyle; color?: string; width?: number; }
interface CellBorder {
top?: CellBorderSide;
right?: CellBorderSide;
bottom?: CellBorderSide;
left?: CellBorderSide;
diagonalDown?: CellBorderSide; // ╲ 單元格內部對角線 (SVG 覆蓋層)
diagonalUp?: CellBorderSide; // ╱ 單元格內部對角線
}
interface CellFormat {
font?: CellFont;
align?: CellAlign;
fill?: CellFill;
border?: CellBorder;
numberFormat?: string;
}
type CellFormatsMap = Readonly<Partial<Record<string, CellFormat>>>;
type CellFormatResolver = (rowIndex: number, columnIndex: number)
=> CellFormat | null | undefined;
type CellFormats = CellFormatResolver | CellFormatsMap;
type NullablePatch<T> = { [K in keyof T]?: T[K] | null };
type CellFontPatch = NullablePatch<CellFont>;
type CellAlignPatch = NullablePatch<CellAlign>;
type CellFillPatch = NullablePatch<CellFill>;
type CellBorderSidePatch = NullablePatch<CellBorderSide>;
interface CellBorderPatch {
top?: CellBorderSidePatch | null;
right?: CellBorderSidePatch | null;
bottom?: CellBorderSidePatch | null;
left?: CellBorderSidePatch | null;
diagonalDown?: CellBorderSidePatch | null;
diagonalUp?: CellBorderSidePatch | null;
}
type CellFormatPatch = {
font?: CellFontPatch | null;
align?: CellAlignPatch | null;
fill?: CellFillPatch | null;
border?: CellBorderPatch | null;
numberFormat?: string | null;
};
type CellBorderPlacement = 'outline' | 'all' | 'top' | 'right' | 'bottom' | 'left' | 'none'
| 'diagonal-down' | 'diagonal-up';
type CellFormatPatchResolver = (
current: CellFormat | undefined,
coord: CellCoord,
) => CellFormatPatch | null | undefined;
interface CellFormatToolbarProps {
selection: SelectionSnapshot | null;
cellFormats?: CellFormatsMap;
onCellFormatsChange: (next: CellFormatsMap) => void;
disabled?: boolean;
className?: string;
// 字體 / 數字格式選項覆蓋
fontFamilies?: readonly CellFormatToolbarFontOption[];
fontSizes?: readonly number[];
numberFormats?: readonly CellFormatToolbarNumberFormatOption[];
// 合併整合 (吸收 CellMergeToolbar 功能)
merges?: ReadonlyArray<SelectionRange>;
onMergesChange?: (next: SelectionRange[]) => void;
onMergeClearCovered?: (ranges: SelectionRange[]) => void;
mergeLabels?: CellMergeToolbarLabels;
// 邊框繪製工具
activeBorderTool?: BorderDrawTool | null;
onBorderDrawToolChange?: (tool: BorderDrawTool | null, side: CellBorderSide) => void;
// 條件格式整合
conditionalRules?: readonly ConditionalRule[];
onConditionalRulesChange?: (next: ConditionalRule[]) => void;
columns?: readonly AnyColumn[];
conditionalFormatLabels?: Partial<ConditionalFormatToolbarLabels>;
// 單元格樣式預設整合
cellStyleRegistry?: CellStyleRegistry;
cellStyleApplyMode?: CellStyleApplyMode;
cellStyleLabels?: Partial<CellStyleToolbarLabels>;
// 格式刷 (Format Painter)
formatPainterArmed?: boolean;
onFormatPainterToggle?: () => void;
formatPainterLabel?: string;
}
function cellFormatKey(rowIndex: number, columnIndex: number): string;
function resolveCellFormat(
formats: CellFormats | undefined,
rowIndex: number,
columnIndex: number,
): CellFormat | undefined;
function applyCellFormatPatch(
formats: CellFormatsMap | undefined,
ranges: ReadonlyArray<SelectionRange>,
patch: CellFormatPatch | CellFormatPatchResolver,
): CellFormatsMap;
function applyCellBorderPatch(
formats: CellFormatsMap | undefined,
ranges: ReadonlyArray<SelectionRange>,
placement: CellBorderPlacement,
side: CellBorderSidePatch,
): CellFormatsMap;
批註類型
type CellAnnotationsMap = Readonly<Record<string, string>>;
type CellAnnotationResolver = (rowIndex: number, columnIndex: number)
=> string | null | undefined;
type CellAnnotations = CellAnnotationResolver | CellAnnotationsMap;
function cellAnnotationKey(rowIndex: number, columnIndex: number): string;
function resolveCellAnnotation(
annotations: CellAnnotations | undefined,
rowIndex: number,
columnIndex: number,
): string | undefined;
保護類型
type CellProtectionPredicate = (rowIndex: number, columnIndex: number) => boolean;
type ProtectedAction =
| 'edit' | 'clear' | 'paste' | 'fill' | 'cut' | 'move'
| 'replace' | 'rowDelete' | 'columnDelete';
interface ProtectedActionInfo {
action: ProtectedAction;
coords: ReadonlyArray<CellCoord>;
}
API:工具函數
從 'hyper-xl' export 的主要工具函數(代表性項目)。
root 除此列表外還 export 了大量與格式·條件格式·單元格樣式·合併·查找·透視·工作表·打印·公式
相關的輔助函數與組件 — 完整列表請參閱各功能章節以及
src/index.ts。
-
computeAggregates(ranges, rows, columns) => SelectionAggregates
與狀態欄相同的 SUM / AVG / COUNT / MIN / MAX 計算。
-
processInChunks(items, perItem, options?) => Promise<void>
paste / fill 所使用的異步分塊迭代輔助函數。回調
perItem(item, index)是第二個參數,選項爲{ chunkSize?, signal? }。 -
BoundedUndoStackclass (options: BoundedUndoStackOptions)
帶有條目數 + 字節上限的 standalone undo 存儲。
-
defaultColumnLabel(index: number) => string
A1 風格列標籤:0 →
'A',26 →'AA'。 -
defaultRowLabel(index: number) => string
1-based 行標籤:0 →
'1'。 - cellAnnotationKey(row, col) => string
- resolveCellAnnotation(annotations, row, col) => string | undefined
- cellFormatKey(row, col) => string
- resolveCellFormat(formats, row, col) => CellFormat | undefined
- applyCellFormatPatch(formats, ranges, patch) => CellFormatsMap
- applyCellBorderPatch(formats, ranges, placement, side) => CellFormatsMap
- valueToFilterKey(value: unknown) => string
-
findMatches(args: FindMatchesArgs) => FindMatch[]
查找 / 替換對話框所使用的行優先匹配引擎。
-
replaceInValue(value, query, replacement, options) => string | null
單個單元格值的替換結果(未匹配時爲
null)。 - compileMatcher(query, options) => CompiledMatcher
- cellValueToString(value: unknown) => string
-
resolveColumnList(column, validationLists) => ResolvedValidationOption[] | null
規範化列的驗證列表。如果不是列表單元格,則爲
null。 -
filterOptions(options, query) => ResolvedValidationOption[]
與搜索框相同的 label · value 部分匹配(忽略大小寫)。
-
isValueInList(value, options) => boolean
與 strict 驗證規則相同。空值始終爲
true。 - normalizeList(list) => ResolvedValidationOption[]
- normalizeOption(item) => ResolvedValidationOption
-
FormulaSheetclass
管理 raw + 顯示值 + 依賴圖的工作表輔助器。 參見公式引擎。
-
parseFormula(input) => FormulaAst | FormulaParseError
四則運算 + 單元格引用語法的解析器。
-
evaluateAst(ast, resolveRef) => number | FormulaErrorCode
用單元格值解析器對解析後的 AST 求值。包含錯誤傳播。
- extractRefs(ast) => { row, col }[]
-
a1ToCoord / coordToA1A1 ↔ {row,col} 轉換(允許
$標記) -
parseA1(ref) => ParsedCellRef | null
拆解並返回包含
$標記的絕對/相對信息。 -
shiftFormulaRefs(formula, deltaRow, deltaCol) => string
移動公式中的相對引用,並固定
$絕對引用:自動 填充引擎在內部使用。 -
isFormulaError(value) => boolean
檢查是否爲
FORMULA_ERROR_CODES之一。 -
SplitPaneViewcomponent @experimental
分割面板 wrapper:1 / 2 / 4 面板 + 成對面板間的滾動同步。
-
useFullscreen(ref) => { isFullscreen, isSupported, request, exit, toggle }
Fullscreen API + vendor-prefix 回退。宿主 unmount 時自動解除 fullscreen lock。
-
useWorkbookBroadcast<T>(channelName) => { latest, broadcast, isSupported }
同源 BroadcastChannel 封裝器:用於新窗口同步。
-
openSheetInNewWindow(options?) => Window | null
window.open封裝器。默認 target 爲可複用的命名窗口。
功能變更歷史
截至當前修訂版已合併的功能(按最新順序)。