sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
@@ -0,0 +1,594 @@
/**
* 表計算らしい格子(#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;