/** * 表計算らしい格子(#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; /** 選択範囲が変わったとき。`売上!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>; ranges?: UniverRange[]; values?: Record; rowHeight?: number | Record; 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(null); const apiRef = useRef(null); /** シートごとの「触られたセル」。鍵は sheetId、値は `行:列` の集合。 */ const dirtyCellsRef = useRef>>(new Map()); const columnChangesRef = useRef(new ColumnWidthChanges()); const savedBaselineRef = useRef(new SpreadsheetSaveBaseline()); const submittedCellsRef = useRef(new WeakMap()); const submittedMergesRef = useRef(new WeakMap()); const dirtyRowsRef = useRef>>(new Map()); /** * 出した列の挿入の控え(#1540)。番地を鍵にした台帳を全部ずらす役も持つので、 * 挿入を知るのはここ 1 か所にする。 */ const structuralOpsRef = useRef(new SpreadsheetStructuralOps()); /** 未保存かどうかを調べ直すまでの猶予。 */ const dirtyTimerRef = useRef(null); /** * 部品へ渡すブック。**部品はこの中身をその場で書き換える**(打ち込んだ値も * 計算し直した値も、渡した `cellData` に直接入る)。差分の基準にこれを使うと * 「変える前」が残らず、いくら直しても差分が空になる——実機で踏んだ。 */ const workbookRef = useRef(toUniverWorkbook(sheets, bookName)); /** * 差分の基準。**別に組み直したもの**を持つ。同じ中身でも部品に渡した方とは * 別の入れ物なので、書き換えられても手つかずのまま残る。 */ const baselineRef = useRef | 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[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 }; 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
; } export default UniverGrid;