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,並接上與所需功能對應的回調即可。 除網格外,工具欄·對話框等輔助組件(CellFormatToolbar, CellMergeToolbar, ConditionalFormatToolbar, FindReplaceDialog, ValidationDropdown, ExportButton, ImportDialog, PivotBuilder, PivotChart, SheetTabBar, PrintPreview, FormulaBar 等)也都從 root 一併 export — 按需挑選使用即可。

<XlReact
  columns={columns}
  rows={rows}
  rowHeight={28}
  columnWidth={120}
  onCellChange={(change) => applyEdit(change)}
  onCellsClear={(payload) => clearRanges(payload.ranges)}
  freezeFirstRow
  showSelectionStats
/>

完整的 prop 列表見 XlReactProps 章節。

列 & 行

Column

  • idstring

    穩定的標識符。排序 / 篩選 / 重排序的負載會攜帶此 id。

  • accessor(row: Row) => T

    返回該列的單元格值。

  • dataType'text' | 'number'

    決定默認編輯器的輸入過濾與有效性樣式。默認值 'text'。設爲 'number' 時,編輯過程中會拒絕非數字鍵輸入。

  • requiredboolean

    當 accessor 返回 null / undefined / '' / NaN 時會應用 invalid 樣式。0 / false 爲 有效值。這只是視覺提示,不會阻止 commit。

  • validate(value: T, row: Row) => boolean | string

    基於值的驗證。返回非空字符串時爲 invalid + 消息,false 則 爲無消息的 invalid。

  • cellRenderer / cellEditor(props) => ReactNode

    自定義單元格渲染器 / 編輯器。編輯器會接收 onCommit / onCancel

  • widthnumber

    初始列寬(px)。會被用戶拖拽調整大小所覆蓋。

  • readOnlyboolean

    將該列的所有單元格標記爲保護狀態。與網格的 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 始終爲 falseisEditing: 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,但組件照常渲染(聚焦的 read-only input 按 Enter 也不會 commit),disabled則灰顯 + 阻斷交互。
  • v1 限制:不支持與網格內CellEditor的 in-progress draft 雙向 實時同步。僅在 commit 單元格編輯後欄中的 值纔會刷新。(CellEditor draft store 共享爲後續工作。)

Props

  • activeRefstring | null

    活動單元格的 A1 引用。通過coordToA1(active.row, active.col)SelectionSnapshot派生。若爲null,則名稱 框爲空,輸入框也以空狀態渲染。

  • valuestring | number | null

    活動單元格的 raw 輸入值:公式時爲'=A1*B1',字面量時爲 數字或字符串。從FormulaSheet.getRaw(active)或行 數據訪問器派生。

  • onCommit(value: string | null) => void

    在 Enter · ✓ · 失焦時調用。空字符串會以null 傳遞,因此可直接流送給FormulaSheet.setRaw

  • onCancel() => void

    Esc · ✗ 時。draft 會恢復爲value

  • onNavigate({ row, col, ref }) => void

    在名稱框中輸入 A1 引用並按 Enter 時調用。未設置時 名稱框爲顯示專用(readOnly)。

  • readOnly · disabledboolean

    readOnly阻斷 commit,disabled爲視覺 禁用 + 阻斷交互。適合與工作表保護(protected)狀態 聯動。

  • labelsFormulaBarLabels

    nameBox · formulaInput · commit · cancel · fxIcon · emptyPlaceholder。 默認值爲韓語('이름 상자' · '수식 입력줄' · '입력' · '취소' · 'fx')。

連接到SelectionSnapshot + FormulaSheet 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 — 不會作爲包導出的實現細節), 構建器 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 個透視表上的重複)。 若要讓 scope 的所有透視表以同一 cadence 一起刷新,請在 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