Files
maestro/ui/src/components/files/spreadsheet/UniverGrid.tsx
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

595 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 表計算らしい格子(#1264)。**このファイルから先が遅延読み込みの塊**になる。
*
* ここに静的 import で `@univerjs/*` を書いてよいのは、呼び出し側
* (`SpreadsheetPane`)が `React.lazy` でここを切り離しているから。逆に言えば、
* 部品を触る import を親側へ 1 つでも上げると 5.9MB が既定の読み込みに乗る。
* 変換と差分の計算を `lib/spreadsheet-grid.ts` に置いてあるのは、そこを部品と
* 無縁に保つためでもある。
*
* 「エージェントに渡す」の操作子はここには置かない。あちらはこちらの機能で
* あって部品の機能ではないので、外側の画面に常設する。
*/
import { useCallback, useEffect, useImperativeHandle, useRef, type Ref } from 'react';
import { Univer, UniverInstanceType } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { defaultTheme } from '@univerjs/themes';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { UniverUIPlugin } from '@univerjs/ui';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula';
import { UniverSheetsFormulaUIPlugin } from '@univerjs/sheets-formula-ui';
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt';
import { UniverSheetsNumfmtUIPlugin } from '@univerjs/sheets-numfmt-ui';
// facade の拡張は副作用 import で入る。これが無いと getActiveWorkbook すら生えない。
import '@univerjs/engine-formula/facade';
import '@univerjs/ui/facade';
import '@univerjs/docs-ui/facade';
import '@univerjs/sheets/facade';
import '@univerjs/sheets-ui/facade';
import '@univerjs/sheets-formula/facade';
import '@univerjs/sheets-numfmt/facade';
import '@univerjs/design/lib/index.css';
import '@univerjs/ui/lib/index.css';
import '@univerjs/docs-ui/lib/index.css';
import '@univerjs/sheets-ui/lib/index.css';
import '@univerjs/sheets-formula-ui/lib/index.css';
import '@univerjs/sheets-numfmt-ui/lib/index.css';
import {
NUMBER_FORMAT_MUTATIONS, cellRef, numberFormatDirtyKeys, toUniverWorkbook,
type CellSnapshot, type GridSheetInput, type UniverRange, type UniverStyle,
} from '../../../lib/spreadsheet-grid';
import { collectGridEdits, hasPendingSave, type GridCollectResult, type GridDropWarnings } from '../../../lib/spreadsheet-collect';
import { SpreadsheetSaveBaseline, type SubmittedCells, type SubmittedMerges } from '../../../lib/spreadsheet-save-baseline';
import { ColumnWidthChanges, COLUMN_WIDTH_MUTATION, MAX_COLUMN_WIDTH, pixelsToChars } from '../../../lib/spreadsheet-columns';
import {
handleColumnInsertMutation,
handleColumnRemoveMutation,
SpreadsheetStructuralOps,
structuralRefusalFor,
type GridRefusal,
} from '../../../lib/spreadsheet-structural';
import { MERGE_MUTATIONS } from '../../../lib/spreadsheet-merges';
import { readGridSelection, type GridSelectionSnapshot } from './grid-selection';
import {
columnDeleteCommandFor, decideColumnDelete, type ColumnDeleteRequest,
} from './column-delete-request';
import { localeFor } from './univer-locale';
import { installUniverUndoGuard } from './univer-undo-guard';
import {
buildMenuConfig, decideCommand, isInsertColumnCommand, isRemoveColumnCommand,
} from './univer-policy';
// 差分の組み立てと、その結果の型は `lib/spreadsheet-collect.ts`。ここは通し口に
// する(格子は jsdom で動かないので、組み立てを中に置くとテストから触れない)。
export type { GridCollectResult, GridDropWarnings } from '../../../lib/spreadsheet-collect';
export type { GridRefusal } from '../../../lib/spreadsheet-structural';
// 選択の読み取りは `grid-selection.ts`。型もあちらに置き、ここは通し口にする
// (画面側は型しか取らないので、部品の塊はそちらへ漏れない)。
export type { GridSelectionSnapshot } from './grid-selection';
// 確認の帯の材料も外に置く(`column-delete-request.ts`)。ここは通し口。
export type { ColumnDeleteRequest } from './column-delete-request';
/** 親が保存のときに呼ぶ口。 */
export interface UniverGridHandle {
/** 触られたところを保存の形にする。何も触っていなければ空。 */
collect: () => GridCollectResult;
/** 保存が通った後に「触っていない」状態へ戻す。 */
markSaved: (saved?: GridCollectResult) => void;
/**
* 選ばれている範囲を取り出す(#1463 段 2)。**1 セルだけのときは null**——
* 押しただけで札が出ると、表を眺めているあいだ中ずっと入力欄が書き換わる。
*
* `collect()` とは別物。あちらは「触ったセルの差分」で、選択とは関係がない。
*/
readSelection: () => GridSelectionSnapshot | null;
/**
* 確認の帯で「続ける」を押されたとき、止めておいた列の削除を出し直す(#1540 段 2)。
*
* 出し直すのは**確認を飛ばす印を立てたうえで**。立てないと、同じ命令が同じ
* 横取りに当たって永久に確認が出続ける。
*/
runColumnDelete: (request: ColumnDeleteRequest) => void;
}
export interface UniverGridProps {
sheets: GridSheetInput[];
bookName: string;
/** 画面の言語。部品の文言もこれに合わせる。 */
language: string;
/** 読むだけにする(打ち切られたシートを開いているときなど)。 */
readOnly?: boolean;
handleRef?: Ref<UniverGridHandle>;
/** 選択範囲が変わったとき。`売上!B2:C4` の形で渡す。 */
onSelectionChange?: (selection: { sheet: string; range: string } | null) => void;
/** 触られた/触られていないが変わったとき。 */
onDirtyChange?: (dirty: boolean) => void;
/**
* 断られた操作があったとき(保存できない編集)。
*
* 中身を渡さないときは従来どおりの「保存に載せられない操作」。列の挿入を
* シートの都合で断ったときと、列を入れた後の「元に戻す」を断ったときは、
* 理由を渡す(画面がそれぞれ別の文章に訳す・#1540)。
*/
onBlocked?: (refusal?: GridRefusal) => void;
/**
* 列が入って、台帳へ写し終えたとき(#1540 のレビュー指摘)。
*
* 画面はこれを合図に**自動保存を止める**。止めないと、断り書きが消える前に
* 自動保存が走って列がファイルへ入り、取り消す道が画面から無くなる。
*/
onStructuralPending?: (kind: 'insert-col' | 'remove-col') => void;
/**
* 数式が壊れる列を消そうとしたとき(#1540 段 2)。
*
* 格子はその場では消さずに止め、材料だけを渡す。訊くかどうかの決めごとは
* `columnDeleteDecision`(`lib/spreadsheet-structural.ts`)が 1 か所で持っている。
*/
onColumnDeleteConfirm?: (request: ColumnDeleteRequest) => void;
/**
* 見ているシートが変わったとき(#1540 段 2 のレビュー指摘)。
*
* 画面はこれを合図に**確認の帯を取り下げる**。訊いていた列は元のシートに
* しか無いので、移った先で「続ける」を押されても何も起きない——押したのに
* 無反応、という形になる。名前が読めなければ `null` を渡す。
*/
onActiveSheetChange?: (sheetName: string | null) => void;
/**
* 持ち帰れないものが差分に見つかったとき。**保存を押した時ではなく、変更が
* 落ち着いた時点で**呼ぶ——貼り付けた直後に黙っているのが一番まずい。
*/
onWarnings?: (warnings: GridDropWarnings) => void;
}
interface MutationParams {
unitId?: string;
subUnitId?: string;
cellValue?: Record<string, Record<string, unknown>>;
ranges?: UniverRange[];
values?: Record<string, { ranges?: UniverRange[] } | undefined>;
rowHeight?: number | Record<string, number | null>;
rowsAutoHeightInfo?: { row: number; autoHeight?: number }[];
/** 列の挿入の書き換えが持つ範囲(#1540)。 */
range?: UniverRange;
}
/**
* 変更通知が落ち着くのを待つ長さ(ミリ秒)。短くすると打鍵のたびに全セルを
* 突き合わせることになり、長くすると保存の口が出るまで間が空く。
*/
const DIRTY_CHECK_DELAY_MS = 150;
const VALUE_MUTATION = 'sheet.mutation.set-range-values';
/** 列が入ったことを知る唯一の書き換え(#1540)。入り口が 6 つあっても最後はここへ来る。 */
const ROW_HEIGHT_MUTATIONS = new Set([
'sheet.mutation.set-worksheet-row-height',
'sheet.mutation.set-worksheet-row-auto-height',
]);
/** `売上!B2:C4` を組み立てる。部品にこの表記は用意されていない。 */
function rangeToA1(
sheetName: string,
r: { startRow: number; endRow: number; startColumn: number; endColumn: number },
): string {
const start = cellRef(r.startRow, r.startColumn);
const end = cellRef(r.endRow, r.endColumn);
return `${sheetName}!${start === end ? start : `${start}:${end}`}`;
}
export function UniverGrid({
sheets, bookName, language, readOnly = false, handleRef,
onSelectionChange, onDirtyChange, onBlocked, onStructuralPending, onColumnDeleteConfirm,
onActiveSheetChange, onWarnings,
}: UniverGridProps): JSX.Element {
const hostRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver | null>(null);
/** シートごとの「触られたセル」。鍵は sheetId、値は `行:列` の集合。 */
const dirtyCellsRef = useRef<Map<string, Set<string>>>(new Map());
const columnChangesRef = useRef(new ColumnWidthChanges());
const savedBaselineRef = useRef(new SpreadsheetSaveBaseline());
const submittedCellsRef = useRef(new WeakMap<GridCollectResult, SubmittedCells[]>());
const submittedMergesRef = useRef(new WeakMap<GridCollectResult, SubmittedMerges[]>());
const dirtyRowsRef = useRef<Map<string, Map<number, number>>>(new Map());
/**
* 出した列の挿入の控え(#1540)。番地を鍵にした台帳を全部ずらす役も持つので、
* 挿入を知るのはここ 1 か所にする。
*/
const structuralOpsRef = useRef(new SpreadsheetStructuralOps());
/** 未保存かどうかを調べ直すまでの猶予。 */
const dirtyTimerRef = useRef<number | null>(null);
/**
* 部品へ渡すブック。**部品はこの中身をその場で書き換える**(打ち込んだ値も
* 計算し直した値も、渡した `cellData` に直接入る)。差分の基準にこれを使うと
* 「変える前」が残らず、いくら直しても差分が空になる——実機で踏んだ。
*/
const workbookRef = useRef(toUniverWorkbook(sheets, bookName));
/**
* 差分の基準。**別に組み直したもの**を持つ。同じ中身でも部品に渡した方とは
* 別の入れ物なので、書き換えられても手つかずのまま残る。
*/
const baselineRef = useRef<ReturnType<typeof toUniverWorkbook> | null>(null);
const sheetsRef = useRef(sheets);
// 見張りの中から読む用(見張りは language / readOnly でしか組み直さない)。
// **書き換えない**——ブックの中身は開き直し(`key` の入れ替え)で作り直すので、
// 描き直しのたびに差し替えると差分の基準が途中で入れ替わる。
const bookNameRef = useRef(bookName);
/**
* 確認の帯で「続ける」を押されたあいだだけ立つ印(#1540 段 2)。立っているあいだは
* 列の削除の横取りを素通りさせる。
*/
const bypassColumnDeleteRef = useRef(false);
const callbacksRef = useRef({
onSelectionChange, onDirtyChange, onBlocked, onStructuralPending, onColumnDeleteConfirm,
onActiveSheetChange, onWarnings,
});
callbacksRef.current = {
onSelectionChange, onDirtyChange, onBlocked, onStructuralPending, onColumnDeleteConfirm,
onActiveSheetChange, onWarnings,
};
const collect = useCallback((): GridCollectResult => {
const api = apiRef.current;
baselineRef.current ??= toUniverWorkbook(sheetsRef.current, bookName);
const book = baselineRef.current;
const structural = structuralOpsRef.current.collect();
const structuralSeq = structuralOpsRef.current.nextSeq;
if (!api) {
return {
edits: [], rows: [], columns: columnChangesRef.current.collect(), structural, structuralSeq,
droppedBorderStyles: 0, droppedStyleCells: 0, numberFormatTextCells: 0,
};
}
const { collected, submitted, submittedMerges } = collectGridEdits({
book,
sources: sheetsRef.current.map(sheet => ({ name: sheet.name, rowHeights: sheet.grid.rowHeights })),
dirtyCells: dirtyCellsRef.current,
dirtyRows: dirtyRowsRef.current,
savedBaseline: savedBaselineRef.current,
columns: columnChangesRef.current.collect(),
structural,
structuralSeq,
// **部品から値を取るのはここだけ。**残りは lib 側の純関数で動く。
readMerges: sheetId => api.getActiveWorkbook()?.getSheetBySheetId(sheetId)?.getSheet().getMergeData() ?? null,
readCell: (sheetId, row, col) => {
const range = api.getActiveWorkbook()?.getSheetBySheetId(sheetId)?.getRange(row, col, 1, 1);
if (!range) return null;
const formula = range.getFormula?.();
const snapshot: CellSnapshot = {
v: range.getRawValue() as string | number | boolean | null | undefined,
style: (range.getCellStyleData() ?? undefined) as UniverStyle | undefined,
// 見たままの文字。表示形式が変わったセルはこちらを保存する。
display: range.getDisplayValue?.(),
};
if (formula) snapshot.f = formula;
return snapshot;
},
});
submittedCellsRef.current.set(collected, submitted);
submittedMergesRef.current.set(collected, submittedMerges);
return collected;
}, [bookName]);
const collectRef = useRef(collect);
collectRef.current = collect;
useImperativeHandle(handleRef, () => ({
collect,
// 中身は `grid-selection.ts`。ここに書き戻すと、格子を差し替えたテストからは
// 二度と通らない場所に判断が戻る。
readSelection: (): GridSelectionSnapshot | null => readGridSelection(apiRef.current),
/**
* 止めておいた列の削除を出し直す(#1540 段 2)。
*
* 行の側は部品が勝手に「シート全体」へ広げるので、こちらは列だけを渡す。
* 印を戻すのは命令が片付いてから——先に戻すと、まだ横取りに届いていない
* 命令が確認へ回って堂々巡りになる。
*/
runColumnDelete: (request) => {
const api = apiRef.current;
if (!api) return;
// 出し直してよいかの判断(訊いているあいだにシートを切り替えられていないか)と
// 命令の中身は `columnDeleteCommandFor` が一緒に決める。ここに if を積むと、
// canvas を要るこの格子の中に検査の届かない判断が増える。
const command = columnDeleteCommandFor(api, request);
if (!command) return;
bypassColumnDeleteRef.current = true;
void Promise.resolve(api.executeCommand(command.id, command.params))
.catch(() => undefined)
.finally(() => { bypassColumnDeleteRef.current = false; });
},
markSaved: (saved) => {
columnChangesRef.current.saved(saved?.columns ?? []);
if (saved) {
for (const snapshot of submittedCellsRef.current.get(saved) ?? []) {
// 送っている間に列が入っていたら、控えの番地を先に当て直す。ずらさずに
// 書き戻すと、保存した値が右へずれた別のセルの基準として入る。
savedBaselineRef.current.acknowledge(structuralOpsRef.current.rebase(snapshot));
}
for (const snapshot of submittedMergesRef.current.get(saved) ?? []) {
savedBaselineRef.current.acknowledgeMerges(structuralOpsRef.current.rebaseMerges(snapshot));
}
submittedMergesRef.current.delete(saved);
savedBaselineRef.current.acknowledgeRows(saved.rows);
submittedCellsRef.current.delete(saved);
// 送ったぶんの構造操作だけを落とす。送信中に入ったものは次の保存へ残る。
structuralOpsRef.current.saved(saved.structuralSeq);
}
// Keep touched keys: a later edit or Undo must be compared with the saved baseline.
if (dirtyTimerRef.current !== null) window.clearTimeout(dirtyTimerRef.current);
const remaining = collectRef.current();
callbacksRef.current.onDirtyChange?.(hasPendingSave(remaining));
callbacksRef.current.onWarnings?.({ droppedBorderStyles: remaining.droppedBorderStyles, droppedStyleCells: remaining.droppedStyleCells, numberFormatTextCells: remaining.numberFormatTextCells });
},
}), [collect]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const { type: localeType, bundle } = localeFor(language);
const univer = new Univer({
theme: defaultTheme,
locale: localeType,
locales: { [localeType]: bundle },
});
univer.registerPlugin(UniverRenderEnginePlugin);
univer.registerPlugin(UniverFormulaEnginePlugin);
univer.registerPlugin(UniverUIPlugin, {
container: host,
header: true,
footer: true,
contextMenu: true,
menu: buildMenuConfig(),
});
univer.registerPlugin(UniverDocsPlugin);
univer.registerPlugin(UniverDocsUIPlugin);
univer.registerPlugin(UniverSheetsPlugin);
univer.registerPlugin(UniverSheetsUIPlugin);
univer.registerPlugin(UniverSheetsFormulaPlugin);
univer.registerPlugin(UniverSheetsFormulaUIPlugin);
univer.registerPlugin(UniverSheetsNumfmtPlugin);
univer.registerPlugin(UniverSheetsNumfmtUIPlugin);
const api = FUniver.newAPI(univer);
apiRef.current = api;
univer.createUnit(
UniverInstanceType.UNIVER_SHEET,
{ ...workbookRef.current, locale: localeType } as unknown as Parameters<typeof univer.createUnit>[1],
);
// 読むだけのときは、まず部品自身の読み取り専用を立てる。入力欄そのものが
// 開かなくなるので、断り書きの層より前に効く。
if (readOnly) api.getActiveWorkbook()?.setEditable(false);
// ── 書き戻せない操作を断る ──────────────────────────────
// リボンから消してあっても、打鍵・右クリック・貼り付けから同じ命令が届く。
//
// 読むだけのときに**全部の命令を断ってはいけない**。巻き上げも範囲の選択も
// シートの切り替えも命令として届くので、一律に断ると画面が凍って読むことも
// できなくなる(実機で踏んだ)。断るのは中身を書き換える命令だけにする。
const undoGuard = installUniverUndoGuard(
api,
() => readOnly || structuralOpsRef.current.blocksUndo,
() => { if (!readOnly) callbacksRef.current.onBlocked?.({ kind: 'undoAfterStructural' }); },
);
const blockSub = api.addEvent(api.Event.BeforeCommandExecute, (event) => {
// 列の挿入は**押した時点で**断る(#1540 段 1)。保存まで黙って持っていくと、
// 何行も打ち込んだ後で「このシートは駄目でした」と言うことになる。
if (isInsertColumnCommand(event.id)) {
if (readOnly) { event.cancel = true; return; }
const sheetName = api.getActiveWorkbook()?.getActiveSheet()?.getSheetName();
const refusal = structuralRefusalFor(
sheetsRef.current.find(sheet => sheet.name === sheetName)?.structuralEdit,
);
if (refusal) {
event.cancel = true;
callbacksRef.current.onBlocked?.(refusal);
}
return;
}
// 列の削除も**押した時点で**判断する(#1540 段 2)。断る条件は挿入と同じ関所
// ——シートごとの判定を緩めない。そのうえで、指し先を失う数式があるときだけ
// 1 回訊く(案ウ)。
if (isRemoveColumnCommand(event.id)) {
const sheetName = api.getActiveWorkbook()?.getActiveSheet()?.getSheetName();
const verdict = decideColumnDelete({
readOnly,
bypass: bypassColumnDeleteRef.current,
refusal: structuralRefusalFor(
sheetsRef.current.find(sheet => sheet.name === sheetName)?.structuralEdit,
) ?? null,
api,
params: event.params as { range?: UniverRange } | undefined,
});
if (verdict.action === 'proceed') return;
event.cancel = true;
if (verdict.action === 'blocked') callbacksRef.current.onBlocked?.(verdict.refusal);
if (verdict.action === 'ask') callbacksRef.current.onColumnDeleteConfirm?.(verdict.request);
return;
}
const decision = decideCommand(event.id, event.type as number, readOnly);
if (decision === 'allow') {
if (event.id === COLUMN_WIDTH_MUTATION) {
const params = event.params as { subUnitId?: string; ranges?: UniverRange[]; colWidth?: number | Record<string, number> };
const widths = typeof params.colWidth === 'number' ? [params.colWidth] : Object.values(params.colWidth ?? {});
if (widths.some(width => !Number.isFinite(width) || width <= 0 || pixelsToChars(width) > MAX_COLUMN_WIDTH)) {
event.cancel = true;
if (!readOnly) callbacksRef.current.onBlocked?.();
return;
}
const worksheet = params.subUnitId ? api.getActiveWorkbook()?.getSheetBySheetId(params.subUnitId) : undefined;
if (worksheet) for (const range of params.ranges ?? []) {
for (let col = range.startColumn; col <= range.endColumn; col++) {
columnChangesRef.current.before(worksheet.getSheetName(), col, worksheet.getColumnWidth(col));
}
}
}
return;
}
event.cancel = true;
if (decision === 'refuse') callbacksRef.current.onBlocked?.();
});
// ── 触られたところを覚える ────────────────────────────
// 「触られた」と「変わった」は別物。ブックを開いた直後、部品は数式を
// 計算し直して同じ形の変更通知を出すので、通知が来た=未保存とすると、
// **開いただけで「未保存の変更があります」になる**(実機で確認した)。
// なので通知そのものではなく、**差分を取り直して中身が出たときだけ**
// 未保存にする。少し待ってからまとめて調べるのは、打鍵のたびに全セルを
// 突き合わせないため。
const markDirty = (): void => {
if (dirtyTimerRef.current !== null) window.clearTimeout(dirtyTimerRef.current);
dirtyTimerRef.current = window.setTimeout(() => {
dirtyTimerRef.current = null;
const collected = collectRef.current();
const { droppedBorderStyles, droppedStyleCells, numberFormatTextCells } = collected;
callbacksRef.current.onDirtyChange?.(hasPendingSave(collected));
// 保存を押すまで黙っていると、貼り付けた直後の画面と保存される中身が
// 食い違ったままになる。数え直すたびに出し直すので、元に戻せば消える。
callbacksRef.current.onWarnings?.({ droppedBorderStyles, droppedStyleCells, numberFormatTextCells });
}, DIRTY_CHECK_DELAY_MS);
};
const commandSub = api.onCommandExecuted((command) => {
const params = command.params as MutationParams | undefined;
if (!params?.subUnitId) return;
if (MERGE_MUTATIONS.has(command.id)) {
markDirty();
return;
}
if (command.id === COLUMN_WIDTH_MUTATION) {
const worksheet = api.getActiveWorkbook()?.getSheetBySheetId(params.subUnitId);
if (worksheet) for (const range of params.ranges ?? []) {
for (let col = range.startColumn; col <= range.endColumn; col++) {
columnChangesRef.current.after(worksheet.getSheetName(), col, worksheet.getColumnWidth(col));
}
}
markDirty();
return;
}
// 列が入った(#1540)。**判断も順番も `handleColumnInsertMutation` の中**に
// ある。台帳をずらす手順・自動保存を止める合図・書きかけの数え直しは、この
// 格子が canvas を要る(=jsdom では動かせない)ぶん、外に出しておかないと
// 挙動のテストから触れない。ここに残すのは材料を詰めて渡す 1 呼び出しだけ。
if (handleColumnInsertMutation(command.id, params, {
sheetNameOf: id => api.getActiveWorkbook()?.getSheetBySheetId(id)?.getSheetName(),
ensureBaseline: id => {
baselineRef.current ??= toUniverWorkbook(sheetsRef.current, bookNameRef.current);
return baselineRef.current.sheets[id];
},
ops: structuralOpsRef.current,
savedBaseline: savedBaselineRef.current,
columnWidths: columnChangesRef.current,
touchedCells: dirtyCellsRef.current,
}, {
onStructuralPending: kind => callbacksRef.current.onStructuralPending?.(kind),
onDirty: markDirty,
})) return;
// 列が消えた(#1540 段 2)。挿入と同じ形で、判断も順番も外の 1 か所に置く。
// **「元に戻す」もここを通る**——列を入れたのを戻すと部品はこの書き換えを
// 出すので、台帳はそのまま詰まる。
if (handleColumnRemoveMutation(command.id, params, {
sheetNameOf: id => api.getActiveWorkbook()?.getSheetBySheetId(id)?.getSheetName(),
ensureBaseline: id => {
baselineRef.current ??= toUniverWorkbook(sheetsRef.current, bookNameRef.current);
return baselineRef.current.sheets[id];
},
ops: structuralOpsRef.current,
savedBaseline: savedBaselineRef.current,
columnWidths: columnChangesRef.current,
touchedCells: dirtyCellsRef.current,
}, {
onStructuralPending: kind => callbacksRef.current.onStructuralPending?.(kind),
onDirty: markDirty,
})) return;
if (command.id === VALUE_MUTATION && params.cellValue) {
let set = dirtyCellsRef.current.get(params.subUnitId);
if (!set) { set = new Set(); dirtyCellsRef.current.set(params.subUnitId, set); }
for (const [rowKey, row] of Object.entries(params.cellValue)) {
for (const colKey of Object.keys(row)) set.add(`${rowKey}:${colKey}`);
}
markDirty();
return;
}
if (NUMBER_FORMAT_MUTATIONS.has(command.id)) {
// %・通貨・小数の桁のボタンは値の書き換えを出さないので、ここで
// 拾わないと画面だけ変わって保存が押せない。
let set = dirtyCellsRef.current.get(params.subUnitId);
if (!set) { set = new Set(); dirtyCellsRef.current.set(params.subUnitId, set); }
for (const key of numberFormatDirtyKeys(params)) set.add(key);
markDirty();
return;
}
if (ROW_HEIGHT_MUTATIONS.has(command.id)) {
let map = dirtyRowsRef.current.get(params.subUnitId);
if (!map) { map = new Map(); dirtyRowsRef.current.set(params.subUnitId, map); }
if (params.rowsAutoHeightInfo) {
for (const info of params.rowsAutoHeightInfo) {
if (typeof info.autoHeight === 'number') map.set(info.row, info.autoHeight);
}
} else if (typeof params.rowHeight === 'number') {
for (const range of params.ranges ?? []) {
for (let r = range.startRow; r <= range.endRow; r++) map.set(r, params.rowHeight);
}
} else if (params.rowHeight && typeof params.rowHeight === 'object') {
for (const [rowKey, height] of Object.entries(params.rowHeight)) {
if (typeof height === 'number') map.set(Number(rowKey), height);
}
}
markDirty();
}
});
// ── 選んだ範囲を外側へ渡す ────────────────────────────
const selectionSub = api.addEvent(api.Event.SelectionChanged, (event) => {
const report = callbacksRef.current.onSelectionChange;
if (!report) return;
const sheetName = event.worksheet.getSheetName();
const first = event.selections[0];
// 1 回のドラッグで空の配列も飛んでくる。そのまま流すと選択が点滅する。
if (!first) return;
report({ sheet: sheetName, range: rangeToA1(sheetName, first) });
});
// ── 見ているシートが変わったことを外側へ渡す ──────────────
// 部品はシートの切り替えを `SetWorksheetActiveOperation` で出し、facade が
// これを `ActiveSheetChanged` として流す(`@univerjs/sheets` の facade を
// 読んで確認した)。確認の帯を畳むのに要る。
const activeSheetSub = api.addEvent(api.Event.ActiveSheetChanged, (event) => {
const report = callbacksRef.current.onActiveSheetChange;
if (!report) return;
let name: string | null = null;
try {
name = event.activeSheet?.getSheetName() ?? null;
} catch {
name = null;
}
report(name);
});
return () => {
if (dirtyTimerRef.current !== null) window.clearTimeout(dirtyTimerRef.current);
blockSub.dispose();
undoGuard.dispose();
commandSub.dispose();
selectionSub.dispose();
activeSheetSub.dispose();
apiRef.current = null;
univer.dispose();
};
// ブックの中身は開き直し(`key` の入れ替え)で作り直す。ここで sheets を
// 見張ると、保存のたびに部品が丸ごと組み直されて選択も巻き戻る。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language, readOnly]);
return <div ref={hostRef} data-testid="univer-grid-host" className="h-full min-h-0 w-full" />;
}
export default UniverGrid;