hyper-xl 参考文档

在网页上提供与 Excel 完全一致的网格 UX 的 React 库。在一个页面中涵盖所有已发布的功能。

▶ 实时演示 · 由真实 hyper-xl 驱动的交互式演示(网格 + 实时代码编辑器)请见 查看演示 →
许可证 · hyper-xl 基于 HyperEZ Source-Available License 分发。评估、学习和非商业用途免费。商业或生产用途需要单独的许可证。联系 support@hyperez.io。参见 LICENSE

简介

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 || ^19
  • react-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} />;
}
可编辑的受控网格示例 tsx · ~30行

只要接上 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 tsx · ~45行

在实际应用中,将编辑 / 删除 / 粘贴 / 填充全部汇集到同一个 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 令牌覆盖示例 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 是该库的核心网格组件。它以完全受控模式 运行,因此网格绝不会拥有行数据。传入 columnsrows,并接上与所需功能对应的回调即可。 除网格外,工具栏、对话框等辅助组件(CellFormatToolbarCellMergeToolbarConditionalFormatToolbarFindReplaceDialogValidationDropdownExportButtonImportDialogPivotBuilderPivotChartSheetTabBarPrintPreviewFormulaBar 等)也都从 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

    将该列的所有单元格标记为保护状态。与网格的 cellProtection prop 取并集。

  • validation{ listKey: string; strict?: boolean }

    将列关联到 validationLists 中命名的列表,以 启用下拉选择。当 stricttrue 时,会将不在列表中的值以 invalid 样式标记。详情请参阅 数据验证 (下拉) 章节。

  • autoCompleteboolean

    §2.3:在编辑模式下,会将同一列已有值中以输入值为 prefix 的第一个候选 以内联 ghost-text 形式提示。通过 Tab / (当光标 位于末尾时)采纳,并通过 Esc 仅关闭候选。忽略大小写 (prefix 匹配):采纳时使用原始大小写。默认值 false

Row

  • idstring | number

    稳定的标识符。删除 / 重新排序的载荷会使用该 id。

  • dataRecord<string, unknown>

    不透明的行数据。网格不会直接读取,只会调用 Column.accessor

  • heightnumber

    初始行高(px)。

自定义单元格渲染器:进度条 tsx · ~25行
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>
  ),
};
自定义编辑器:选择框 tsx · ~30行
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

单元格选区

FeatureHigh 同时支持单个 / 范围 / 多个非连续选区的核心选区系统。

功能

  • 点击指定活动单元格。F2 / 双击进入编辑模式。
  • Enter(↓) / Tab(→) / Shift+Enter(↑) / Shift+Tab(←) 移动。Esc 取消编辑。
  • 拖拽选择矩形范围。Shift+点击 / Shift+方向键扩展范围。
  • Ctrl+A 在数据区域 → 整个工作表之间切换。Shift+Ctrl+方向键扩展至数据末尾。
  • Ctrl+点击 / Ctrl+拖拽添加非连续范围。
  • 点击行 / 列标题选择整个轴。

API

  • onSelectionChange(snapshot: SelectionSnapshot) => void

    每当用户看到的选区(活动单元格或范围列表)发生变化时调用。初次挂载时不会调用。

将选区状态同步到外部 tsx
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>
    </>
  );
}
相关类型 typescript
interface CellCoord { row: number; col: number; }
interface SelectionRange { start: CellCoord; end: CellCoord; }
interface SelectionSnapshot {
  active: CellCoord;
  ranges: ReadonlyArray<SelectionRange>;
}

编辑 & 验证

FeatureHigh 包含韩文 IME 在内、与 Excel 等同的输入流程。

编辑进入模式

  • 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 组合示例 tsx · ~25行

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 tsx
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)

FeatureHigh 与 Excel 双向兼容的复制 / 剪切 / 粘贴。

动作

  • 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
注意:带类型列的 paste: 自动 paste 的 onCellChange 所携带的 nextValue 始终为 string(剪贴板文本)。数字 / 日期列请在 reducer 内部进行 coerce。
数字列 paste 时进行 coerce tsx
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 禁用) tsx
<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);
  }}
/>

填充手柄 & 快捷键

FeatureMedium Excel 风格填充手柄 + Ctrl+D / Ctrl+R / Ctrl+Enter。

功能

  • 将活动单元格的填充手柄(右下角的小方块)拖拽至相邻单元格。
  • 单个值 → 重复。两个值作为种子 → 线性序列 (例如:1,2 → 3,4,5)。
  • 日期序列会按 step(日/月/年)自动检测。
  • 双击手柄 → 自动填充至左侧列数据的末尾。
  • Ctrl+D 从上向下填充。
  • Ctrl+R 从左向右填充。
  • Ctrl+Enter 将整个选区填充为活动值。

所有被填充的单元格都会流经 onCellChange。受保护的单元格会被自动排除。

将填充与外部副作用绑定 tsx

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;
    });
  }
};

撤销 / 重做

FeatureHigh 受条目数与 字节限制的 undo 栈。

动作

  • 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) tsx
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,
    });
  }
};

行 / 列操作

FeatureHigh 调整大小 / 插入 / 删除 / 重新排序 / 隐藏 / 冻结。

调整大小

  • 拖拽标题边界调节宽度 / 高度。
  • 双击列标题边界 → 适应内容(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 tsx
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) tsx
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)

FeatureMedium PRD §6.4:多级父/子树、由使用方持有的 collapse 状态,在第一个数据列 自动应用 ▶ / ▼ 展开收起部件与按层级的缩进。

数据模型

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 仅采用首次出现的 (之后忽略)。可用 useMemorows identity 缓存。

  • 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) tsx
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

排序 & 筛选

FeatureHigh 受控的多列排序 + 按列值筛选。

排序

  • 在单列上点击表头会在 ascdescnone 之间循环。
  • 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 源数据 (可选)。

在消费方一侧应用排序 tsx
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 收窄行 tsx
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}
/>
排序 / 筛选相关类型 typescript
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__';

虚拟化 & 性能

FeatureHigh 双轴 虚拟化在 1M+ 单元格下仍保持 60fps。

特性

  • 行 / 列虚拟化始终开启。无需 opt-in。
  • 单元格组件通过 React.memo 复用。
  • 滚动通过 requestAnimationFrame 进行 coalesce。
  • 大规模 paste / fill(10k+) 通过 processInChunks 辅助方法分块。
  • Undo 栈以条目数 + 字节数为上限 (撤销)。

API

  • overscannumber

    在视口外预先渲染的行 / 列数量。

  • rowHeightnumber
  • columnWidthnumber
用 processInChunks 异步应用 1 万行 ts
import { processInChunks } from 'hyper-xl';

await processInChunks(
  largePasteCells,
  (cell) => applyEdit(cell),
  { chunkSize: 500 },
);

键盘 & 上下文菜单

FeatureHigh 与 Excel 同等的 键盘映射与右键菜单。

键盘映射

按键操作
← ↑ → ↓移动一格
Tab / Shift+Tab右 / 左
Enter / Shift+Enter下 / 上
Home行的首个单元格
Ctrl+HomeA1
End → 方向键跳到数据末尾
Ctrl + 方向键数据区域末端
Page Up / Down按屏上/下
Alt+Page Up / Down按屏左/右
F2编辑活动单元格
Esc取消编辑 (若显示 AutoComplete 候选项,则先仅关闭候选项)
Alt+Enter在编辑模式单元格内部换行 (§2.1)
Tab / 在编辑模式下采纳 AutoComplete 候选项 ( 仅在光标位于末尾时生效,§2.3)
Delete删除值
Ctrl+1onCellFormatRequest
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)

查找 & 替换

FeatureHigh 值的查找与替换: 网格内置对话框。

特性

  • 网格内置:无需额外接线即可工作。聚焦于表格后用快捷键打开。
  • 选项:区分大小写 · 全部匹配(整个单元格内容) · 正则表达式(支持 $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)。

用纯引擎直接搜索 / 替换 typescript
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'

工作表缩放

FeatureMedium 10% ~ 400% 缩放,Ctrl+滚轮 + 右下角小部件。

特性

  • 缩放是线性的:行高、列宽、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 + 外部切换 tsx
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}
    />
  </>
);

视图模式(分割 / 新窗口 / 全屏)

FeatureMedium §13:网格线·表头切换(P1)、4 分割面板、新窗口同步、Fullscreen API。

特性

  • 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:未来将以网格的显式 scrollTo API 取代对 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 分割 + 新窗口同步 tsx
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} />}
      />
    </>
  );
}
全屏切换 tsx
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>
  );
}

打印(预览 / 分页 / 页眉·页脚)

FeatureMedium §12:打印预览 · 打印区域 / 分页符 · 页眉·页脚占位符 · 行/列重复 · 纸张/方向/缩放/边距。

特性

  • 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 换算常量、选项默认值、韩语标签预设。

预览 + 控制器钩子 + 直接打印 tsx
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}
      />
    </>
  );
}
分页符预览叠加层 tsx
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 / 服务端渲染复用) tsx
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

FeatureMedium 消费者拥有的每单元格视觉格式由网格渲染,并由 toolbar 编辑。

特性

  • cellFormats 以映射或函数注入。网格不拥有格式状态。
  • CellFormatToolbar 是库提供的 UI,它接收选区和可写的 CellFormatsMap,并通过 onCellFormatsChange 返回新的 map。
  • 将字体 family / size / bold / italic / underline / strikethrough / color 以内联样式应用。
  • 按单元格渲染横向·纵向对齐、换行、缩进、背景色、上/下/左/右边框。
  • 横向对齐支持 'left' / 'center' / 'right' / 'justify'(两端对齐)/ 'distributed'(均匀分布:最后一行也均匀对齐)。纵向支持 'top' / 'middle' / 'bottom' / 'distributed'(stretch)。
  • 边框中 BoxTopRightBottomLeft 仅应用于选区外缘矩形,只有 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 + formatPainterArmed props,即可暴露相同行为的 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

    接收 selectioncellFormatsonCellFormatsChange,将字体·对齐·填充·边框的变更应用到选区。 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 tsx
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)

FeatureMedium 将单元格值按 Excel 格式代码转换为显示字符串的纯引擎。

特性

  • formatCellValue(value, format, locale?) 是不依赖 UI 的纯函数。可在没有网格的情况下单独使用。
  • 仅当指定了 cellFormat.numberFormat 且该列没有 cellRenderer 时,才在渲染时应用。cellRenderer 始终优先。
  • 编辑过程中始终显示原始值。格式转换仅用于显示,所保存的值不会改变。
  • CellFormatToolbar 内置了数字格式下拉框(常规·数字·货币·会计·百分比·指数·分数·日期·时间·文本)以及增加/减少小数位按钮,可对选区应用格式代码。可通过 numberFormats prop 替换列表。
  • 支持常规(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>

    常用格式代码常量的集合。GENERALINTEGERNUMBERTHOUSANDSCURRENCY_KRWCURRENCY_USDACCOUNTING_KRWPERCENTSCIENTIFICFRACTIONDATE_ISODATE_KOTIME_HMDATETIMETEXT 等。

  • increaseDecimals / decreaseDecimals(format) => string

    将格式代码的小数位增加或减少一位,返回新的格式代码。这是与 Excel 的 “增加/减少小数位” 按钮对应的 API 级辅助函数。

  • adjustFormatDecimals(format, delta) => string

    delta 增减小数位的底层辅助函数。 increaseDecimals / decreaseDecimals 对其进行封装。

  • CellFormatToolbar · numberFormats{ label, value }[]

    替换工具栏数字格式下拉框的预设列表。value 是格式 代码,空字符串('') 映射为常规(General),会清除单元格的 numberFormat。省略时使用默认的韩语预设列表。

单元格样式 / 主题预设 (Cell Styles)

FeatureLow 将带名称的 CellFormat 捆绑(内置预设 + 用户样式)合成到选区的 纯模型 + 画廊工具栏。(规格书 §7.7)

特性

  • 单元格样式是带名称的 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 传入 cellStyleRegistry prop 来整合同一个下拉框(与合并·条件格式相同的 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'。若 formatundefined(或空格式) 则清除区域的格式(标准)。绝不 修改输入映射。

  • 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)

FeatureLow 将规则列表还原为逐单元格格式·装饰的纯求值器 + 数据条 / 图标渲染。

特性

  • evaluateConditionalFormats(rules, rows, columns, options?) 是不依赖网格状态的纯函数。用各列 accessor 返回的原始值进行比较,因此与数字显示转换(number format) 无关,且易于单元测试。
  • 返回值为 { formats, decorations }formats 是以 "row:col" 为键的 CellFormatsMap,可直接连接到 cellFormats prop。
  • 支持的规则:值比较(大于等于/小于等于/介于)、前 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=月)。
  • makeConditionalCellRenderercellRenderer 没有索引,所以通过 row.id / column.id 反查单元格。请向求值器和渲染器传入相同的 rows / columns 数组(顺序·id 一致)。请用 useMemo 稳定返回的渲染器以及用它生成的列。若渲染器标识改变,列对象会重新生成,导致逐单元格 memo 失效,所有装饰单元格都会重新渲染。
  • 与数字格式的组合:若单元格被设置了 cellRenderer,则会绕过网格的 numberFormat 显示路径。因此若要让数据条 / 图标旁的值也保留数字格式,请向 makeConditionalCellRenderer 通过 options.cellFormats 传入与网格相同cellFormats。这样值会经 formatCellValue 转换,与 Cell.tsx 显示方式一致(例如显示为 ₩1,234,567 而非 1234567)。优先级顺序为 options.baseRenderernumberFormat → 原始值。
  • 求值器为了范围统计(百分位·前 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 的选项。值显示优先级为 baseRenderercellFormatsnumberFormat → 原始值,若存在 baseRenderercellFormats 被 忽略。

  • ConditionalDataBar / ConditionalIconcomponent

    直接渲染装饰时使用的表现型组件。可在自定义 cellRenderer 中 组合。

  • resolveConditionalDecoration(decorations, rowIndex, columnIndex) => ConditionalDecoration | undefined

    按索引查询装饰的辅助函数(与 resolveCellFormat 相同的模式)。

  • ConditionalFormatToolbarcomponent

    Excel 风格的 “条件格式” 下拉框。是与 CellMergeToolbar 相同的 受控组件,接收 selection · columns · rules · onRulesChange,针对选区的列 添加/删除规则。菜单:突出显示单元格(大于·小于·介于,输入值) · 前/后 · 高于·低于平均值 · 重复·唯一 · 数据条·色阶·图标集 · 清除规则(选区 / 全部)。新规则添加到数组前部,具有最高优先级。也可通过向 CellFormatToolbar 传入 conditionalRules / onConditionalRulesChange / columns 来整合到格式工具栏 (与合并控件相同的模式)。

  • 规则构建辅助函数build* / clear* / selectionColumnIds

    自行构建工具栏时使用的纯辅助函数:selectionColumnIdsbuildDataBarRule · buildColorScaleRule · buildIconSetRule · buildCellValueRule · buildTopBottomRule · buildAverageRule · buildDuplicateRuleappendRuleclearRulesForColumns · clearAllRulesDEFAULT_HIGHLIGHT_FORMAT

自定义单元格渲染器 (Custom Cell Renderer)

FeatureHigh 在单元格内绘制任意 React 元素的显示渲染器 + 编辑渲染器。在列·单元格两个级别指定。

特性

  • 显示(Display) 与编辑(Edit) 分离cellRenderer 仅用于屏幕显示,cellEditor 仅用于通过 F2 · 双击 · 输入进入的编辑。两者均可选,若没有则回退到默认文本显示 / 默认输入编辑器(既有行为不变)。
  • 两级指定:列级(Column.cellRenderer · Column.cellEditor) 和单元格级(cellRenderers prop)。单元格级会覆盖列级。
  • cellRendererscellFormats · 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

    cellRenderers prop 的类型。 CellRenderersMapRecord<"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)

FeatureHigh 网格将消费方拥有的合并区域渲染为单个锚点单元格,并由 toolbar 进行编辑。

特性

  • merges 是矩形范围(SelectionRange)的数组。网格不拥有合并状态,仅负责渲染。
  • 每个合并区域绘制为左上角的单个锚点单元格,并横向·纵向 span 整个区域。被遮盖的单元格不会被渲染。
  • 点击合并区域内部时,整个区域被选中,active 单元格固定在锚点上。
  • 方向键移动会跳过合并边界,使光标不会被困在锚点中,而是移出区域之外。
  • 即使锚点行滚动到屏幕之外,只要与虚拟化窗口重叠,span 就会继续渲染。
  • 合并 / 取消合并 / 合并并居中控件,只要将 merges / onMergesChange 传给 CellFormatToolbar,就会集成到格式工具栏中。若想单独使用,也照样提供 CellMergeToolbar
  • Merge & Center 在合并的同时对锚点应用水平居中对齐。直接使用传给 CellFormatToolbarcellFormats / onCellFormatsChange
  • 网格默认保留被遮盖单元格的值。若想像 Excel 那样仅保留左上角并清空其余,可选用 onMergeClearCovered,由消费方直接清除收到的范围(也可用 coveredCellRanges 辅助函数计算)。仅清空值,被遮盖单元格的 cellFormats 保持原样。
  • 一次合并会依次调用多个回调(onMergesChange → 若居中则 onCellFormatsChangeonMergeClearCovered)。使用撤销功能的应用应将它们打包为一个事务,以便一次性回退。
  • 合并坐标是当前视图的 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 工具提示)

FeatureMedium 在开发端注入的单元格级工具提示。

特性

  • 有批注的单元格在右上角显示一个小三角形指示器。
  • 悬停时显示标准工具提示:进入/离开延迟,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)。

从列元数据构建批注映射 tsx
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 ts
import { cellAnnotationKey, resolveCellAnnotation } from 'hyper-xl';

cellAnnotationKey(0, 1);                  // '0:1'
resolveCellAnnotation(map, 0, 1);         // 'Hover me' | undefined

单元格保护 (read-only)

FeatureHigh 在所有编辑 surface 上一致地强制单元格级 read-only。

覆盖范围

保护基于位置(行 / 列索引),并与 Column.readOnly 取 union。受保护的 单元格会全部阻止以下操作:

  • edit:F2、双击、输入覆盖、Backspace 初始化
  • clear:对非空选区按 Delete
  • paste:Ctrl+V、native paste、右键粘贴
  • fill:填充手柄、Ctrl+D、Ctrl+R、Ctrl+Enter
  • cut:Ctrl+X 的 clear-half
  • move: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 消息 tsx
<XlReact
  columns={columns}
  rows={rows}
  cellProtection={(rowIndex) => rowIndex === 0}
  onProtectedAction={(info) => {
    toast(`此单元格已受保护 (${info.action} · ${info.coords.length}个)`);
  }}
/>
ProtectedAction 类型 typescript
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>;
}

选区聚合

FeatureMedium 左下角状态栏的实时 SUM / AVG / COUNT。

行为

  • 仅当选区覆盖 2 个以上单元格时才渲染。
  • SUM / AVG 忽略非数字单元格。
  • AVG 分母排除空单元格。
  • COUNT 与 Excel COUNTA 相同(非空单元格)。
  • MIN / MAX 已预先计算并暴露:可用于自定义 readout。

API

  • showSelectionStatsboolean
  • selectionStatsLocalestring | string[]

    BCP-47 区域设置(例如 'ko-KR')。

用 computeAggregates 构建自定义状态栏 tsx
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>

数据验证 (下拉框)

FeatureHigh 将列关联到命名列表,在单元格中通过下拉框选择值。

特性

  • validationLists 定义命名列表,并通过 Column.validation.listKey 关联到列。列表数据归消费方 所有,网格只读取。
  • 条目同时支持字符串('活跃')或对象({ value, label }): 对象在下拉框中显示 label,但在单元格中存储 value(例如代码)。
  • 活动列表单元格会显示 ▾ 箭头,当条目超过 8 个时会自动出现搜索框 (label · value 部分匹配,忽略大小写)。
  • 选择值后单元格选区仍保持不变(与 Excel 一致:不会自动移动)。commit 通过 onCellChange流转,且仅在值发生变化时才记录到撤销栈。
  • validation.stricttrue时,不在列表中的非空值会以 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。

将状态 / 始发港列设为下拉框 tsx
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)' },
    ],
  }}
/>
用列表辅助函数直接处理验证 / 筛选 typescript
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: 四则运算 + 单元格引用)

FeatureMedium=A1+B1·=A1*B1/2等单元格引用与四则运算进行求值, 当依赖单元格发生变化时自动重新计算。

特性

  • 纯函数分词器 → 解析器 → 求值器 + FormulaSheet辅助函数。 网格仅用于显示,当使用方通过onCellChange将 raw 输入传递给工作表后,工作表会沿依赖图刷新结果。
  • 支持的语法:整数 / 小数、一元符号(-A1)、 四则运算(+ − * /)、括号、A1 形式的相对/绝对单元格引用(含多字符列 :AA1AB10$A$1$A1A$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 tsx
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 即可调用求值器 typescript
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
}
$绝对引用 + 自动填充时的相对引用平移 typescript
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)

FeatureMedium 与 Excel 的公式输入栏外观 · 行为完全一致的独立组件。 可将名称框 · fx · 输入框 · ✓/✗ 按钮捆绑为一行,停靠在网格 正上方。

特性

  • 这是一个受控组件。从外部注入activeRef(A1 字符串) · value(raw 公式或字面量),并通过onCommit(next)接收 用户确认的值,反映到工作表模型(如FormulaSheet.setRaw)。
  • 内部 draft 字符串保存在组件内部,因此每次按键不会导致父组件 重新渲染。当value prop 发生变化时(外部 paste·undo 等),仅在非编辑状态下才覆盖 draft。
  • Enter / ✓ / 失焦 → commit,Esc / ✗ → cancel。 与 Excel 一致,失焦也按 confirm 处理。
  • 中日韩 IME 组合守卫:compositionstartcompositionend 之间的 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 tsx
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!));
      }}
    />
  </>
);
为名称框连接导航 tsx
// 当使用方可以控制活动单元格时(例如自有 selection 模型),
// 接收 onNavigate 并用自己的 reducer 跳转。
<FormulaBar
  activeRef={activeRef}
  value={value}
  onCommit={handleCommit}
  onNavigate={({ row, col }) => selectionDispatch({ type: 'moveTo', row, col })}
/>

导入 / 导出 (Excel · CSV · TSV)

FeatureHigh 将网格快照序列化为.xlsx或 RFC 4180 CSV / TSV, 并将相同格式重新吸收回网格。ExcelJS 通过 await import('exceljs')延迟加载,因此主包 保持无依赖。

特性

  • 基于快照的 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>

    将多个快照按工作表分组写入一个工作簿。每个条目同时接收 snapshotExportOptions(每工作表的 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:与演示 相同的模式 tsx
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} />
    </>
  );
}
编程方式:仅使用函数 typescript
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 });
多工作表 + 公式往返 typescript
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: '库存' },
]);

透视表

FeatureHigh PRD §10A:Row / Column / Value / Filter 四个区域的拖放 构建器,9 种聚合,值显示格式共 13 种(P1 6 种:常规 / 总计 % / 列 % / 行 % / 父行 % / 父列 % + §10A.4 P2 计算模式 7 种:差异 / % 差异 / 累计 / % 累计 / 升序·降序排名 / 索引),行·列标签上的分组(日期:年/季度/月/周/日 · 数字:任意 区间),总计行·列,单个透视表刷新,透视图(柱状·折线·饼图) 全部包含的 MVP。结果表以库标准 <XlReact> 网格 渲染,单元格选区 · 键盘导航 · 剪贴板 · 缩放 · Freeze 等网格的 所有 UX 均可原样使用。

组成元素

  • 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)

总计不是单元格之和

总计行 / 列不是以本体单元格之和计算的。它始终在全部原始行 上重新运行 aggregateaverage· min·max·variance·stdDev· product 不满足分配律,因此把单元格相加会得出错误的值。 这也是为何即便在单元格为空的位置(matrix[r][c] === null),总计 仍能被正确计算的原因。

无头使用:用 buildPivot 仅获取结果 tsx
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 tsx
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> 渲染 tsx

当你想制作自定义侧边栏,仅将结果以标准网格呈现时。 直接使用 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 种模式重新表现。所有 % 模式都将分子 除以各模式对应的分母,当分母为 null0 时 单元格会变为空(null)。网格将 % 模式单元格以 Intl.NumberFormat({ style: 'percent', maximumFractionDigits: 2 }) 绘制。

  • normal默认值

    原始聚合本身。

  • percentOfTotal总计 %

    单元格 / 整体总计。网格的总计单元格为 100%。

  • percentOfColumn列 %

    单元格 / 该列合计。各列的总计行为 100%。

  • percentOfRow行 %

    单元格 / 该行合计。各行的总计列为 100%。

  • percentOfParentRow父行 %

    在多层级行透视中,单元格 / 上级分组合计。在最顶层行,父级与整个 数据集相同,因此结果上等同于对各列合计的比率。

  • percentOfParentColumn父列 %

    列轴的对称形式。在多层级列中具有意义。

引擎在将原始聚合原样保留于 matrix / grandTotal* 的同时, 将用于显示的转换单独填充到 displayMatrix / displayGrandTotal*。无头用户读取原始值, 网格读取 display*,两种视角在同一个 PivotResult 中同时共存。

值显示格式使用示例 tsx
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~100100~200、…

在启用了分组的标签上,标签旁会显示 [季度][10] 这样的小徽标。无法强制解析的值(空单元格、 非 ISO 格式的字符串等)不会被分组,而是落入既有的 (空白) 或 原始值桶:因此分组行与非分组行可以在同一结果中共存。

引擎在内部 readField 阶段将值归一化为桶(bucketize, 非公开实现),因此所有后续路径(表头树 · 父桶 · % 模式分母)都无需改动即可 运作。也就是说,即便在按季度分组的行 × 按季度分组的列之上,§10A.4 的 percentOfParentRow 这类显示格式也能原样保有其意义。

分组使用示例 tsx
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 应用于行或列 整个轴。有 topNtop / bottom 方向与 naboveAverage / belowAverage (以轴平均为基准)、threshold(数值比较)共四种。Top-N 会保留边界值处的所有同分项(Excel 行为)。

在应用了排序·筛选的标签上,标签旁会出现小徽标(↑ / ↓ / ⇅ / ⚑)。 被标签筛选与值筛选裁掉的行/列叶子会从表头中移除,总计与 % 显示模式的分母也会重新计算为只合计可见数据。也就是说,在 “高于平均 + 总计 %”组合中,被筛选掉的行也会从分母中排除。

排序 · 筛选使用示例 tsx
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 的紧凑/概要小计行外观相同。

布局选项使用示例 tsx
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 绑定到一个作用域 tsx
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(独立工作表/标签页/对话框)。 如果注册了回调,则不会打开默认模态框, detailsPivotDrillDownDetailsrows 是从源数组中提取的 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 提取原始行 tsx
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)。 直接注入 PivotBuilderinitialConfigbuildPivot 即可。

  • 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) · 航次 · 许可状态 · 生产进度(%) 的顺序暴露。 直接传给 PivotBuilderavailableFields 即可 让预设与数据集原封不动地结合。

PivotBuilderuncontrolled 组件,因此 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)度量值 保持原始吨数不变,以便确认分母。

立即应用预设 + 数据集 tsx
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 芯片上都附有 ⎘ 复制按钮,点击一次即可将相同的芯片复制 到下一个槽位,并更改为不同的 aggvalueDisplay

值显示格式: 计算模式 (§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% 差值

    (当前 − 之前) / 之前。若之前的值为 0null 则为 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 中移除以恢复为全部通过。 未指定 idfield 将用作标识符。

  • <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"家族的内置调色板。 给表头 / 小计 / 总合计 / 条纹各自施加不同的颜色。 若为 nonecellFormats 保持为 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)

PivotBuilderrows 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

    将多个 RowSourcerows 按输入顺序拼接起来的虚拟源。当输入之一 发生变更时,结合结果也会重新发布。“基于其他网格 的透视表”场景: 即使网格 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 中 tsx
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 绘制 tsx
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)

FeatureHigh PRD §14:拥有多个工作表的工作簿模型。在一处统一管理工作表的添加 / 删除 / 重命名 / 标签颜色 / 顺序移动 / 复制、隐藏·显示、保护(只读)元数据。 <XlReact> 始终只绘制单个工作表的行 / 格式,因此每个工作表的载荷(rows / cellFormats / merges)由使用方 以 Record<SheetId, …> 自行保存。库 只处理 "存在哪些工作表、当前哪个工作表处于活动状态" 这类元数据。

构成要素

  • createWorkbook(options?):创建一个拥有初始工作表列表和 activeSheetIdWorkbook 对象。 如果所有种子工作表都是 hidden: true,则第一个工作表会自动 提升为显示状态。
  • workbookReducer(workbook, action):纯 reducer。 处理 'add' | 'delete' | 'rename' | 'recolor' | 'reorder' | 'duplicate' | 'setHidden' | 'setProtected' | 'activate' 这 九种 action,对于完整性违规(重名·重复 id·删除最后一个 工作表·隐藏最后一个显示工作表)则以返回相同引用予以拒绝。
  • useWorkbook(options?):将 workbookReduceruseReducer 包裹,一次性提供稳定的回调(addSheetdeleteSheetrenameSheet,…)以及 派生值(activeSheetvisibleSheetshiddenSheets)。
  • <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 tsx
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 tsx
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。

核心类型 typescript
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;
选区 & 编辑类型 typescript
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>;
}
数据验证类型 typescript
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; }
上下文菜单载荷 typescript · ~30行
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; }
排序 & 筛选类型 typescript
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;
单元格格式类型 typescript
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;
批注类型 typescript
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;
保护类型 typescript
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 为可复用的命名窗口。

功能变更历史

截至当前修订版已合并的功能(按最新顺序)。

功能 类型 优先级 完成日期
打印 (Print: 预览模态 · A4/A3/A5/Letter/Legal/Tabloid 纸张 + 横向/纵向 · 边距 / 缩放(10~400%) · 页眉·页脚 3-zone + &P/&N/&D/&T/&F/&A 占位符 · 打印区域 · 行/列重复 · 分页预览叠加层 · @media print 分页符 · 纯 paginate() 引擎(可复用于 PDF))FeatureMedium2026-05-29
视图模式 (View Modes: 网格线 / 表头显示·隐藏 · 1 / 2 / 4 分割面板 + 滚动同步 · 新窗口 BroadcastChannel 同步 · Fullscreen API)FeatureMedium2026-05-28
工作表 / 标签 (Workbook: 多工作表模型 · 添加 / 删除 / 重命名 / 标签颜色 / 拖动重排 / 复制 · 隐藏 · 保护(只读) · 各工作表的载荷由使用方所有 · 多工作表 .xlsx 导出桥接 · 英语 / 韩语标签预设)FeatureHigh2026-05-28
透视表 (Pivot Table: 4 区域拖放构建器 · 9 种聚合 · 13 种值显示格式(P1 % 6 + P2 计算模式 7) · 行/列分组(日期单位 · 数字区间) · 排序·标签筛选·轴值筛选(Top N / 高于·低于平均 / 阈值) · 分类汇总位置 · 报表格式(紧凑/大纲/表格) · 空单元格显示值 · 总计 · 多透视表批量刷新 · 详细信息 (Show Details: 双击值单元格提取源行) · 预设透视表 (布线申请合计 / 月度累计 / 计划对比实绩 %) · 透视图 (柱形 / 折线 / 饼图) · 外部 · 动态数据源 (RowSource 适配器: 其他网格 / DB 查询 / 添加行时自动重算) · 用 XlReact 网格渲染结果)FeatureHigh2026-05-28
行层级结构 (Row Hierarchy / Grouping: 多级树 + 折叠/展开)FeatureMedium2026-05-27
导入 / 导出 (Excel · CSV · TSV: 格式 · 公式 · 多工作表 · 列映射 · 验证报告)FeatureHigh2026-05-27
公式引擎 (Formula Engine: 四则运算 + 单元格引用 + $ 绝对引用 + 自动填充时相对移动)FeatureMedium2026-05-27
自定义单元格渲染器 (Custom Cell Renderer)FeatureHigh2026-05-23
数据验证列表(下拉框)FeatureHigh2026-05-23
查找 / 替换 (Find & Replace)FeatureHigh2026-05-23
条件格式 (Conditional Formatting)FeatureLow2026-05-23
数字格式引擎 (Number Format)FeatureHigh2026-05-23
单元格合并 (Merge / Unmerge / Merge & Center)FeatureHigh2026-05-22
单元格格式 + 编辑 toolbarFeatureMedium2026-05-22
单元格保护 (read-only)FeatureHigh2026-05-13
选区聚合 (SUM / AVG / COUNT)FeatureMedium2026-05-13
单元格批注 / 工具提示FeatureMedium2026-05-13
工作表缩放FeatureMedium2026-05-13
排序 & 筛选FeatureHigh2026-05-13
撤销 / 重做FeatureHigh2026-05-13
填充手柄 & 快捷键FeatureMedium2026-05-13
剪贴板 (TSV)FeatureHigh2026-05-13
键盘导航 & 上下文菜单FeatureHigh2026-05-13
行 & 列操作FeatureHigh2026-05-12
缺陷修复:列表头布局回归BugHigh2026-05-11
虚拟化 & 性能FeatureHigh2026-05-11
单元格编辑 & 验证FeatureHigh2026-05-11
单元格选择系统FeatureHigh2026-05-11
React 库项目初始搭建FeatureHigh2026-05-11