@aiquants/spreadsheet 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,7 +63,7 @@ import type { ViewStore } from "../model/viewStore.ts"
63
63
  import type { ResizeGuide } from "../overlay/SelectionOverlay.tsx"
64
64
  import type { SelectionState } from "../selection/selectionState.ts"
65
65
  import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
66
- import { gateResizeEntries, type ResizeEntry, ROW_FANOUT_DIVISOR, ROW_HIDDEN_LIMIT } from "./resizeGates.ts"
66
+ import { gateResizeEntries, keyboardStepSize, type ResizeEntry, ROW_FANOUT_DIVISOR, ROW_HIDDEN_LIMIT } from "./resizeGates.ts"
67
67
 
68
68
  /** Autofit horizontal padding: cell padding 4+4 + right border 1 (px). / autofit の水平パディング (セル padding 4+4 + 右罫 1)。 */
69
69
  const AUTOFIT_PADDING = 9
@@ -97,6 +97,8 @@ type UseHeaderResizeParams = {
97
97
  adapter: SpreadsheetDataAdapter
98
98
  /** Autofit adoption condition: renderCell unspecified (custom DOM defeats measureText — §10 deviation). / autofit 採用条件 (renderCell 未指定)。 */
99
99
  autofitEnabled: boolean
100
+ /** Leading header rows (`headerRows`): autofit measures their cells in the stylesheet's header-row weight. / 先頭の見出しの行数 (`headerRows`)。autofit はそのセルをスタイルシートの見出し行の太さで測る。 */
101
+ headerRows: number
100
102
  defaultRowHeight: number
101
103
  defaultColumnWidth: number
102
104
  maxOperationCells: number
@@ -125,7 +127,7 @@ type UseHeaderResizeParams = {
125
127
  * cellPaint と同式) / フォント族を重ねる)。書式 object 毎に intern (塗り intern が書式毎に 1 object を
126
128
  * 保つので、異なりフォント毎に 1 文字列)。
127
129
  */
128
- const fontKeyOf = (painted: CellFormat, fontKeys: WeakMap<CellFormat, string>, bandStyle: CSSStyleDeclaration): string => {
130
+ const fontKeyOf = (painted: CellFormat, fontKeys: WeakMap<CellFormat, string>, bandStyle: Pick<CSSStyleDeclaration, "fontStyle" | "fontWeight" | "fontSize" | "fontFamily">): string => {
129
131
  const cached = fontKeys.get(painted)
130
132
  if (cached !== undefined) {
131
133
  return cached
@@ -169,10 +171,26 @@ type ResizeGesture = {
169
171
  const guidePositionOf = (gesture: ResizeGesture, axisModel: AxisModel, pendingSize: number): number => (gesture.anchor === "trailing" ? axisModel.offsetOf(gesture.index) + gesture.initialSize - pendingSize : axisModel.offsetOf(gesture.index) + pendingSize)
170
172
 
171
173
  /**
172
- * Wires the resize/unhide machine onto both header bands; returns the §7.4 composite surface.
173
- * 両ヘッダー帯へリサイズ / unhide 機械を結線し、§7.4 の合成面を返す。
174
+ * The resize machine's surface: the §7.4 drag surface plus the keyboard twins of the strips, which resize through the
175
+ * same fan-out, gates and batch seam as a strip release. Both return whether the command ran: false only after a
176
+ * refusal through onOperationRejected (and for autofit while renderCell is set); a track already at a bound, an empty
177
+ * column or nothing to measure is a run that changes nothing (true).
178
+ * リサイズ機械の面。§7.4 のドラッグ面に、ストリップのキーボードの双子を加えたもの (ストリップの放しと同じファンアウト・
179
+ * ゲート・バッチの継ぎ目で寸法を変える)。どちらもコマンドが走ったかを返す: false は onOperationRejected で拒まれたとき
180
+ * (と renderCell がある間の autofit) だけで、端にあるトラック・全空列・測るものが無いときは何も変えずに走った (true)。
174
181
  */
175
- export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine => {
182
+ export type HeaderResizeMachine = SelectionEngine & {
183
+ /** Grows (1) or shrinks (-1) track `index` by one keyboard step (keyboardStepSize), with the strip's fan-out. / トラック `index` を 1 段大きく (1) / 小さく (-1) する (keyboardStepSize。ストリップと同じファンアウト)。 */
184
+ stepTrack: (axis: "row" | "col", index: number, direction: 1 | -1) => boolean
185
+ /** Fits column `index` to its content, as the strip double-click does (false when renderCell is set). / 列 `index` を中身に合わせる (ストリップのダブルクリックと同じ。renderCell があれば false)。 */
186
+ autofitColumn: (index: number) => boolean
187
+ }
188
+
189
+ /**
190
+ * Wires the resize/unhide machine onto both header bands; returns the §7.4 composite surface with the keyboard twins.
191
+ * 両ヘッダー帯へリサイズ / unhide 機械を結線し、§7.4 の合成面とキーボードの双子を返す。
192
+ */
193
+ export const useHeaderResize = (params: UseHeaderResizeParams): HeaderResizeMachine => {
176
194
  // paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
177
195
  const paramsRef = useRef(params)
178
196
  paramsRef.current = params
@@ -271,16 +289,17 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
271
289
  * 純関数ゲート (resizeGates.ts: 件数ゲート・丸め・§7.2 の押下トラック 0 化ガード・§5.4(b) の行軸
272
290
  * 非表示総数ゲート) を通してから resizeTracks を 1 回呼ぶ。拒否は onOperationRejected("resize")。
273
291
  */
274
- const applyEntries = useCallback((axis: "row" | "col", rawEntries: readonly ResizeEntry[], pressedIndex: number | null): void => {
292
+ const applyEntries = useCallback((axis: "row" | "col", rawEntries: readonly ResizeEntry[], pressedIndex: number | null): boolean => {
275
293
  const p = paramsRef.current
276
294
  const axisModel = axis === "col" ? p.colAxis : p.rowAxis
277
295
  // ゲートは純関数 (§4.2.10 の委譲) — ヘッダー由来のサイズは常に有限 ≥ 0 なので RangeError 枝は到達しない
278
296
  const result = gateResizeEntries(axisModel, rawEntries, pressedIndex, { maxOperationCells: p.maxOperationCells, rowHiddenLimit: ROW_HIDDEN_LIMIT, axisKind: axis })
279
297
  if (!result.ok) {
280
298
  p.onOperationRejected?.("resize", result.count, result.reason)
281
- return
299
+ return false
282
300
  }
283
301
  p.resizeTracks(axis, result.entries)
302
+ return true
284
303
  }, [])
285
304
 
286
305
  /** The up commit: same-value skip → fan-out targets → batch apply. / up 確定 (同値スキップ → ファンアウト → バッチ適用)。 */
@@ -306,11 +325,11 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
306
325
 
307
326
  /** Column autofit (§7.3 / v0.8 §4.2.10 / ADR-21): per-column canvas measurement over the union of both frozen bands and the visible window — `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)`, each filtered by sizeOf > 0 — in each cell's painted font, bucketed by font key. / 列 autofit (§7.3 / v0.8 §4.2.10 / ADR-21): 両凍結帯 + 可視ウィンドウの和集合 `[0, R_eff) ∪ [rowStart, rowEnd] ∪ [rowCount − T_eff, rowCount)` (各 sizeOf > 0) をセルの塗りフォントで計測、フォントキーでバケット化。 */
308
327
  const autofitColumns = useCallback(
309
- (band: HTMLElement, pressedIndex: number): void => {
328
+ (band: HTMLElement, pressedIndex: number): boolean => {
310
329
  const p = paramsRef.current
311
330
  const targets = collectTargets("col", pressedIndex)
312
331
  if (targets === null) {
313
- return
332
+ return false
314
333
  }
315
334
  // 帯件数は判断時ハンドル読み (ADR-21 / ADR-19 の判断時規律 — getFrozenSize().rows /
316
335
  // .trailingRows はエンジンクランプ済み実効値)。装着前 (null) は帯集合なし — 測定集合が
@@ -344,8 +363,9 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
344
363
  }
345
364
  // 計測対象の可視行がゼロ (全帯 + 窓が非表示) は no-op — Excel も計測対象
346
365
  // なしでは幅を変えない (0 計測 → 9px 潰しの破壊を作らない。R1(B) 所見)
366
+ // 測る行が無い / 測る書体が無い / 全空列は「当てるものが無い」実行で、拒否ではない (true)
347
367
  if (measuredRows.length === 0) {
348
- return
368
+ return true
349
369
  }
350
370
  // 上限ゲートは計測セル数単位 (§7.3 — トラック数単位では 16k 列 × 可視 40 行が素通り
351
371
  // する)。上限は autofit 専用の低い係数 (§13 (e) の設計返却 — 実測根拠は定数 docstring)。
@@ -354,24 +374,30 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
354
374
  const measureLimit = Math.floor(p.maxOperationCells / AUTOFIT_MEASURE_DIVISOR)
355
375
  if (measuredCells > measureLimit) {
356
376
  p.onOperationRejected?.("resize", measuredCells, "limit")
357
- return
377
+ return false
358
378
  }
359
379
  // canvas 生成 + computed font 取得はジェスチャ 1 回につき 1 度 (§7.3)。font は
360
380
  // ロングハンド合成の単一経路 (Firefox は font ショートハンドの computed 値を空で
361
381
  // 返すため、合成だけが全ブラウザで決定的)
362
382
  const context = document.createElement("canvas").getContext("2d")
363
383
  if (context === null) {
364
- return // 実ブラウザでは常に成立 (jsdom は 2d 未実装 — 計測面は E2E がピン)
384
+ return true // 実ブラウザでは常に成立 (jsdom は 2d 未実装 — 計測面は E2E がピン)
365
385
  }
366
386
  const bandStyle = getComputedStyle(band)
367
387
  // 既定フォント (書式なしセル) — 帯の computed style のロングハンド合成
368
388
  const baseFont = `${bandStyle.fontStyle} ${bandStyle.fontWeight} ${bandStyle.fontSize} ${bandStyle.fontFamily}`
389
+ // 見出しの行は書式でなくスタイルシートが太くする — 測る書体もセルと同じ太さにする。スタイルシートが読まれていなければ
390
+ // 見出しの行も帯と同じ太さで描かれるので、帯の太さがそのまま実際の描画の値
391
+ const headerWeight = bandStyle.getPropertyValue("--_aqsp-header-row-font-weight").trim()
392
+ const headerStyle = { fontStyle: bandStyle.fontStyle, fontWeight: headerWeight === "" ? bandStyle.fontWeight : headerWeight, fontSize: bandStyle.fontSize, fontFamily: bandStyle.fontFamily }
393
+ const headerFont = `${headerStyle.fontStyle} ${headerStyle.fontWeight} ${headerStyle.fontSize} ${headerStyle.fontFamily}`
369
394
  // CF は幅に効く規則 (bold / italic 設定) と stopIfTrue の障壁だけを 1 ジェスチャ 1 回抽出し列毎に
370
395
  // スライスする (実測 155 → 56 ms)。障壁を残すため走査は塗りと同じ位置で止まり bold / italic が
371
396
  // 厳密一致する。getConditionalFormats は getCellFormat を要する (root がマウント時に保証)
372
397
  const cfRules = p.adapter.getConditionalFormats === undefined ? null : widthRelevantRules(p.adapter.getConditionalFormats())
373
398
  const memo = createCfMemo()
374
399
  const fontKeys = new WeakMap<CellFormat, string>()
400
+ const headerFontKeys = new WeakMap<CellFormat, string>()
375
401
  // フォントキー毎のバケット (列 index + 表示文字列): ctx.font の代入は異なりフォント毎に 1 回
376
402
  const buckets = new Map<string, { col: number; text: string }[]>()
377
403
  for (const col of targets) {
@@ -384,7 +410,8 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
384
410
  const raw = p.adapter.getCellText(row, col)
385
411
  // 塗り書式 (base ⊕ 幅に効く CF) — raw は既読なので thunk は再読しない
386
412
  const painted = p.adapter.getCellFormat === undefined ? undefined : resolvePaintedFormat(p.adapter, colIndex, memo, row, col, () => raw)
387
- const font = painted === undefined ? baseFont : fontKeyOf(painted, fontKeys, bandStyle)
413
+ const isHeaderRow = row < p.headerRows
414
+ const font = painted === undefined ? (isHeaderRow ? headerFont : baseFont) : isHeaderRow ? fontKeyOf(painted, headerFontKeys, headerStyle) : fontKeyOf(painted, fontKeys, bandStyle)
388
415
  const text = formatCellValue(raw, painted === undefined ? undefined : painted.value)
389
416
  const bucket = buckets.get(font)
390
417
  if (bucket === undefined) {
@@ -415,13 +442,51 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
415
442
  }
416
443
  }
417
444
  if (entries.length === 0) {
418
- return
445
+ return true
446
+ }
447
+ return applyEntries("col", entries, pressedIndex)
448
+ },
449
+ [collectTargets, applyEntries],
450
+ )
451
+
452
+ const stepTrack = useCallback(
453
+ (axis: "row" | "col", index: number, direction: 1 | -1): boolean => {
454
+ const axisModel = axis === "col" ? paramsRef.current.colAxis : paramsRef.current.rowAxis
455
+ const size = keyboardStepSize(axisModel.sizeOf(index), direction)
456
+ // 端にあるトラックは寸法を保つ (当てるものが無い — 拒否ではないので onOperationRejected は鳴らさない)
457
+ if (size === axisModel.sizeOf(index)) {
458
+ return true
459
+ }
460
+ const targets = collectTargets(axis, index)
461
+ if (targets === null) {
462
+ return false
419
463
  }
420
- applyEntries("col", entries, pressedIndex)
464
+ return applyEntries(
465
+ axis,
466
+ targets.map((target) => ({ index: target, size })),
467
+ index,
468
+ )
421
469
  },
422
470
  [collectTargets, applyEntries],
423
471
  )
424
472
 
473
+ const autofitColumn = useCallback(
474
+ (index: number): boolean => {
475
+ const p = paramsRef.current
476
+ const band = p.colBandRef.current
477
+ // renderCell のカスタム DOM は measureText で測れない (帯のダブルクリックと同じ逸脱) — 帯の未装着は測る書体が無い
478
+ if (!p.autofitEnabled || band === null) {
479
+ return false
480
+ }
481
+ // 隠れた列は隠れたまま (戻すのは再表示だけ) — 走ったが何も変えない
482
+ if (p.colAxis.sizeOf(index) === 0) {
483
+ return true
484
+ }
485
+ return autofitColumns(band, index)
486
+ },
487
+ [autofitColumns],
488
+ )
489
+
425
490
  const { enabled, colBandRef, rowBandRef } = params
426
491
  useEffect(() => {
427
492
  const colBand = colBandRef.current
@@ -650,6 +715,8 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
650
715
 
651
716
  return useMemo(
652
717
  () => ({
718
+ stepTrack,
719
+ autofitColumn,
653
720
  isDragging: () => draggingRef.current,
654
721
  // リサイズ / unhide は選択矩形を配送しない — 配送モードは常に加算
655
722
  getDragMode: (): "add" | "subtract" | "point" => "add",
@@ -661,6 +728,6 @@ export const useHeaderResize = (params: UseHeaderResizeParams): SelectionEngine
661
728
  },
662
729
  cancelDrag: cleanupDrag,
663
730
  }),
664
- [cleanupDrag],
731
+ [cleanupDrag, stepTrack, autofitColumn],
665
732
  )
666
733
  }
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@ export { type HtmlTableKind, type HtmlTableResult, parseHtmlTable, serializeHtml
46
46
  export type { NumberFormatDecodeOptions } from "./clipboard/numberFormatCodec.ts"
47
47
  export type { PasteMode, PasteOperation, PasteOptions } from "./clipboard/pasteSpecial.ts"
48
48
  export { estimatePasteCellCount, expandPaste } from "./clipboard/tiling.ts"
49
- export { parseTsv, serializeTsv } from "./clipboard/tsv.ts"
49
+ export { iterateTsvRows, measureTsv, parseTsv, serializeClipboardTsv, serializeTsv } from "./clipboard/tsv.ts"
50
50
  export type { ClipInfo, PasteOutcome } from "./clipboard/useClipboard.ts"
51
51
  export type { BorderPreset } from "./commands/useCommands.ts"
52
52
  // renderCell の ctx 型 — prop が公開面にある以上、その引数型も名指しできなければならない
@@ -67,6 +67,12 @@ export type SpreadsheetCommand =
67
67
  | "hideColumns"
68
68
  | "unhideRows"
69
69
  | "unhideColumns"
70
+ // 寸法のキーボード操作 (WCAG 2.1.1 — 帯の境界のドラッグと同じ処理)
71
+ | "increaseColumnWidth"
72
+ | "decreaseColumnWidth"
73
+ | "increaseRowHeight"
74
+ | "decreaseRowHeight"
75
+ | "autoFitColumnWidth"
70
76
  // 拡張コマンド (既定 [] = 未束縛、再バインド可)
71
77
  | "formatGeneral"
72
78
  | "bordersAll"
@@ -147,6 +153,11 @@ export const SPREADSHEET_COMMANDS: readonly SpreadsheetCommand[] = [
147
153
  "hideColumns",
148
154
  "unhideRows",
149
155
  "unhideColumns",
156
+ "increaseColumnWidth",
157
+ "decreaseColumnWidth",
158
+ "increaseRowHeight",
159
+ "decreaseRowHeight",
160
+ "autoFitColumnWidth",
150
161
  "formatGeneral",
151
162
  "bordersAll",
152
163
  "pasteFormats",
@@ -498,8 +509,64 @@ export const BUILTIN_ROWS: readonly BuiltinRow[] = [
498
509
  command("hideRows", "Ctrl+Alt+Digit9", "idle", "hide the selected rows"),
499
510
  command("unhideRows", "Ctrl+Shift+Digit9", "idle", "unhide rows"),
500
511
  command("unhideColumns", "Ctrl+Shift+Digit0", "idle", "unhide columns"),
512
+ command("increaseColumnWidth", "Alt+Shift+ArrowRight", "idle", "widen the active column by one step (x 2^(1/4))"),
513
+ command("decreaseColumnWidth", "Alt+Shift+ArrowLeft", "idle", "narrow the active column by one step (/ 2^(1/4), never below 1 px)"),
514
+ command("increaseRowHeight", "Alt+Shift+ArrowDown", "idle", "make the active row taller by one step (x 2^(1/4))"),
515
+ command("decreaseRowHeight", "Alt+Shift+ArrowUp", "idle", "make the active row shorter by one step (/ 2^(1/4), never below 1 px)"),
516
+ command("autoFitColumnWidth", "Alt+Shift+Equal", "idle", "fit the active column to its content (the strip double-click)"),
501
517
  ]
502
518
 
519
+ /**
520
+ * The commands that change the size of the active track (WCAG 2.1.1 — the keyboard twins of the header strips), in the
521
+ * order the hidden input describes them through `aria-keyshortcuts`.
522
+ * アクティブなトラックの寸法を変えるコマンド (WCAG 2.1.1 — 帯の境界のキーボードの双子)。隠しの入力が `aria-keyshortcuts` で
523
+ * 示す順。
524
+ */
525
+ export const RESIZE_COMMANDS: readonly SpreadsheetCommand[] = Object.freeze(["increaseColumnWidth", "decreaseColumnWidth", "increaseRowHeight", "decreaseRowHeight", "autoFitColumnWidth"])
526
+
527
+ /** ARIA key names of the code tokens whose key differs from the token (US layout, unshifted). / トークンと名前が違うコードの ARIA のキー名 (US 配列・Shift なし)。 */
528
+ const ARIA_KEY_OF_CODE: Readonly<Record<string, string>> = Object.freeze({
529
+ Equal: "=",
530
+ Minus: "-",
531
+ Semicolon: ";",
532
+ Quote: "'",
533
+ Comma: ",",
534
+ Period: ".",
535
+ Slash: "/",
536
+ Backslash: "\\",
537
+ BracketLeft: "[",
538
+ BracketRight: "]",
539
+ Backquote: "`",
540
+ NumpadAdd: "+",
541
+ NumpadSubtract: "-",
542
+ NumpadMultiply: "*",
543
+ NumpadDivide: "/",
544
+ NumpadDecimal: ".",
545
+ NumpadEnter: "Enter",
546
+ })
547
+
548
+ /**
549
+ * One canonical chord in the `aria-keyshortcuts` syntax: `Ctrl` becomes `Control`, digits and punctuation become the
550
+ * key they produce on a US layout without Shift (`Equal` → `=`, `Digit5` → `5`), and every other token is already an
551
+ * ARIA key name. The layout-specific keys `IntlYen`, `IntlRo` and `IntlBackslash` have no fixed character, so a chord
552
+ * using one has no ARIA form (null).
553
+ * 正準のチョード 1 つの `aria-keyshortcuts` の書き方。`Ctrl` は `Control`、数字と記号は US 配列で Shift なしに生む文字
554
+ * (`Equal` → `=`、`Digit5` → `5`) にし、ほかのトークンはそのまま ARIA のキー名。配列ごとのキー `IntlYen` / `IntlRo` /
555
+ * `IntlBackslash` は文字が決まらないので、それを使うチョードは ARIA の形を持たない (null)。
556
+ *
557
+ * @param chord - A canonical chord (`Ctrl+Alt+Shift+KEY`). 正準のチョード。
558
+ * @returns The ARIA form, or null. ARIA の形、または null。
559
+ */
560
+ export const toAriaKeyShortcut = (chord: string): string | null => {
561
+ const parsed = parseChord(chord)
562
+ if (parsed.key === "IntlYen" || parsed.key === "IntlRo" || parsed.key === "IntlBackslash") {
563
+ return null
564
+ }
565
+ const digit = /^(?:Digit|Numpad)(\d)$/u.exec(parsed.key)
566
+ const key = digit !== null ? (digit[1] as string) : parsed.key in ARIA_KEY_OF_CODE ? (ARIA_KEY_OF_CODE[parsed.key] as string) : parsed.key
567
+ return `${parsed.ctrl ? "Control+" : ""}${parsed.alt ? "Alt+" : ""}${parsed.shift ? "Shift+" : ""}${key}`
568
+ }
569
+
503
570
  /** True when a row is consulted in the given column (`both` covers idle and editing). / 行が指定列で参照されるなら true (`both` は idle と編集の両方)。 */
504
571
  const rowInColumn = (row: BuiltinRow, column: "idle" | "editing"): boolean => row.column === "both" || row.column === column
505
572
 
@@ -581,6 +581,80 @@ export const readOnlyDeclared = (adapter: SpreadsheetDataAdapter): boolean => ad
581
581
  */
582
582
  export const isReadOnlyCell = (adapter: SpreadsheetDataAdapter, row: number, col: number): boolean => adapter.isCellReadOnly?.(row, col) === true
583
583
 
584
+ /**
585
+ * Every member of SpreadsheetDataAdapter, so a view can forward each one by name; the compile-time check below fails when
586
+ * the adapter type gains a member this list lacks.
587
+ * SpreadsheetDataAdapter の全メンバー。ビューが名前ごとに転送するための一覧で、型にメンバーが増えて一覧に無いときは下の
588
+ * コンパイル時の検査が落ちる。
589
+ */
590
+ const ADAPTER_MEMBERS = [
591
+ "rowCount",
592
+ "colCount",
593
+ "getCellText",
594
+ "applyEdits",
595
+ "findDataEdge",
596
+ "getCellFormat",
597
+ "getCellFormula",
598
+ "getCellNote",
599
+ "getCellComments",
600
+ "getCellHyperlink",
601
+ "getCellValidation",
602
+ "getConditionalFormats",
603
+ "isCellReadOnly",
604
+ "getCellAttributes",
605
+ "getRangeExtrema",
606
+ "evaluateFormula",
607
+ ] as const satisfies readonly (keyof SpreadsheetDataAdapter)[]
608
+
609
+ /** True when ADAPTER_MEMBERS names every member of the adapter type. / ADAPTER_MEMBERS が型の全メンバーを挙げているとき true。 */
610
+ const ADAPTER_MEMBERS_COMPLETE: [Exclude<keyof SpreadsheetDataAdapter, (typeof ADAPTER_MEMBERS)[number]>] extends [never] ? true : never = true
611
+ void ADAPTER_MEMBERS_COMPLETE
612
+
613
+ /** The read-only answer of a whole read-only grid: every cell. / グリッド全体を読み取り専用にしたときの答え (どのセルも読み取り専用)。 */
614
+ const EVERY_CELL_READ_ONLY = (): boolean => true
615
+
616
+ /**
617
+ * A view of `adapter` whose every cell is read-only (the `readOnly` prop): `isCellReadOnly` answers true for every cell
618
+ * whatever the adapter declares, so every read-only gate refuses every write and routes it to `onReadOnlyRejected`, and
619
+ * every other member reads the adapter live (getters run on the adapter, methods are bound to it once per function
620
+ * identity), so a class-based adapter with private fields or accessors keeps working through the view.
621
+ * 全てのセルを読み取り専用にした `adapter` のビュー (`readOnly` prop)。`isCellReadOnly` はアダプタの宣言に関わらずどのセルにも
622
+ * true を返すので、読み取り専用のゲートは全ての書き込みを拒み `onReadOnlyRejected` へ送る。ほかのメンバーはアダプタを毎回読む
623
+ * (アクセサはアダプタの上で動き、メソッドは関数の同一性ごとに 1 度だけアダプタへ束ねる) ので、private なフィールドやアクセサを
624
+ * 持つクラスのアダプタもビュー越しに動く。
625
+ *
626
+ * @param adapter - The host's adapter. ホストのアダプタ。
627
+ * @returns The read-only view. 読み取り専用のビュー。
628
+ */
629
+ export const readOnlyAdapterView = (adapter: SpreadsheetDataAdapter): SpreadsheetDataAdapter => {
630
+ const bound = new WeakMap<object, unknown>()
631
+ const view = {} as SpreadsheetDataAdapter
632
+ for (const member of ADAPTER_MEMBERS) {
633
+ if (member === "isCellReadOnly") {
634
+ continue
635
+ }
636
+ Object.defineProperty(view, member, {
637
+ enumerable: true,
638
+ get: () => {
639
+ const value: unknown = Reflect.get(adapter, member, adapter)
640
+ if (typeof value !== "function") {
641
+ return value
642
+ }
643
+ // メソッドの同一性を保つ (毎回 bind すると依存配列の比較や能力の読みのたびに別の関数になる)
644
+ const cached = bound.get(value)
645
+ if (cached !== undefined) {
646
+ return cached
647
+ }
648
+ const fresh = value.bind(adapter)
649
+ bound.set(value, fresh)
650
+ return fresh
651
+ },
652
+ })
653
+ }
654
+ Object.defineProperty(view, "isCellReadOnly", { enumerable: true, value: EVERY_CELL_READ_ONLY })
655
+ return view
656
+ }
657
+
584
658
  /** Host cell-attribute key grammar (v0.12): lowercase `data-*` only. / ホストセル属性キーの文法 (v0.12): 小文字 `data-*` のみ。 */
585
659
  const CELL_ATTRIBUTE_KEY = /^data-[a-z0-9-]+$/
586
660
 
@@ -371,7 +371,7 @@ export const placeBandSegments = (nodes: BandNodes, rect: BandRect, extents: Ban
371
371
  // 埋め色は族の CSS 変数を参照するインライン background (パレット交換に追随)、交点表示へ戻るとき解除
372
372
  if (crossTint !== last.crossTint) {
373
373
  last.crossTint = crossTint
374
- nodes.cross.style.background = crossTint === "intersection" ? "" : `var(--aqsp-band-${crossTint})`
374
+ nodes.cross.style.background = crossTint === "intersection" ? "" : `var(--_aqsp-band-${crossTint})`
375
375
  }
376
376
  if (rect.width !== last.crossWidth || rect.height !== last.crossHeight) {
377
377
  last.crossWidth = rect.width
@@ -490,6 +490,10 @@ type SelectionOverlayProps = {
490
490
  /** v0.9 capability: trailing frozen rows (the vertical twin of trailingCols). / v0.9 能力: 末尾凍結行数 (trailingCols の縦双子)。 */
491
491
  trailingRows?: number
492
492
  sinkAriaLabel: string
493
+ /** Whole-grid read-only (the `readOnly` prop): the hidden input carries `aria-readonly="true"`. / グリッド全体の読み取り専用 (`readOnly` prop)。隠しの入力が `aria-readonly="true"` を持つ。 */
494
+ sinkReadOnly: boolean
495
+ /** The keyboard resize chords in `aria-keyshortcuts` syntax, or undefined. / キーボードの寸法の操作のチョード (`aria-keyshortcuts` の書き方)。無ければ undefined。 */
496
+ sinkKeyShortcuts: string | undefined
493
497
  onSinkMouseDown: (event: MouseEvent) => void
494
498
  /**
495
499
  * v0.8 capability: the editing sink's paint (line box + vertical alignment) owned by the
@@ -683,6 +687,8 @@ export const SelectionOverlay = ({
683
687
  trailingCols,
684
688
  trailingRows,
685
689
  sinkAriaLabel,
690
+ sinkReadOnly,
691
+ sinkKeyShortcuts,
686
692
  onSinkMouseDown,
687
693
  editPaintRef,
688
694
  highlightRef,
@@ -1899,7 +1905,21 @@ export const SelectionOverlay = ({
1899
1905
  一度も変化しない — 変換中の属性変更 (Windows TSF が変換を強制終了する) を構造的に排除する。
1900
1906
  OSK の実際の表示は UA 依存: WebKit はフォーカス済み要素の変更を再評価する、Chromium はフォーカス取得時に
1901
1907
  しか評価しない。ハードウェアキーボードとデスクトップ IME は無影響 */}
1902
- <textarea ref={mergedSinkRef} className="aqsp-sink" aria-label={sinkAriaLabel} tabIndex={-1} defaultValue=" " inputMode={virtualKeyboard ? "text" : "none"} spellCheck={false} autoCorrect="off" autoCapitalize="off" autoComplete="off" data-aqsp-sink="" />
1908
+ <textarea
1909
+ ref={mergedSinkRef}
1910
+ className="aqsp-sink"
1911
+ aria-label={sinkAriaLabel}
1912
+ aria-readonly={sinkReadOnly ? "true" : undefined}
1913
+ aria-keyshortcuts={sinkKeyShortcuts}
1914
+ tabIndex={-1}
1915
+ defaultValue=" "
1916
+ inputMode={virtualKeyboard ? "text" : "none"}
1917
+ spellCheck={false}
1918
+ autoCorrect="off"
1919
+ autoCapitalize="off"
1920
+ autoComplete="off"
1921
+ data-aqsp-sink=""
1922
+ />
1903
1923
  {/* pointer-events: auto の例外第 2 号 (§8.1) — シンクより後置 = 最上層。enableFillHandle=false は
1904
1924
  DOM ごと抑止 (機械も不活性) */}
1905
1925
  {enableFillHandle ? <div ref={fillHandleNodeRef} className="aqsp-autofill-handle" data-aqsp-autofill-handle="" style={{ display: "none" }} /> : null}