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,但组件照常渲染(聚焦的只读 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
—— 不会作为包 API export 的实现细节),
构建器 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 个透视表上各铺一份而重复)。 若要以单一 cadence 一起刷新 scope 内的所有透视表,请在 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 为可复用的命名窗口。
功能变更历史
截至当前修订版已合并的功能(按最新顺序)。