595 lines
30 KiB
TypeScript
595 lines
30 KiB
TypeScript
/**
|
||
* 表計算らしい格子(#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;
|