@aiquants/spreadsheet 0.8.0 → 0.10.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.
- package/CHANGELOG.md +104 -0
- package/README.md +160 -13
- package/dist/index.cjs +13 -13
- package/dist/index.d.cts +156 -7
- package/dist/index.d.ts +156 -7
- package/dist/index.js +4508 -4057
- package/dist/styles/spreadsheet.css +1 -1
- package/dist/styles/spreadsheet.standalone.css +1 -1
- package/package.json +5 -5
- package/src/Spreadsheet.tsx +350 -34
- package/src/clipboard/tiling.ts +106 -16
- package/src/clipboard/useClipboard.ts +120 -7
- package/src/commands/useCommands.ts +226 -42
- package/src/editor/useCellEditor.ts +62 -13
- package/src/grid/GridBody.tsx +45 -3
- package/src/header/ColumnHeader.tsx +84 -12
- package/src/header/RowHeader.tsx +79 -11
- package/src/header/resizeStrips.ts +86 -1
- package/src/header/useHeaderResize.ts +88 -20
- package/src/header/useHeaderSelection.ts +28 -9
- package/src/index.ts +5 -1
- package/src/keyboard/ensureCellVisible.ts +40 -17
- package/src/keyboard/useGridKeyboard.ts +34 -7
- package/src/model/dataAdapter.ts +121 -2
- package/src/model/frozenPartition.ts +183 -59
- package/src/model/labels.ts +8 -2
- package/src/overlay/SelectionOverlay.tsx +599 -116
- package/src/overlay/useFillHandle.ts +100 -13
- package/src/popover/CommentPopover.tsx +14 -2
- package/src/popover/LinkPopover.tsx +15 -3
- package/src/popover/NotePopover.tsx +24 -6
- package/src/popover/usePopovers.ts +128 -18
- package/src/selection/useAutoScroller.ts +58 -15
- package/src/selection/useSelectionEngine.ts +27 -10
- package/src/styles/spreadsheet.css +32 -0
|
@@ -39,6 +39,15 @@
|
|
|
39
39
|
* - Return value: true = committed (or nothing needed writing), false = gate rejection / adapter
|
|
40
40
|
* rejection / command unavailable / commit-gate refusal. Every callback is identity-stable
|
|
41
41
|
* (params are read through `paramsRef`).
|
|
42
|
+
* - v0.10 read-only walls (design ADR-28/30, all through the dataAdapter helpers, refusals via the
|
|
43
|
+
* required `rejectReadOnly` funnel): G8 `runFill` two-phase collect → walk → materialise (the
|
|
44
|
+
* walk covers the producer's own targets — a read-only SOURCE stays legal), G9 editing
|
|
45
|
+
* `fillSelection`, G10 `writeFormat` (walks the invoked target ranges, never the selection),
|
|
46
|
+
* G11 `runCalc`, G12 idle insertNow, G13 the four annotate setters (commit-gate-first kept),
|
|
47
|
+
* and G15 — ONE shared pre-check for the four popover-open command ids between the availability
|
|
48
|
+
* bail and the dispatch-level `requestCommit` (content-present annotate chords fall through and
|
|
49
|
+
* open the viewer; content-absent and the list chord consume + refuse without force-committing).
|
|
50
|
+
* Capability undeclared = every wall compiles to the previous behaviour byte-identically.
|
|
42
51
|
*
|
|
43
52
|
* コマンド実行器 (v0.8 §4.1.13 — ADR-17)。70 の SpreadsheetCommand 全件の executeCommand、handle の
|
|
44
53
|
* 書込群 (applyFormat / setBorders / setHyperlink / setNote / addComment / setValidation)、能力
|
|
@@ -67,7 +76,13 @@
|
|
|
67
76
|
* トグルは setHighlight。formatCommandAnnouncement (opt-in) は書式コマンド成功後に announce へ
|
|
68
77
|
* (state はトグルの適用後状態、非トグルは null)。戻り値: true = commit 成功 (書くものが無い場合も)、
|
|
69
78
|
* false = ゲート拒否 / アダプタ拒否 / 不可用 / commit ゲート拒否。全コールバックは identity 安定
|
|
70
|
-
* (パラメータは paramsRef 経由で読む)。
|
|
79
|
+
* (パラメータは paramsRef 経由で読む)。v0.10 の読み取り専用壁 (設計 ADR-28/30、全て dataAdapter
|
|
80
|
+
* ヘルパ経由、拒否は必須 `rejectReadOnly` 漏斗): G8 runFill の 2 相化 (収集 → walk → 実体化 —
|
|
81
|
+
* walk はプロデューサ自身の targets、読み取り専用ソースは合法のまま)、G9 編集中 fillSelection、
|
|
82
|
+
* G10 writeFormat (呼出しパラメータを walk — 選択ではない)、G11 runCalc、G12 idle insertNow、
|
|
83
|
+
* G13 注釈 setter ×4 (commit-gate-first 維持)、G15 = popover 系 4 コマンド id の単一 pre-check
|
|
84
|
+
* (availability bail と dispatch 水準 requestCommit の間 — 内容有の注釈チョードはビューア開通、
|
|
85
|
+
* 内容無と list は force-commit なしの消費拒否)。能力未宣言 = 全壁が従来挙動と byte 恒等。
|
|
71
86
|
*/
|
|
72
87
|
|
|
73
88
|
import { useCallback, useMemo, useRef } from "react"
|
|
@@ -80,7 +95,7 @@ import { type CellCommentInput, normalizeAdapterHyperlink, normalizeHyperlink }
|
|
|
80
95
|
import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, type SpreadsheetOperation } from "../model/cellCoord.ts"
|
|
81
96
|
import { BORDER_DEFAULT_COLOR, type CellBorder, type CellBorders, type CellFormat, type CellFormatPatch, type CellValueFormat, mergeFormat } from "../model/cellFormat.ts"
|
|
82
97
|
import { normalizeColor } from "../model/cellPaint.ts"
|
|
83
|
-
import type
|
|
98
|
+
import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
84
99
|
import type { DataValidationRule, ValidationVerdict } from "../model/dataValidation.ts"
|
|
85
100
|
import type { PopoverKind, PopoverOverrides } from "../popover/usePopovers.ts"
|
|
86
101
|
import type { SelectionState } from "../selection/selectionState.ts"
|
|
@@ -118,6 +133,15 @@ export type UseCommandsParams = {
|
|
|
118
133
|
refreshEditPaint: () => void
|
|
119
134
|
maxOperationCells: number
|
|
120
135
|
onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
|
|
136
|
+
/**
|
|
137
|
+
* v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
|
|
138
|
+
* read through paramsRef): the producer walls G8–G13 and the popover-chord pre-check G15
|
|
139
|
+
* fire it once per refused gesture. Capability absent ⇒ never called (byte identity).
|
|
140
|
+
* v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
|
|
141
|
+
* プロデューサ壁 G8–G13 と popover チョード pre-check G15 が拒否ジェスチャ毎 1 回発火する。
|
|
142
|
+
* 能力未宣言なら一度も呼ばれない (バイト恒等)。
|
|
143
|
+
*/
|
|
144
|
+
rejectReadOnly: (rejection: ReadOnlyRejection) => void
|
|
121
145
|
/** Custom colour slots (5 each) for fillColorCustomN / fontColorCustomN. / fillColorCustomN / fontColorCustomN の色スロット (各 5)。 */
|
|
122
146
|
colors: { fill: readonly string[]; font: readonly string[] }
|
|
123
147
|
/** The formatCurrency currency / rendering locale; undefined ⇒ formatCurrency is unavailable (passes through). / formatCurrency の通貨 / locale。未宣言なら formatCurrency は行不在 (素通し)。 */
|
|
@@ -395,6 +419,13 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
395
419
|
if (!gateSelectionWrite("format", ranges, p.maxOperationCells, p.onOperationRejected)) {
|
|
396
420
|
return false
|
|
397
421
|
}
|
|
422
|
+
// G10 (v0.10 §6.9): 読み取り専用壁 — walk は**呼出しパラメータ** (targetRanges) であって選択では
|
|
423
|
+
// ない (編集列トグルはセッションセル 1×1 を渡す — 古い混在選択で拒否しない。設計 rule 3)。
|
|
424
|
+
// 限度ゲートの後 = O(1) 算術が O(cells) walk を守る既存拒否ランク
|
|
425
|
+
if (rangesContainReadOnly(p.adapter, ranges)) {
|
|
426
|
+
p.rejectReadOnly({ kind: "operation", op: "format", cellCount: countRangeCells(ranges) })
|
|
427
|
+
return false
|
|
428
|
+
}
|
|
398
429
|
// memo: バケット (パッチの同一性) × base identity — パッチはジェスチャ内で定数なので
|
|
399
430
|
// intern された base 1 つにつき mergeFormat は 1 回 (applyEdits 引数の format identity は 1 つ)
|
|
400
431
|
const memo = new Map<number, Map<CellFormat | undefined, CellFormat | undefined>>()
|
|
@@ -478,7 +509,16 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
478
509
|
}
|
|
479
510
|
}, [])
|
|
480
511
|
|
|
481
|
-
/**
|
|
512
|
+
/**
|
|
513
|
+
* fillDown / fillRight / fillSelection (idle) — two phases since v0.10 (§0.2 R2): phase 1
|
|
514
|
+
* collects the (source, targets) pairs with coordinate arithmetic only (no reads, no edits),
|
|
515
|
+
* the G8 read-only walk runs over the collected targets, and only a passing gesture
|
|
516
|
+
* materialises (readFillSource + fillSourceEdit) and commits.
|
|
517
|
+
* fillDown / fillRight / fillSelection (idle) — v0.10 から 2 相 (§0.2 R2): 第 1 相は座標算術
|
|
518
|
+
* だけで (source, targets) 対を収集 (読みも edits 生成もしない)、G8 の読み取り専用 walk を
|
|
519
|
+
* 収集済み targets に掛け、通過したジェスチャだけが実体化 (readFillSource + fillSourceEdit)
|
|
520
|
+
* して commit する。
|
|
521
|
+
*/
|
|
482
522
|
const runFill = useCallback(
|
|
483
523
|
(direction: "down" | "right" | "selection"): boolean => {
|
|
484
524
|
const p = paramsRef.current
|
|
@@ -486,7 +526,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
486
526
|
if (!gateSelectionWrite("fill", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
|
|
487
527
|
return false
|
|
488
528
|
}
|
|
489
|
-
|
|
529
|
+
// 第 1 相: (source, targets) 対の収集のみ — 対の順序は従来の fillEdits 呼出し順と同一 (byte 恒等)
|
|
530
|
+
const pairs: { source: CellCoord; targets: CellCoord[] }[] = []
|
|
490
531
|
if (direction === "selection") {
|
|
491
532
|
// active の内容を active 以外の全選択セルへ
|
|
492
533
|
const active = selection.active
|
|
@@ -496,7 +537,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
496
537
|
targets.push(cell)
|
|
497
538
|
}
|
|
498
539
|
})
|
|
499
|
-
|
|
540
|
+
pairs.push({ source: active, targets })
|
|
500
541
|
} else {
|
|
501
542
|
for (const range of selection.ranges) {
|
|
502
543
|
if (direction === "down") {
|
|
@@ -511,7 +552,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
511
552
|
for (let row = single ? range.startRow : range.startRow + 1; row <= range.endRow; row += 1) {
|
|
512
553
|
targets.push({ row, col })
|
|
513
554
|
}
|
|
514
|
-
|
|
555
|
+
pairs.push({ source: { row: sourceRow, col }, targets })
|
|
515
556
|
}
|
|
516
557
|
} else {
|
|
517
558
|
const single = range.startCol === range.endCol
|
|
@@ -525,11 +566,31 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
525
566
|
for (let col = single ? range.startCol : range.startCol + 1; col <= range.endCol; col += 1) {
|
|
526
567
|
targets.push({ row, col })
|
|
527
568
|
}
|
|
528
|
-
|
|
569
|
+
pairs.push({ source: { row, col: sourceCol }, targets })
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// G8 (v0.10 §6.9): 収集済み対の全 targets を 1 サイトで walk (最初の hit で短絡)。読み取り
|
|
575
|
+
// 専用**ソース**は walk 外 = 合法のまま (Excel 同等 — RC-G8)。拒否 cellCount = Σ targets.length
|
|
576
|
+
// (overlap-naive — ONE 語彙)。実体化 (ソース読み) より前に立つ
|
|
577
|
+
for (const pair of pairs) {
|
|
578
|
+
for (const target of pair.targets) {
|
|
579
|
+
if (isReadOnlyCell(p.adapter, target.row, target.col)) {
|
|
580
|
+
let cellCount = 0
|
|
581
|
+
for (const counted of pairs) {
|
|
582
|
+
cellCount += counted.targets.length
|
|
529
583
|
}
|
|
584
|
+
p.rejectReadOnly({ kind: "operation", op: "fill", cellCount })
|
|
585
|
+
return false
|
|
530
586
|
}
|
|
531
587
|
}
|
|
532
588
|
}
|
|
589
|
+
// 第 2 相: 実体化 — ソース毎に 1 回読み、対象へ複製 (従来と同じ呼出し順)
|
|
590
|
+
const edits: CellEdit[] = []
|
|
591
|
+
for (const pair of pairs) {
|
|
592
|
+
fillEdits(pair.source, pair.targets, edits)
|
|
593
|
+
}
|
|
533
594
|
if (edits.length === 0) {
|
|
534
595
|
return true
|
|
535
596
|
}
|
|
@@ -538,6 +599,60 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
538
599
|
[fillEdits],
|
|
539
600
|
)
|
|
540
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Editing-column Ctrl+Enter (`fillSelection` with an open session): writes the session text
|
|
604
|
+
* to every selected cell as ONE applyEdits and closes the session. Order: limit gate → the
|
|
605
|
+
* G9 read-only wall (v0.10 — whole refusal, session kept, `validateCommit` unreached) → the
|
|
606
|
+
* per-target validation walk (the first strict refusal aborts before the adapter) → commit →
|
|
607
|
+
* `cancelEdit` (no double write).
|
|
608
|
+
* 編集列 Ctrl+Enter (セッション中の fillSelection): セッション文字列を全選択セルへ 1 applyEdits
|
|
609
|
+
* で書き、セッションを閉じる。順序: 限度ゲート → G9 読み取り専用壁 (v0.10 — 全体拒否・
|
|
610
|
+
* セッション保持・validateCommit 不到達) → 対象セル毎の検証 walk (最初の strict 拒否で adapter
|
|
611
|
+
* 前に中断) → commit → cancelEdit (二重書込なし)。
|
|
612
|
+
*/
|
|
613
|
+
const runFillSelectionEditing = useCallback((): boolean => {
|
|
614
|
+
const p = paramsRef.current
|
|
615
|
+
// 編集列: セッション文字列を全選択セルへ 1 applyEdits、セッション閉鎖
|
|
616
|
+
const text = p.getSessionText()
|
|
617
|
+
if (text === null) {
|
|
618
|
+
return false
|
|
619
|
+
}
|
|
620
|
+
const selection = p.getSelection()
|
|
621
|
+
if (!gateSelectionWrite("fill", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
|
|
622
|
+
return false
|
|
623
|
+
}
|
|
624
|
+
// G9 (v0.10 §6.9): 読み取り専用壁 — validateCommit walk より前・セッション保持で
|
|
625
|
+
// 全体拒否 (書込集合 = 選択そのもの、意図した粗さ — 設計 rule 3)
|
|
626
|
+
if (rangesContainReadOnly(p.adapter, selection.ranges)) {
|
|
627
|
+
p.rejectReadOnly({ kind: "operation", op: "fill", cellCount: countRangeCells(selection.ranges) })
|
|
628
|
+
return false
|
|
629
|
+
}
|
|
630
|
+
// 検証ゲート (分冊 08 §4.2) は対象セル毎 — 規則はセル毎に違い得る。最初の strict 拒否で
|
|
631
|
+
// 走査を打ち切り applyEdits へ届けない (通知と message ポップオーバーは root の validateCommit が担う)
|
|
632
|
+
const edits: CellEdit[] = []
|
|
633
|
+
let rejected = false
|
|
634
|
+
enumerateRangeCells(selection.ranges, (cell) => {
|
|
635
|
+
if (rejected) {
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
if (!p.validateCommit(cell, text).ok) {
|
|
639
|
+
rejected = true
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
edits.push({ row: cell.row, col: cell.col, text })
|
|
643
|
+
})
|
|
644
|
+
// 拒否はセッション維持のまま false (Enter の keep 経路と同じ — Escape で取消 / 修正して再試行)
|
|
645
|
+
if (rejected) {
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
if (p.commitEdits(edits, { source: "fill" }) === null) {
|
|
649
|
+
return false
|
|
650
|
+
}
|
|
651
|
+
// 値は全セルへ書かれた — セッションは破棄で閉じる (二重書込なし)
|
|
652
|
+
p.cancelEdit()
|
|
653
|
+
return true
|
|
654
|
+
}, [])
|
|
655
|
+
|
|
541
656
|
/** Self-calc over the selection (source "calc"). / 選択の自己計算 (source "calc")。 */
|
|
542
657
|
const runCalc = useCallback((transform: { text: (text: string) => string | null; formula: (formula: string) => string }): boolean => {
|
|
543
658
|
const p = paramsRef.current
|
|
@@ -545,6 +660,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
545
660
|
if (!gateSelectionWrite("calc", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
|
|
546
661
|
return false
|
|
547
662
|
}
|
|
663
|
+
// G11 (v0.10 §6.9): 読み取り専用壁 — 限度ゲート後・列挙 (アダプタ読み) 前の全体拒否
|
|
664
|
+
if (rangesContainReadOnly(p.adapter, selection.ranges)) {
|
|
665
|
+
p.rejectReadOnly({ kind: "operation", op: "calc", cellCount: countRangeCells(selection.ranges) })
|
|
666
|
+
return false
|
|
667
|
+
}
|
|
548
668
|
const edits: CellEdit[] = []
|
|
549
669
|
const adapter = p.adapter
|
|
550
670
|
enumerateRangeCells(selection.ranges, (cell) => {
|
|
@@ -660,6 +780,68 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
660
780
|
[activeHyperlink],
|
|
661
781
|
)
|
|
662
782
|
|
|
783
|
+
/**
|
|
784
|
+
* G15 (v0.10 — design ADR-30): the SINGLE read-only pre-check for the popover-opening
|
|
785
|
+
* dispatches — the four popover-open command ids (`spec.kind === "popover"`) plus the
|
|
786
|
+
* `openHyperlink` host-undeclared fallback, whose execute arm opens the SAME link popover
|
|
787
|
+
* (Ctrl+K equivalent) and therefore walks the link arm of this gate. Returns false when the
|
|
788
|
+
* chord was consumed AND refused (the dispatcher aborts BEFORE its session `requestCommit` —
|
|
789
|
+
* doomed-open), true when dispatch may proceed: non-popover-opening commands (including
|
|
790
|
+
* `openHyperlink` with a declared host — a pure read), editable cells, and the
|
|
791
|
+
* content-PRESENT annotate chords (the normal open path then serves the viewer — S3 carries
|
|
792
|
+
* `cellReadOnly`, G14a is the wall; opening is a success, no callback). Content presence
|
|
793
|
+
* reads ONLY the command's own capability member (availability already guarantees the
|
|
794
|
+
* declaration); an empty note string / empty comment thread counts as ABSENT (design §8).
|
|
795
|
+
* `openValidationList` is always refused (the list surface has no view value).
|
|
796
|
+
* G15 (v0.10 — 設計 ADR-30): popover を開くディスパッチの**単一** pre-check — popover 系
|
|
797
|
+
* 4 コマンド id (`spec.kind === "popover"`) に加え、execute 腕が**同じ** link ポップオーバーを
|
|
798
|
+
* 開く `openHyperlink` の host 未宣言 fallback (Ctrl+K 同等) も link 腕を通る。false =
|
|
799
|
+
* チョード消費 + 拒否 (dispatcher はセッション requestCommit の**前**に中断 — doomed-open)、
|
|
800
|
+
* true = 続行 (popover を開かないコマンド / host 宣言時の `openHyperlink` (純粋な読み) /
|
|
801
|
+
* 編集可能セル / 内容**有**の注釈チョード — 通常 open 経路がビューアを開く。S3 が
|
|
802
|
+
* cellReadOnly を運び G14a が壁。開通は成功で callback なし)。内容有無は当該コマンドの能力
|
|
803
|
+
* メンバーだけを読む (availability が宣言を保証済み)。空文字ノート / 空スレッドは「無」扱い
|
|
804
|
+
* (設計 §8)。openValidationList は常に拒否 (list 面に閲覧価値は無い)。
|
|
805
|
+
*/
|
|
806
|
+
const gateReadOnlyChord = useCallback(
|
|
807
|
+
(spec: CommandSpec): boolean => {
|
|
808
|
+
const p = paramsRef.current
|
|
809
|
+
// 壁の対象 = execute 腕が開く popover 種。openHyperlink の host 未宣言 fallback は
|
|
810
|
+
// Ctrl+K 同等の link open (宣言時は読み = 対象外。リンク無しの行不在は availability が先に落とす)
|
|
811
|
+
const popover: PopoverKind | undefined = spec.kind === "popover" ? spec.popover : spec.kind === "openHyperlink" && p.onHyperlinkOpen === undefined ? "link" : undefined
|
|
812
|
+
if (popover === undefined) {
|
|
813
|
+
return true
|
|
814
|
+
}
|
|
815
|
+
const active = p.getActive()
|
|
816
|
+
if (!isReadOnlyCell(p.adapter, active.row, active.col)) {
|
|
817
|
+
return true
|
|
818
|
+
}
|
|
819
|
+
if (popover === "list") {
|
|
820
|
+
// list 面に閲覧価値は無い (対話 = 書込そのもの) — 常に消費拒否
|
|
821
|
+
p.rejectReadOnly({ kind: "edit", cell: active, editSession: "none" })
|
|
822
|
+
return false
|
|
823
|
+
}
|
|
824
|
+
// 注釈 3 種は内容の有無で分岐 (このパッケージに独立ビューアは無い — WCAG 2.1.1)
|
|
825
|
+
let present: boolean
|
|
826
|
+
if (popover === "link") {
|
|
827
|
+
present = activeHyperlink() !== undefined
|
|
828
|
+
} else if (popover === "note") {
|
|
829
|
+
const note = p.adapter.getCellNote === undefined ? undefined : p.adapter.getCellNote(active.row, active.col)
|
|
830
|
+
present = note !== undefined && note !== ""
|
|
831
|
+
} else {
|
|
832
|
+
const thread = p.adapter.getCellComments === undefined ? undefined : p.adapter.getCellComments(active.row, active.col)
|
|
833
|
+
present = thread !== undefined && thread.length > 0
|
|
834
|
+
}
|
|
835
|
+
if (!present) {
|
|
836
|
+
// 内容無 = 純粋な追加意図 (doomed empty editor) — 消費拒否、requestCommit 不実行
|
|
837
|
+
p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
|
|
838
|
+
return false
|
|
839
|
+
}
|
|
840
|
+
return true
|
|
841
|
+
},
|
|
842
|
+
[activeHyperlink],
|
|
843
|
+
)
|
|
844
|
+
|
|
663
845
|
const executeCommand = useCallback(
|
|
664
846
|
(command: SpreadsheetCommand): boolean => {
|
|
665
847
|
const p = paramsRef.current
|
|
@@ -667,6 +849,14 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
667
849
|
return false
|
|
668
850
|
}
|
|
669
851
|
const spec = COMMAND_SPECS[command]
|
|
852
|
+
// G15 (v0.10 §6.9 / 設計 ADR-30): popover を開くディスパッチ (popover 系 4 コマンド id +
|
|
853
|
+
// openHyperlink の host 未宣言 fallback) の**単一** pre-check — 座席は availability bail と
|
|
854
|
+
// dispatch 水準 requestCommit の**間** (doomed-open 原則: 拒否腕は無関係セッションを
|
|
855
|
+
// force-commit しない)。チョードは keymap が execute 前に消費済み。
|
|
856
|
+
// imperative 経路 (handle.executeCommand / commandDispatch.execute) も同関数で自動継承
|
|
857
|
+
if (!gateReadOnlyChord(spec)) {
|
|
858
|
+
return false
|
|
859
|
+
}
|
|
670
860
|
const editing = p.isEditing()
|
|
671
861
|
// 編集列に無いコマンドは先にセッションを確定する (拒否なら中断)
|
|
672
862
|
if (editing && !EDITING_COLUMN_COMMANDS.has(command) && !p.requestCommit()) {
|
|
@@ -733,39 +923,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
733
923
|
return runCalc({ text: reverseSignText, formula: reverseSignFormula })
|
|
734
924
|
case "fill": {
|
|
735
925
|
if (editing && spec.direction === "selection") {
|
|
736
|
-
|
|
737
|
-
const text = p.getSessionText()
|
|
738
|
-
if (text === null) {
|
|
739
|
-
return false
|
|
740
|
-
}
|
|
741
|
-
const selection = p.getSelection()
|
|
742
|
-
if (!gateSelectionWrite("fill", selection.ranges, p.maxOperationCells, p.onOperationRejected)) {
|
|
743
|
-
return false
|
|
744
|
-
}
|
|
745
|
-
// 検証ゲート (分冊 08 §4.2) は対象セル毎 — 規則はセル毎に違い得る。最初の strict 拒否で
|
|
746
|
-
// 走査を打ち切り applyEdits へ届けない (通知と message ポップオーバーは root の validateCommit が担う)
|
|
747
|
-
const edits: CellEdit[] = []
|
|
748
|
-
let rejected = false
|
|
749
|
-
enumerateRangeCells(selection.ranges, (cell) => {
|
|
750
|
-
if (rejected) {
|
|
751
|
-
return
|
|
752
|
-
}
|
|
753
|
-
if (!p.validateCommit(cell, text).ok) {
|
|
754
|
-
rejected = true
|
|
755
|
-
return
|
|
756
|
-
}
|
|
757
|
-
edits.push({ row: cell.row, col: cell.col, text })
|
|
758
|
-
})
|
|
759
|
-
// 拒否はセッション維持のまま false (Enter の keep 経路と同じ — Escape で取消 / 修正して再試行)
|
|
760
|
-
if (rejected) {
|
|
761
|
-
return false
|
|
762
|
-
}
|
|
763
|
-
if (p.commitEdits(edits, { source: "fill" }) === null) {
|
|
764
|
-
return false
|
|
765
|
-
}
|
|
766
|
-
// 値は全セルへ書かれた — セッションは破棄で閉じる (二重書込なし)
|
|
767
|
-
p.cancelEdit()
|
|
768
|
-
return true
|
|
926
|
+
return runFillSelectionEditing()
|
|
769
927
|
}
|
|
770
928
|
return runFill(spec.direction)
|
|
771
929
|
}
|
|
@@ -782,7 +940,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
782
940
|
p.onHyperlinkOpen(url, p.getActive())
|
|
783
941
|
return true
|
|
784
942
|
}
|
|
785
|
-
// 未宣言: link ポップオーバー (Open ボタンなし)
|
|
943
|
+
// 未宣言: link ポップオーバー (Open ボタンなし)。読み取り専用 × リンク無しの
|
|
944
|
+
// doomed empty editor は G15 の link 腕が拒否済み (ここに届くのは開通できる open のみ)
|
|
786
945
|
p.openPopover("link")
|
|
787
946
|
return true
|
|
788
947
|
}
|
|
@@ -806,6 +965,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
806
965
|
}
|
|
807
966
|
// idle: active セルだけへ 1 applyEdits (選択全体には書かない)、直接確定 (セッションは無い = discarded)
|
|
808
967
|
const active = p.getActive()
|
|
968
|
+
// G12 (v0.10 §6.9): 読み取り専用壁 — セッションレス "edit" 書込も validateCommit より前で拒否
|
|
969
|
+
if (isReadOnlyCell(p.adapter, active.row, active.col)) {
|
|
970
|
+
p.rejectReadOnly({ kind: "edit", cell: active, editSession: "none" })
|
|
971
|
+
return false
|
|
972
|
+
}
|
|
809
973
|
// Excel の Ctrl+; は編集モード経由で Enter 確定が検証される (strict ⇒ 拒否) — 分冊 08 §4.2 の
|
|
810
974
|
// fill / 貼り付け非検証免除はここへ及ばないため、strict 拒否は書込前に判定する
|
|
811
975
|
// (通知と message ポップオーバーは root の validateCommit が担う)
|
|
@@ -838,7 +1002,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
838
1002
|
}
|
|
839
1003
|
}
|
|
840
1004
|
},
|
|
841
|
-
[isCommandAvailable, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runHide],
|
|
1005
|
+
[isCommandAvailable, gateReadOnlyChord, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runFillSelectionEditing, runHide],
|
|
842
1006
|
)
|
|
843
1007
|
|
|
844
1008
|
/** Commit gate shared by the annotation setters. / 注釈 setter 共通の commit ゲート。 */
|
|
@@ -861,6 +1025,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
861
1025
|
return false
|
|
862
1026
|
}
|
|
863
1027
|
const active = p.getActive()
|
|
1028
|
+
// G13 (v0.10 §6.9): 読み取り専用壁 — commit-gate-first は登記済み品行 (§3 preamble)、壁は commit 前
|
|
1029
|
+
if (isReadOnlyCell(p.adapter, active.row, active.col)) {
|
|
1030
|
+
p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
|
|
1031
|
+
return false
|
|
1032
|
+
}
|
|
864
1033
|
return p.commitEdits([{ row: active.row, col: active.col, hyperlink: normalized }], { source: "annotate" }) !== null
|
|
865
1034
|
},
|
|
866
1035
|
[commitGate],
|
|
@@ -876,6 +1045,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
876
1045
|
return false
|
|
877
1046
|
}
|
|
878
1047
|
const active = p.getActive()
|
|
1048
|
+
// G13 (v0.10 §6.9): 読み取り専用壁 — commit 前の単一ターゲット walk
|
|
1049
|
+
if (isReadOnlyCell(p.adapter, active.row, active.col)) {
|
|
1050
|
+
p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
|
|
1051
|
+
return false
|
|
1052
|
+
}
|
|
879
1053
|
return p.commitEdits([{ row: active.row, col: active.col, note: text }], { source: "annotate" }) !== null
|
|
880
1054
|
},
|
|
881
1055
|
[commitGate],
|
|
@@ -892,6 +1066,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
892
1066
|
return false
|
|
893
1067
|
}
|
|
894
1068
|
const active = p.getActive()
|
|
1069
|
+
// G13 (v0.10 §6.9): 読み取り専用壁 — 既存スレッド読み (実体化) より前で拒否
|
|
1070
|
+
if (isReadOnlyCell(p.adapter, active.row, active.col)) {
|
|
1071
|
+
p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
|
|
1072
|
+
return false
|
|
1073
|
+
}
|
|
895
1074
|
// 既存スレッド (id つき) を verbatim に保ち input を末尾へ — 1 スレッドとして書く
|
|
896
1075
|
const existing = read(active.row, active.col)
|
|
897
1076
|
const thread: CellCommentInput[] = existing === undefined ? [input] : [...existing, input]
|
|
@@ -913,6 +1092,11 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
913
1092
|
if (!gateSelectionWrite("annotate", ranges, p.maxOperationCells, p.onOperationRejected)) {
|
|
914
1093
|
return false
|
|
915
1094
|
}
|
|
1095
|
+
// G13 (v0.10 §6.9): 読み取り専用壁 — setValidation だけ選択レンジ walk (限度ゲート後・列挙前)
|
|
1096
|
+
if (rangesContainReadOnly(p.adapter, ranges)) {
|
|
1097
|
+
p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: countRangeCells(ranges) })
|
|
1098
|
+
return false
|
|
1099
|
+
}
|
|
916
1100
|
const edits: CellEdit[] = []
|
|
917
1101
|
enumerateRangeCells(ranges, (cell) => {
|
|
918
1102
|
edits.push({ row: cell.row, col: cell.col, validation: rule })
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
* F2 — plus the extension anchor of the pointed token (the corner a Shift+Arrow chain is
|
|
19
19
|
* pinned to — Excel / Sheets keyboard pointing), and `data-aqsp-pointing` is a progressive
|
|
20
20
|
* visual hint written after input / selectionchange and after every mutator here — never a
|
|
21
|
-
* decision input.
|
|
21
|
+
* decision input. v0.10 read-only cells (plan §6.3): G1 refuses edit-mode entry for all six
|
|
22
|
+
* entry paths at the single `startEdit` funnel (between the sink-null bail and `discardClip` —
|
|
23
|
+
* ants kept, no scroll jump, before seeding) and G16 live-re-checks the session cell at
|
|
24
|
+
* `finishEdit` commit time (after the unchanged short-circuit, before `validateCommit`);
|
|
25
|
+
* both fire the required `rejectReadOnly` funnel, and an undeclared capability is byte-identical.
|
|
22
26
|
*
|
|
23
27
|
* セル編集セッション状態機械 (§6.7.1)。idle / editing(edit) / editing(replace) のセッション
|
|
24
28
|
* 状態・composition 追跡・グリッド外 focusout commit・suppressFocusoutCommit を所有する。
|
|
@@ -34,13 +38,17 @@
|
|
|
34
38
|
* Excel の Enter モード)、F2 でトグル — と指しトークンの伸長アンカー (Shift+Arrow 連鎖が固定
|
|
35
39
|
* する角 — Excel / Sheets のキーボードポインティング)、`data-aqsp-pointing` は input /
|
|
36
40
|
* selectionchange 後と当フックの各変更操作後に書く漸進的な視覚ヒントであり判断根拠にはしない。
|
|
41
|
+
* v0.10 読み取り専用セル (計画 §6.3): G1 は全 6 入口の単一漏斗 `startEdit` で編集モード進入を
|
|
42
|
+
* 拒否し (sink-null bail と `discardClip` の間 — アンツ保持・スクロール不跳び・シード前)、G16 は
|
|
43
|
+
* `finishEdit` の確定時にセッションセルを生再検査する (無変更短絡の後・`validateCommit` の前)。
|
|
44
|
+
* どちらも必須 `rejectReadOnly` 漏斗へ発火し、能力未宣言は従来挙動とバイト同一。
|
|
37
45
|
*/
|
|
38
46
|
|
|
39
47
|
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
40
48
|
import type { CellCoord, CellRange } from "../model/cellCoord.ts"
|
|
41
49
|
import type { CellAlign } from "../model/cellFormat.ts"
|
|
42
50
|
import { type EditPaint, IDLE_EDIT_PAINT } from "../model/cellPaint.ts"
|
|
43
|
-
import { type CellEdit, type EditRejection, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
51
|
+
import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
44
52
|
import type { ValidationVerdict } from "../model/dataValidation.ts"
|
|
45
53
|
import { cycleAnchorAt, findReferenceAt, formatRangeReference, isPointingPrefix, type PointingTarget, type ReferenceAxis } from "../model/formulaRefs.ts"
|
|
46
54
|
|
|
@@ -100,6 +108,17 @@ type UseCellEditorParams = {
|
|
|
100
108
|
discardClip: () => void
|
|
101
109
|
/** Drag/edit mutual exclusion probe (§6.7.1 entry guard). / ドラッグと編集セッションの併存禁止 (冒頭ガード)。 */
|
|
102
110
|
isDragging: () => boolean
|
|
111
|
+
/**
|
|
112
|
+
* v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
|
|
113
|
+
* read through paramsRef): G1 (a refused entry, `editSession: "none"`) and G16 (a refused
|
|
114
|
+
* commit of a mid-session flipped cell, `editSession: "kept" | "discarded"` per the caller's
|
|
115
|
+
* declared fate) fire it once per attempt. Capability absent ⇒ never called (byte identity).
|
|
116
|
+
* v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
|
|
117
|
+
* G1 (入口拒否 — `editSession: "none"`) と G16 (セッション途中で読み取り専用化されたセルの
|
|
118
|
+
* 確定拒否 — 呼び手宣言の帰結どおり `"kept" | "discarded"`) が試行毎 1 回発火する。能力
|
|
119
|
+
* 未宣言なら一度も呼ばれない (バイト恒等)。
|
|
120
|
+
*/
|
|
121
|
+
rejectReadOnly: (rejection: ReadOnlyRejection) => void
|
|
103
122
|
/**
|
|
104
123
|
* The cell's effective alignment for the editing textarea (Excel keeps the cell alignment
|
|
105
124
|
* while editing in place). null = the adapter has no getCellFormat capability — the sink's
|
|
@@ -285,8 +304,11 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
|
|
|
285
304
|
}, [])
|
|
286
305
|
|
|
287
306
|
/**
|
|
288
|
-
* The single terminal transition (idempotent — no-op when idle). commit=true
|
|
289
|
-
*
|
|
307
|
+
* The single terminal transition (idempotent — no-op when idle). commit=true walks the
|
|
308
|
+
* pinned order: unchanged short-circuit → the v0.10 read-only live re-check (G16 — a
|
|
309
|
+
* mid-session flip refuses before `validateCommit`, firing `rejectReadOnly` with the
|
|
310
|
+
* caller-declared fate) → the validation gate → applyEdits; on rejection `onReject`
|
|
311
|
+
* decides the session's fate
|
|
290
312
|
* and is DECLARED up front so the wrapper can relay it to onEditRejected: "keep" (in-grid
|
|
291
313
|
* commits — the session stays open with the typed value) or "discard" (grid-exit focusout
|
|
292
314
|
* — the session closes, the text-align / paint retire and the sink is re-armed immediately
|
|
@@ -295,7 +317,9 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
|
|
|
295
317
|
* applyEdits: the session just ends (Sheets-like — opening an editor and leaving without a
|
|
296
318
|
* change is not a write, so read-only hosts get a smooth focus-out instead of a rejection
|
|
297
319
|
* loop).
|
|
298
|
-
* 終端遷移の一本化 (冪等 — idle なら no-op)。commit=true
|
|
320
|
+
* 終端遷移の一本化 (冪等 — idle なら no-op)。commit=true は 無変更短絡 → v0.10 読み取り専用
|
|
321
|
+
* 生再検査 (G16 — セッション途中の読み取り専用化は `validateCommit` より前に拒否し、呼び手
|
|
322
|
+
* 宣言の帰結つきで `rejectReadOnly` へ発火) → 検証ゲート → applyEdits の順に
|
|
299
323
|
* 試み、拒否時のセッションの行方は `onReject` が**事前に宣言**する (ラッパーが onEditRejected
|
|
300
324
|
* へ中継できるように): "keep" (グリッド内確定 — 入力値ごと維持) / "discard" (グリッド外
|
|
301
325
|
* focusout — セッションを閉じ、text-align / 塗りを退役し、フォーカスに触れずシンクを即再武装)。
|
|
@@ -333,13 +357,28 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
|
|
|
333
357
|
// R4 是正)。replace モードで「打って全消し → 確定」は原文と異なるため従来どおり書込 (セルクリア)
|
|
334
358
|
const canonical = resolveEditText(p.adapter, session.cell.row, session.cell.col).text.replace(/\r\n?/g, "\n")
|
|
335
359
|
const unchanged = text === canonical
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
360
|
+
// 無変更は G16 にも検証ゲートにも書込にも進まない (短絡が先頭 — 開いて閉じただけは書込で
|
|
361
|
+
// ない: 読み取り専用化されたセルの focus-out も無音で閉じ、拒否ループに化けない)。
|
|
362
|
+
// 順序契約 unchanged → G16 → validateCommit → commitEdits (v0.10 §6.3 / RC-G16v)
|
|
363
|
+
let committed: boolean
|
|
364
|
+
if (unchanged) {
|
|
365
|
+
committed = true
|
|
366
|
+
} else if (isReadOnlyCell(p.adapter, session.cell.row, session.cell.col)) {
|
|
367
|
+
// G16 (v0.10 §6.3 / 設計 ADR-29): セッションと読み取り専用状態は両方ライブ —
|
|
368
|
+
// dataVersion 増分はセッションを取り消さないため、確定時にここで生再検査する。
|
|
369
|
+
// validateCommit より前 (退路の無い確定が strict 拒否メッセージポップオーバーを
|
|
370
|
+
// 開かない — RC-G16v)。帰結は呼び手宣言の keep / discard がそのまま payload の
|
|
371
|
+
// editSession になり、後段の既存 keep / discard 機構へ委譲する (strict 検証拒否と同形)
|
|
372
|
+
p.rejectReadOnly({ kind: "edit", cell: session.cell, editSession: options.onReject === "keep" ? "kept" : "discarded" })
|
|
373
|
+
committed = false
|
|
374
|
+
} else {
|
|
375
|
+
// 拒否文脈は呼び手が事前宣言する (onEditRejected はラッパーが発火 — ここで重ねて発火しない):
|
|
376
|
+
// keep = グリッド内確定 (セッション維持) / discard = グリッド外 focusout (破棄) の 2 値だけ
|
|
377
|
+
const rejection: EditRejection = { source: "edit", editSession: options.onReject === "keep" ? "kept" : "discarded" }
|
|
378
|
+
// 検証ゲート (能力分岐) の拒否判定はアダプタへ届けず applyEdits 拒否と同じ keep / discard
|
|
379
|
+
// 経路へ。commitEdits は成功でトランザクション id、拒否で null (= 未確定)
|
|
380
|
+
committed = (p.validateCommit === undefined || p.validateCommit(session.cell, text).ok) && p.commitEdits([{ row: session.cell.row, col: session.cell.col, text }], rejection) !== null
|
|
381
|
+
}
|
|
343
382
|
if (!committed) {
|
|
344
383
|
if (options.onReject === "discard") {
|
|
345
384
|
// グリッド外 focusout の拒否 (§6.7.1): フォーカス無き宙吊りセッションを残さず、
|
|
@@ -380,9 +419,19 @@ export const useCellEditor = (params: UseCellEditorParams): CellEditor => {
|
|
|
380
419
|
if (sink === null) {
|
|
381
420
|
return
|
|
382
421
|
}
|
|
422
|
+
// G1 (v0.10 §6.3 / 設計 ADR-29): 全 6 入口の単一漏斗で読み取り専用を拒否する。座席は
|
|
423
|
+
// sink-null bail の直後・discardClip より前 — アンツ保持 (Excel 同様、拒否試行でコピー /
|
|
424
|
+
// カットのアンツを消さない)・スクロール不跳び・シード前の非破壊配置。既存 1 回の
|
|
425
|
+
// getActive 読みをゲート前へ巻上げ (§0.2 R3 — 追加読みゼロ、selection 読みは純粋で
|
|
426
|
+
// discardClip と独立)。E2/E3/E4 の打鍵 / 変換は武装シンクの自己修復が捨てる (isDragging
|
|
427
|
+
// 劣化と同じ検証済みの形)。発火は試行毎 1 回 (autorepeat = キーストローク毎)
|
|
428
|
+
const active = p.getActive()
|
|
429
|
+
if (isReadOnlyCell(p.adapter, active.row, active.col)) {
|
|
430
|
+
p.rejectReadOnly({ kind: "edit", cell: active, editSession: "none" })
|
|
431
|
+
return
|
|
432
|
+
}
|
|
383
433
|
// 編集開始でクリップを破棄 (§6.5.4-4 — copy アンツも Excel 同様ここで消灯)
|
|
384
434
|
p.discardClip()
|
|
385
|
-
const active = p.getActive()
|
|
386
435
|
// active はスクロールで画面外に出得る — 画面外セルの上にエディタ / IME 候補窓を開かない (§6.7.1)
|
|
387
436
|
p.ensureCellVisible(active)
|
|
388
437
|
if (mode === "edit") {
|