@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
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* cell binds —) and the
|
|
22
22
|
* IME guard the layer applies to every popover keydown (`isCompositionKeydown`). No DOM is
|
|
23
23
|
* rendered here — PopoverLayer.tsx renders from `context` / `tipContext` (lazy mount).
|
|
24
|
+
* v0.10 read-only (design ADR-30, refusals via the required `rejectReadOnly` funnel): S3 = ONE
|
|
25
|
+
* interactive-context builder site for the declaration-gated `cellReadOnly` key (note / comment /
|
|
26
|
+
* link builds and the G14a re-present all share it); G14a re-checks live at `commitOrReject`'s
|
|
27
|
+
* head (refusal re-presents with `cellReadOnly` recomputed, `rejected` kept false, draft
|
|
28
|
+
* preserved); G14b is the list commit's defense-in-depth; G14c refuses a list open PRE-open —
|
|
29
|
+
* after the RangeError wiring check, BEFORE the commit gate (doomed-open: no force-commit).
|
|
30
|
+
* Capability undeclared = contexts and conduct byte-identical to v0.9.
|
|
24
31
|
*
|
|
25
32
|
* ポップオーバー状態機械 (v0.8 §1.8 / §4.1.15 — ADR-15 対話層)。6 種 (note エディタ / ホバー
|
|
26
33
|
* note tip / コメントスレッド / ハイパーリンクエディタ / 検証リスト / 検証メッセージ) を overlay が
|
|
@@ -39,7 +46,12 @@
|
|
|
39
46
|
* 変化 (root の active 効果が呼ぶ) のたびに再評価し、離れれば外し、ホバー中セルへ乗れば結ぶ —)、
|
|
40
47
|
* 層が全 popover keydown に掛ける
|
|
41
48
|
* IME ガード (`isCompositionKeydown`)。DOM は描かない — PopoverLayer.tsx が `context` /
|
|
42
|
-
* `tipContext` から遅延マウントする。
|
|
49
|
+
* `tipContext` から遅延マウントする。v0.10 読み取り専用 (設計 ADR-30、拒否は必須 `rejectReadOnly`
|
|
50
|
+
* 漏斗): S3 = 宣言ゲートキー `cellReadOnly` の単一対話 context ビルダ (note / comment / link の
|
|
51
|
+
* 構築と G14a 再提示が共有)、G14a = commitOrReject 頭の live 再検査 (拒否は cellReadOnly 再計算 +
|
|
52
|
+
* rejected false のまま + 下書き保持で再提示)、G14b = list commit の縦深防御、G14c = list の
|
|
53
|
+
* pre-open 拒否 (RangeError 結線検査の後・commit ゲートの前 — doomed-open で force-commit なし)。
|
|
54
|
+
* 能力未宣言は context も品行も v0.9 とバイト恒等。
|
|
43
55
|
*/
|
|
44
56
|
|
|
45
57
|
import type { VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
@@ -48,7 +60,7 @@ import { isCompositionKeydown } from "../keyboard/compositionGuard.ts"
|
|
|
48
60
|
import type { AxisModel } from "../model/axisModel.ts"
|
|
49
61
|
import { type CellComment, type CellCommentInput, normalizeAdapterHyperlink, normalizeHyperlink } from "../model/cellAnnotations.ts"
|
|
50
62
|
import { type CellCoord, clampCoord, coordEquals } from "../model/cellCoord.ts"
|
|
51
|
-
import type
|
|
63
|
+
import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, readOnlyDeclared, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
52
64
|
import { colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
|
|
53
65
|
import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
|
|
54
66
|
import { NOTE_TIP_LEAVE_GRACE_MS, type PopoverRect, shouldShowNoteTip } from "./placement.ts"
|
|
@@ -62,17 +74,28 @@ export const POPOVER_KINDS: readonly PopoverKind[] = ["note", "note-tip", "comme
|
|
|
62
74
|
/** The `popovers` prop: `false` disables a kind (its commands pass through, `openPopover` throws). / `popovers` prop: `false` はその kind を無効化 (コマンドは素通し、`openPopover` は RangeError)。 */
|
|
63
75
|
export type PopoverOverrides = { readonly [K in PopoverKind]?: boolean }
|
|
64
76
|
|
|
65
|
-
/** Note editor context (v0.8 §1.8). `current` undefined = no note yet; `commit(null)` clears. / note エディタ文脈。current undefined = ノートなし、commit(null) は消去。 */
|
|
66
|
-
export type NotePopoverContext = { kind: "note"; cell: CellCoord; anchorRect: PopoverRect; current: string | undefined; commit: (next: string | null) => void; close: () => void; rejected: boolean }
|
|
77
|
+
/** Note editor context (v0.8 §1.8). `current` undefined = no note yet; `commit(null)` clears. `cellReadOnly` (v0.10, declaration-gated): key ABSENT = the `isCellReadOnly` capability is undeclared (byte-identical context), else the live predicate answer — `true` disables the default component's commit affordance (G14a walls the commit regardless). / note エディタ文脈。current undefined = ノートなし、commit(null) は消去。cellReadOnly (v0.10、宣言ゲート): キー不在 = isCellReadOnly 能力未宣言 (context はバイト恒等)、あれば live 述語の答え — true は既定コンポーネントの確定操作を disabled にする (壁は G14a)。 */
|
|
78
|
+
export type NotePopoverContext = { kind: "note"; cell: CellCoord; anchorRect: PopoverRect; current: string | undefined; commit: (next: string | null) => void; close: () => void; rejected: boolean; cellReadOnly?: boolean }
|
|
67
79
|
|
|
68
80
|
/** Hover note tip context (read-only). / ホバー note tip 文脈 (読み取り専用)。 */
|
|
69
81
|
export type NoteTipContext = { kind: "note-tip"; cell: CellCoord; anchorRect: PopoverRect; current: string; close: () => void }
|
|
70
82
|
|
|
71
|
-
/** Comment thread context: the reply UI commits `[...current (with ids), { text }]`; `null` clears the thread. / コメントスレッド文脈: 返信 UI は `[...current (id つき), { text }]` を commit、null はスレッド消去。 */
|
|
72
|
-
export type CommentPopoverContext = { kind: "comment"; cell: CellCoord; anchorRect: PopoverRect; current: readonly CellComment[]; commit: (next: readonly CellCommentInput[] | null) => void; close: () => void; rejected: boolean }
|
|
83
|
+
/** Comment thread context: the reply UI commits `[...current (with ids), { text }]`; `null` clears the thread. `cellReadOnly` (v0.10): same declaration-gated key as the note context. / コメントスレッド文脈: 返信 UI は `[...current (id つき), { text }]` を commit、null はスレッド消去。cellReadOnly (v0.10) は note 文脈と同じ宣言ゲートキー。 */
|
|
84
|
+
export type CommentPopoverContext = { kind: "comment"; cell: CellCoord; anchorRect: PopoverRect; current: readonly CellComment[]; commit: (next: readonly CellCommentInput[] | null) => void; close: () => void; rejected: boolean; cellReadOnly?: boolean }
|
|
73
85
|
|
|
74
|
-
/** Hyperlink editor context: `url` on commit is a normalised href or null (clear); `canOpen` = onHyperlinkOpen declared. / ハイパーリンクエディタ文脈: commit の url は正規化済み href か null (消去)、canOpen = onHyperlinkOpen 宣言。 */
|
|
75
|
-
export type LinkPopoverContext = {
|
|
86
|
+
/** Hyperlink editor context: `url` on commit is a normalised href or null (clear); `canOpen` = onHyperlinkOpen declared. `cellReadOnly` (v0.10): same declaration-gated key as the note context (the Open button stays live — navigation is a read). / ハイパーリンクエディタ文脈: commit の url は正規化済み href か null (消去)、canOpen = onHyperlinkOpen 宣言。cellReadOnly (v0.10) は note 文脈と同じ宣言ゲートキー (Open は読みなので生きたまま)。 */
|
|
87
|
+
export type LinkPopoverContext = {
|
|
88
|
+
kind: "link"
|
|
89
|
+
cell: CellCoord
|
|
90
|
+
anchorRect: PopoverRect
|
|
91
|
+
current: { text: string; url: string | undefined }
|
|
92
|
+
commit: (next: { text: string; url: string | null }) => void
|
|
93
|
+
close: () => void
|
|
94
|
+
canOpen: boolean
|
|
95
|
+
open: () => void
|
|
96
|
+
rejected: boolean
|
|
97
|
+
cellReadOnly?: boolean
|
|
98
|
+
}
|
|
76
99
|
|
|
77
100
|
/** Validation list picker context: `current` = the list rule's values, `typed` = the prefix that opened it. / 検証リスト文脈: current = list 規則の値、typed = 開いたときの入力プレフィックス。 */
|
|
78
101
|
export type ListPopoverContext = { kind: "list"; cell: CellCoord; anchorRect: PopoverRect; current: readonly string[]; typed: string; commit: (value: string) => void; close: () => void }
|
|
@@ -144,6 +167,15 @@ export type UsePopoversParams = {
|
|
|
144
167
|
/** Composite drag probe incl. the reference drag — the tip is never shown mid-drag. / 参照ドラッグ含む合成ドラッグ判定 — ドラッグ中は tip を出さない。 */
|
|
145
168
|
isDraggingAny: () => boolean
|
|
146
169
|
commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
|
|
170
|
+
/**
|
|
171
|
+
* v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
|
|
172
|
+
* read through paramsRef): G14a (interactive commit), G14b (list commit) and G14c (the list
|
|
173
|
+
* pre-open refusal) fire it once per refused gesture. Capability absent ⇒ never called.
|
|
174
|
+
* v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
|
|
175
|
+
* G14a (対話 commit)・G14b (list commit)・G14c (list の pre-open 拒否) が拒否ジェスチャ毎
|
|
176
|
+
* 1 回発火する。能力未宣言なら一度も呼ばれない。
|
|
177
|
+
*/
|
|
178
|
+
rejectReadOnly: (rejection: ReadOnlyRejection) => void
|
|
147
179
|
labels: SpreadsheetLabels
|
|
148
180
|
onHyperlinkOpen?: (url: string, cell: CellCoord) => void
|
|
149
181
|
/** Root-owned anchor state the overlay positions. / root 所有のアンカー状態 (overlay が配置)。 */
|
|
@@ -207,16 +239,28 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
207
239
|
const compositionEndedAtRef = useRef<number | null>(null)
|
|
208
240
|
const tipId = useId()
|
|
209
241
|
|
|
210
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* The active cell's rect in layer px (the overlay's three-way forward map — the same recipe
|
|
244
|
+
* as the keyboard cellRect; v0.9 plan §4.6, census #7): the AxisForwardCtx pair is built
|
|
245
|
+
* INLINE from one handle snapshot per judgment, `extent` from a judgment-time
|
|
246
|
+
* `getViewportSize()` read (plan §2.1 rule 1) — a trailing cell's anchor is extent-anchored
|
|
247
|
+
* and scroll-static, exactly like the engine's trailing band.
|
|
248
|
+
* active セルの層 px 矩形 (overlay の 3 分岐順写像 — keyboard cellRect と同じ式。v0.9 計画
|
|
249
|
+
* §4.6、census #7)。AxisForwardCtx の対は判断毎に 1 ハンドルスナップショットからインライン
|
|
250
|
+
* 構築し、`extent` は判断時 `getViewportSize()` 読み (計画 §2.1 規則 1) — 末尾セルのアンカーは
|
|
251
|
+
* extent アンカーでスクロール静的 (エンジンの末尾帯と同じ)。
|
|
252
|
+
*/
|
|
211
253
|
const anchorRectOf = useCallback((cell: CellCoord): PopoverRect => {
|
|
212
254
|
const p = paramsRef.current
|
|
213
255
|
const handle = p.handleRef.current
|
|
214
|
-
// detached ハンドルは原点扱い (番兵クランプは keyboard と同じ)
|
|
256
|
+
// detached ハンドルは原点扱い (番兵クランプは keyboard と同じ)。件数 0 / extent 0 の縮退で
|
|
257
|
+
// 末尾分岐は不活性 — 未装着ホストの矩形は v0.8 と byte 一致
|
|
215
258
|
const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
|
|
216
|
-
const frozen = handle === null ? { rows: 0, cols: 0,
|
|
259
|
+
const frozen = handle === null ? { rows: 0, cols: 0, trailingRows: 0, trailingCols: 0 } : handle.getFrozenSize()
|
|
260
|
+
const viewport = handle === null ? { width: 0, height: 0 } : handle.getViewportSize()
|
|
217
261
|
return {
|
|
218
|
-
top: rowToViewportY(p.rowAxis, cell.row, Math.max(0, position.y), frozen.rows),
|
|
219
|
-
left: colToViewportX(p.colAxis, cell.col, Math.max(0, position.x), frozen.cols),
|
|
262
|
+
top: rowToViewportY(p.rowAxis, cell.row, { scroll: Math.max(0, position.y), frozenCount: frozen.rows, trailingCount: frozen.trailingRows, extent: viewport.height }),
|
|
263
|
+
left: colToViewportX(p.colAxis, cell.col, { scroll: Math.max(0, position.x), frozenCount: frozen.cols, trailingCount: frozen.trailingCols, extent: viewport.width }),
|
|
220
264
|
width: p.colAxis.sizeOf(cell.col),
|
|
221
265
|
height: p.rowAxis.sizeOf(cell.row),
|
|
222
266
|
}
|
|
@@ -348,7 +392,18 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
348
392
|
[showTip, cancelGrace, startGrace],
|
|
349
393
|
)
|
|
350
394
|
|
|
351
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* Hit-tests a client position to a cell — the fill machine's recipe, three-way since v0.9
|
|
397
|
+
* (plan §4.6, census #8): the AxisInverseCtx pair is built INLINE per judgment,
|
|
398
|
+
* `trailingSize` from the TREE fields and `extent` from a NEW judgment-time
|
|
399
|
+
* `getViewportSize()` read (plan §2.1 rules 1/2 — the pre-existing dimension-probe read in
|
|
400
|
+
* `getLayerSize` is unrelated; extent 0 keeps the trailing branch inert, byte-matching v0.8).
|
|
401
|
+
* client 座標をセルへヒットテスト — fill 機械と同じ式、v0.9 から 3 分岐 (計画 §4.6、census
|
|
402
|
+
* #8)。AxisInverseCtx の対は判断毎にインライン構築し、`trailingSize` は TREE フィールド、
|
|
403
|
+
* `extent` は**新規**の判断時 `getViewportSize()` 読み (計画 §2.1 規則 1/2 — 既存の
|
|
404
|
+
* `getLayerSize` の寸法プローブ読みは無関係。extent 0 は末尾分岐を不活性に保ち v0.8 と byte
|
|
405
|
+
* 一致)。
|
|
406
|
+
*/
|
|
352
407
|
const hitTest = useCallback((clientX: number, clientY: number): CellCoord | null => {
|
|
353
408
|
const p = paramsRef.current
|
|
354
409
|
const body = p.bodyRef.current
|
|
@@ -360,7 +415,16 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
360
415
|
const z = p.zoomRef.current
|
|
361
416
|
const position = handle.getScrollPosition()
|
|
362
417
|
const frozen = handle.getFrozenSize()
|
|
363
|
-
|
|
418
|
+
// 判断時の viewport 読み (§2.1 規則 1 — census #8 の新規読み。キャッシュ / ストア由来の extent は不可)
|
|
419
|
+
const viewport = handle.getViewportSize()
|
|
420
|
+
return clampCoord(
|
|
421
|
+
{
|
|
422
|
+
row: viewportYToRow(p.rowAxis, (clientY - rect.top) / z, { scroll: Math.max(0, position.y), frozenSize: frozen.height, trailingSize: frozen.trailingHeight, extent: viewport.height }),
|
|
423
|
+
col: viewportXToCol(p.colAxis, (clientX - rect.left) / z, { scroll: Math.max(0, position.x), frozenSize: frozen.width, trailingSize: frozen.trailingWidth, extent: viewport.width }),
|
|
424
|
+
},
|
|
425
|
+
p.rowAxis.count,
|
|
426
|
+
p.colAxis.count,
|
|
427
|
+
)
|
|
364
428
|
}, [])
|
|
365
429
|
|
|
366
430
|
/**
|
|
@@ -419,10 +483,34 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
419
483
|
[writeAnchors],
|
|
420
484
|
)
|
|
421
485
|
|
|
422
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* S3 (v0.10): the SINGLE interactive-context builder site for the declaration-gated
|
|
488
|
+
* `cellReadOnly` key — the three build paths (note / comment / link) and the G14a re-present
|
|
489
|
+
* all spread this result, so the census stays at one `readOnlyDeclared` + one `isReadOnlyCell`
|
|
490
|
+
* call. Key absent = capability undeclared (context byte-identical), else the live answer.
|
|
491
|
+
* S3 (v0.10): 宣言ゲートキー `cellReadOnly` の**単一**対話 context ビルダサイト — 3 つの構築
|
|
492
|
+
* 経路 (note / comment / link) と G14a の再提示が全てこの戻り値を spread するため、census は
|
|
493
|
+
* readOnlyDeclared 1 + isReadOnlyCell 1 のまま。キー不在 = 能力未宣言 (context バイト恒等)、
|
|
494
|
+
* あれば live の答え。
|
|
495
|
+
*/
|
|
496
|
+
const readOnlyKeyOf = useCallback((cell: CellCoord): { cellReadOnly?: boolean } => {
|
|
497
|
+
const p = paramsRef.current
|
|
498
|
+
// 宣言時だけキーを出す (undefined キーも出さない — v0.9 形ホストの context はバイト恒等)
|
|
499
|
+
return readOnlyDeclared(p.adapter) ? { cellReadOnly: isReadOnlyCell(p.adapter, cell.row, cell.col) } : {}
|
|
500
|
+
}, [])
|
|
501
|
+
|
|
502
|
+
/** Runs an annotate commit; a G14a read-only refusal re-presents with `cellReadOnly` recomputed (`rejected` stays false), an adapter rejection re-presents with `rejected: true`. / annotate の commit を実行。G14a の読み取り専用拒否は cellReadOnly を再計算して再提示 (rejected は false のまま)、アダプタ拒否は rejected: true で再提示。 */
|
|
423
503
|
const commitOrReject = useCallback(
|
|
424
504
|
(ctx: NotePopoverContext | CommentPopoverContext | LinkPopoverContext, edits: readonly CellEdit[]) => {
|
|
425
505
|
const p = paramsRef.current
|
|
506
|
+
// G14a (v0.10 §6.10): commit 頭の live 再検査 — adapter 不到達。再提示は cellReadOnly 再計算
|
|
507
|
+
// (今は true — 既定コンポーネントが確定操作を disabled に)、rejected は false のまま
|
|
508
|
+
// (true は「adapter が投げた、再試行の余地あり」の意味 — ここでは嘘になる)、入力値は保持
|
|
509
|
+
if (isReadOnlyCell(p.adapter, ctx.cell.row, ctx.cell.col)) {
|
|
510
|
+
p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
|
|
511
|
+
present({ ...ctx, ...readOnlyKeyOf(ctx.cell) })
|
|
512
|
+
return
|
|
513
|
+
}
|
|
426
514
|
if (p.commitEdits(edits, { source: "annotate" }) === null) {
|
|
427
515
|
// 拒否: 開いたまま rejected 状態へ (既定コンポーネントが Ctrl+Enter 再試行 / Escape 破棄を担う)
|
|
428
516
|
present({ ...ctx, rejected: true })
|
|
@@ -430,7 +518,7 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
430
518
|
}
|
|
431
519
|
close()
|
|
432
520
|
},
|
|
433
|
-
[present, close],
|
|
521
|
+
[present, close, readOnlyKeyOf],
|
|
434
522
|
)
|
|
435
523
|
|
|
436
524
|
const open = useCallback(
|
|
@@ -440,6 +528,14 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
440
528
|
throw new RangeError(`[spreadsheet] openPopover("${kind}") is unavailable: the kind is disabled by the popovers prop or its adapter capability is undeclared`)
|
|
441
529
|
}
|
|
442
530
|
const cell = p.getActive()
|
|
531
|
+
// G14c (v0.10 §6.10 — doomed-open 原則): list の pre-open 拒否。座席は RangeError 結線検査の
|
|
532
|
+
// 後 (結線バグはデータ状態に優先 — fail-fast 序列保存)・open 経路 commit ゲートの**前**
|
|
533
|
+
// (拒否される open が無関係セッションを force-commit しない)。picker の対話 = 書込そのもの
|
|
534
|
+
// なので popover は開かない。RangeError にはしない (読み取り専用はデータ状態で結線バグではない)
|
|
535
|
+
if (kind === "list" && isReadOnlyCell(p.adapter, cell.row, cell.col)) {
|
|
536
|
+
p.rejectReadOnly({ kind: "edit", cell, editSession: "none" })
|
|
537
|
+
return
|
|
538
|
+
}
|
|
443
539
|
if (kind === "note-tip") {
|
|
444
540
|
// 手動表示: active セルにノートがあるときだけ (ホバー述語とは独立)
|
|
445
541
|
showTip(cell)
|
|
@@ -483,6 +579,14 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
483
579
|
current: rule.values,
|
|
484
580
|
typed,
|
|
485
581
|
commit: (value: string) => {
|
|
582
|
+
// G14b (v0.10 §6.10): list commit の縦深防御 — 開いている間の読み取り専用化を
|
|
583
|
+
// commitEdits 前に live 再検査 (G14c が正門、ここは第二壁)。拒否は無書込で閉じる
|
|
584
|
+
// (picker に他の対話は無い — Escape 相当の閉鎖)
|
|
585
|
+
if (isReadOnlyCell(paramsRef.current.adapter, cell.row, cell.col)) {
|
|
586
|
+
paramsRef.current.rejectReadOnly({ kind: "edit", cell, editSession: "none" })
|
|
587
|
+
close()
|
|
588
|
+
return
|
|
589
|
+
}
|
|
486
590
|
// リスト確定はセル編集 (source "edit"、セッションなし = discarded)
|
|
487
591
|
paramsRef.current.commitEdits([{ row: cell.row, col: cell.col, text: value }], { source: "edit", editSession: "discarded" })
|
|
488
592
|
close()
|
|
@@ -505,6 +609,8 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
505
609
|
},
|
|
506
610
|
close,
|
|
507
611
|
rejected: false,
|
|
612
|
+
// S3: 宣言ゲートキー (単一ビルダ経由 — 未宣言はキー不在で context バイト恒等)
|
|
613
|
+
...readOnlyKeyOf(cell),
|
|
508
614
|
}
|
|
509
615
|
present(ctx)
|
|
510
616
|
return
|
|
@@ -522,6 +628,8 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
522
628
|
},
|
|
523
629
|
close,
|
|
524
630
|
rejected: false,
|
|
631
|
+
// S3: 宣言ゲートキー (単一ビルダ経由)
|
|
632
|
+
...readOnlyKeyOf(cell),
|
|
525
633
|
}
|
|
526
634
|
present(ctx)
|
|
527
635
|
return
|
|
@@ -553,10 +661,12 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
|
553
661
|
}
|
|
554
662
|
},
|
|
555
663
|
rejected: false,
|
|
664
|
+
// S3: 宣言ゲートキー (単一ビルダ経由)
|
|
665
|
+
...readOnlyKeyOf(cell),
|
|
556
666
|
}
|
|
557
667
|
present(ctx)
|
|
558
668
|
},
|
|
559
|
-
[anchorRectOf, close, commitOrReject, hideTipNow, present, showTip],
|
|
669
|
+
[anchorRectOf, close, commitOrReject, hideTipNow, present, showTip, readOnlyKeyOf],
|
|
560
670
|
)
|
|
561
671
|
|
|
562
672
|
const { enabled, bodyRef } = params
|
|
@@ -2,11 +2,22 @@
|
|
|
2
2
|
* @fileoverview Edge-zone rAF auto-scroller for drag selection (§6.4.4). Driven by the
|
|
3
3
|
* selection engine via start/stop; hit tests use the 2-axis scrollBy synchronous return
|
|
4
4
|
* (freshness hierarchy §7-4: scrollBy return > getScrollPosition() — the store carries no
|
|
5
|
-
* positions since v0.4).
|
|
5
|
+
* positions since v0.4). v0.9 (plan §4.4, census #3/#4): the far zones (right / bottom) are
|
|
6
|
+
* band-based — `p > pane − trailingVisible* − EDGE_ZONE` — with the trailing home-territory
|
|
7
|
+
* suppressor (anchor inside the trailing band AND T_px > 0 AND pointer inside the band ⇒ no
|
|
8
|
+
* drive, the exact far twin of the v0.5 R5 leading rule), and the hover resolution builds
|
|
9
|
+
* its AxisInverseCtx inline per tick, reusing the tick-top `getViewportSize()` snapshot as
|
|
10
|
+
* `extent` (same-tick freshness — plan §0.4 R1). T = 0 leaves every zone and ctx expression
|
|
11
|
+
* byte-identical to the v0.8 arithmetic.
|
|
6
12
|
*
|
|
7
13
|
* ドラッグ選択の端ゾーン rAF オートスクロール (§6.4.4)。エンジンが start/stop で駆動し、
|
|
8
14
|
* ヒットテストは 2 軸 scrollBy の同期返り値を使う (鮮度序列 §7-4: scrollBy 返り値 >
|
|
9
|
-
* getScrollPosition — v0.4 からストアは位置を運ばない)。
|
|
15
|
+
* getScrollPosition — v0.4 からストアは位置を運ばない)。v0.9 (計画 §4.4、census #3/#4): 遠
|
|
16
|
+
* ゾーン (右 / 下) は帯基準 `p > pane − trailingVisible* − EDGE_ZONE`、末尾ホームテリトリー
|
|
17
|
+
* (アンカー末尾帯 ∧ T_px > 0 ∧ ポインタ帯内 ⇒ 無駆動 — v0.5 R5 の leading 規則の遠端双子) を
|
|
18
|
+
* 併設。hover 解決は AxisInverseCtx を tick 内その場構築し、extent は tick 冒頭の
|
|
19
|
+
* `getViewportSize()` スナップショットを再利用 (同 tick 鮮度 — 計画 §0.4 R1)。T = 0 は全
|
|
20
|
+
* ゾーン / ctx 式が v0.8 算術と byte 一致。
|
|
10
21
|
*/
|
|
11
22
|
|
|
12
23
|
import type { VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
@@ -49,17 +60,22 @@ export type AutoScrollContext = {
|
|
|
49
60
|
* Drag-anchor column for the frozen home-territory rule (v0.5 R5 fix): when the anchor is
|
|
50
61
|
* a frozen column AND the pointer sits inside the frozen band, the band-based left zone
|
|
51
62
|
* does NOT drive — operating within the frozen pane must not scroll the band (Excel-like).
|
|
63
|
+
* v0.9 (plan §4.4): the same anchor also feeds the TRAILING home territory — an anchor in
|
|
64
|
+
* the trailing band with the pointer inside that band suppresses the band-based right zone.
|
|
52
65
|
* Omit for vertical-only drags.
|
|
53
66
|
* 凍結ホームテリトリー規則 (v0.5 R5 是正) 用のドラッグアンカー列。アンカーが凍結列で
|
|
54
67
|
* ポインタも凍結帯内なら帯基準左ゾーンは駆動しない — 凍結ペイン内の操作は可動帯を
|
|
55
|
-
* スクロールさせない (Excel 同型)
|
|
68
|
+
* スクロールさせない (Excel 同型)。v0.9 (計画 §4.4): 同じアンカーが末尾ホームテリトリーにも
|
|
69
|
+
* 供給され、末尾帯アンカー × ポインタ帯内は帯基準右ゾーンを抑止。縦専用ドラッグでは省略。
|
|
56
70
|
*/
|
|
57
71
|
anchorCol?: number
|
|
58
72
|
/**
|
|
59
73
|
* Drag-anchor row for the vertical frozen home-territory rule (row twin of anchorCol — v0.6):
|
|
60
|
-
* anchor frozen AND pointer inside the frozen band suppresses the band-based top zone
|
|
74
|
+
* anchor frozen AND pointer inside the frozen band suppresses the band-based top zone; the
|
|
75
|
+
* trailing twin (v0.9 plan §4.4) suppresses the band-based bottom zone.
|
|
61
76
|
* 縦の凍結ホームテリトリー規則用のドラッグアンカー行 (anchorCol の行双子 — v0.6)。アンカーが
|
|
62
|
-
*
|
|
77
|
+
* 凍結行でポインタも凍結帯内なら帯基準上ゾーンは駆動しない。末尾双子 (v0.9 計画 §4.4) は
|
|
78
|
+
* 帯基準下ゾーンを抑止。
|
|
63
79
|
*/
|
|
64
80
|
anchorRow?: number
|
|
65
81
|
}
|
|
@@ -143,10 +159,20 @@ export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFr
|
|
|
143
159
|
let dx = 0
|
|
144
160
|
if (axis !== "x") {
|
|
145
161
|
// 上端ゾーンは帯基準 (v0.6 計画 §3 — 横の左端ゾーンと対称): スクロール帯の上端 = H_F。
|
|
146
|
-
// 下端優先 + 凍結ホームテリトリー (アンカー凍結行 × ポインタ帯内は駆動しない、H_F = 0 除外)
|
|
162
|
+
// 下端優先 + 凍結ホームテリトリー (アンカー凍結行 × ポインタ帯内は駆動しない、H_F = 0 除外)。
|
|
163
|
+
// v0.9 (計画 §4.4): 下端ゾーンも帯基準 — スクロール帯の下端 = paneH − H_T_vis (vis =
|
|
164
|
+
// getFrozenSize().trailingVisibleHeight、tick スナップショット)。末尾ホームテリトリーは
|
|
165
|
+
// leading 双子と同形: アンカー末尾帯 (row >= count − trailingRows) ∧ T_px > 0 ∧ ポインタ
|
|
166
|
+
// 帯内 (py >= paneH − vis — 境界画素の >= は逆写像の末尾分類とミラー) ⇒ 駆動しない。
|
|
167
|
+
// T_px = 0 (末尾行が全て非表示) は可視帯が存在しない — ペイン外の深い py を帯内と誤認
|
|
168
|
+
// して抑止しない (leading の H_F = 0 除外の双子)
|
|
147
169
|
const frozenV = handle.getFrozenSize()
|
|
148
|
-
|
|
149
|
-
|
|
170
|
+
const visH = frozenV.trailingVisibleHeight
|
|
171
|
+
if (py > paneH - visH - EDGE_ZONE) {
|
|
172
|
+
const anchorTrailingRow = context.anchorRow !== undefined && frozenV.trailingRows > 0 && context.anchorRow >= rowAxis.count - frozenV.trailingRows
|
|
173
|
+
if (!(anchorTrailingRow && visH > 0 && py >= paneH - visH)) {
|
|
174
|
+
dy = speedFor(py - (paneH - visH - EDGE_ZONE))
|
|
175
|
+
}
|
|
150
176
|
} else if (py < frozenV.height + EDGE_ZONE) {
|
|
151
177
|
const anchorFrozenRow = context.anchorRow !== undefined && context.anchorRow < frozenV.rows
|
|
152
178
|
if (!(anchorFrozenRow && frozenV.height > 0 && py < frozenV.height)) {
|
|
@@ -158,12 +184,19 @@ export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFr
|
|
|
158
184
|
// 左端ゾーンは帯基準 (v0.5 計画 §3-6): スクロール帯の左端 = W_F。スクロール帯アンカーの
|
|
159
185
|
// ドラッグは凍結帯上の保持でも左スクロールが継続する (Excel 同型 — 帯を跨いだ引き寄せ)。
|
|
160
186
|
// 判定は右端優先 (R2 是正): 帯幅 < 2×EDGE_ZONE で両ゾーンが重なると左優先は右端保持の
|
|
161
|
-
// ドラッグを逆走させる —
|
|
162
|
-
//
|
|
163
|
-
//
|
|
187
|
+
// ドラッグを逆走させる — 重なりは右端が勝つ (v0.9 でも far-edge-wins 形は不変)。凍結
|
|
188
|
+
// ホームテリトリー (R5 是正): アンカーが凍結列でポインタも凍結帯内なら駆動しない —
|
|
189
|
+
// 凍結ペイン内の選択操作が可動帯を勝手に左スクロールさせない (帯外 [W_F, W_F+EDGE_ZONE)
|
|
190
|
+
// の戻りドラッグは従来どおり駆動)。v0.9 (計画 §4.4): 右端ゾーンも帯基準 — スクロール帯の
|
|
191
|
+
// 右端 = paneW − W_T_vis。末尾ホームテリトリーは縦と同形 (アンカー末尾列 ∧ T_px > 0 ∧
|
|
192
|
+
// ポインタ帯内 ⇒ 無駆動、T_px = 0 除外)
|
|
164
193
|
const frozen = handle.getFrozenSize()
|
|
165
|
-
|
|
166
|
-
|
|
194
|
+
const visW = frozen.trailingVisibleWidth
|
|
195
|
+
if (px > paneW - visW - EDGE_ZONE) {
|
|
196
|
+
const anchorTrailingCol = context.anchorCol !== undefined && frozen.trailingCols > 0 && context.anchorCol >= colAxis.count - frozen.trailingCols
|
|
197
|
+
if (!(anchorTrailingCol && visW > 0 && px >= paneW - visW)) {
|
|
198
|
+
dx = speedFor(px - (paneW - visW - EDGE_ZONE))
|
|
199
|
+
}
|
|
167
200
|
} else if (px < frozen.width + EDGE_ZONE) {
|
|
168
201
|
// W_F = 0 (凍結列が全て非表示) は可視の凍結帯が存在しない — 負 px を帯内と
|
|
169
202
|
// 誤認して抑止しない (R5 是正)
|
|
@@ -183,9 +216,19 @@ export const useAutoScroller = ({ handleRef, rowAxis, colAxis, bodyRef, onDragFr
|
|
|
183
216
|
// 静止 + 無スクロール + hover 同値の tick は書き込みをスキップ (同値スタイルの毎フレーム
|
|
184
217
|
// 再書き込みを抑止 — スクロール追随の再配置は repaintOverlay 側が担う)
|
|
185
218
|
if (vy >= 0 && hx >= 0 && rowAxis.totalSize() > 0 && colAxis.totalSize() > 0) {
|
|
186
|
-
// 行 /
|
|
219
|
+
// 行 / 列とも凍結パーティション 3 分岐逆写像 (v0.6 / v0.5 計画 §2、末尾は v0.9 §2.1)。
|
|
220
|
+
// ctx は判断のこの場でインライン構築 (§2.1 規則 1 — census #4)。trailingSize は TREE
|
|
221
|
+
// フィールド (規則 2)、extent は tick 冒頭の viewport スナップショットを再利用 (同 tick
|
|
222
|
+
// 鮮度 — 計画 §0.4 R1。paneW/H <= 0 は上で次フレームへ抜けるため extent > 0 が保たれる)
|
|
187
223
|
const frozenHover = handle.getFrozenSize()
|
|
188
|
-
const hover = clampCoord(
|
|
224
|
+
const hover = clampCoord(
|
|
225
|
+
{
|
|
226
|
+
row: viewportYToRow(rowAxis, py, { scroll: vy, frozenSize: frozenHover.height, trailingSize: frozenHover.trailingHeight, extent: viewport.height }),
|
|
227
|
+
col: viewportXToCol(colAxis, px, { scroll: hx, frozenSize: frozenHover.width, trailingSize: frozenHover.trailingWidth, extent: viewport.width }),
|
|
228
|
+
},
|
|
229
|
+
rowAxis.count,
|
|
230
|
+
colAxis.count,
|
|
231
|
+
)
|
|
189
232
|
const last = lastHoverRef.current
|
|
190
233
|
if (dy !== 0 || dx !== 0 || last === null || last.row !== hover.row || last.col !== hover.col) {
|
|
191
234
|
lastHoverRef.current = hover
|
|
@@ -85,15 +85,22 @@ const caretIndexFromPoint = (clientX: number, clientY: number, cell: CellCoord):
|
|
|
85
85
|
/**
|
|
86
86
|
* Body-relative pointer → cell hit test with the §7-4 freshness hierarchy: the handle's
|
|
87
87
|
* synchronous scroll position, the press-cached body rect, one zoom division at the boundary
|
|
88
|
-
* and the frozen-partition inverse mapping on both axes
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
88
|
+
* and the three-way frozen-partition inverse mapping on both axes (leading > trailing >
|
|
89
|
+
* middle — v0.9 plan §4.2, census #1), clamped into the grid. The AxisInverseCtx pair is
|
|
90
|
+
* built INLINE from one handle snapshot per judgment (`trailingSize` = the TREE fields,
|
|
91
|
+
* `extent` = a judgment-time `getViewportSize()` read — plan §2.1 rules 1/2; extent 0 keeps
|
|
92
|
+
* the trailing branch inert, byte-matching v0.8). ONE mapping shared by the cell engine and
|
|
93
|
+
* the reference-drag owner (usePointDrag — plan §4.1.18) so an identical pointer resolves to
|
|
94
|
+
* an identical cell in both. null = unmeasurable (no handle / rect, detached scroll
|
|
95
|
+
* sentinel, empty axis).
|
|
92
96
|
* body 基準のポインタ → セルのヒットテスト (§7-4 の鮮度序列): ハンドルの同期スクロール位置・
|
|
93
|
-
* 押下時計測の body rect・境界で 1
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
+
* 押下時計測の body rect・境界で 1 回だけのズーム除算・両軸の凍結パーティション 3 分岐逆写像
|
|
98
|
+
* (先頭 > 末尾 > 中央 — v0.9 計画 §4.2、census #1)、グリッド内へクランプ。AxisInverseCtx の対は
|
|
99
|
+
* 判断毎に 1 ハンドルスナップショットからインライン構築 (`trailingSize` は TREE フィールド、
|
|
100
|
+
* `extent` は判断時 `getViewportSize()` 読み — 計画 §2.1 規則 1/2。extent 0 は末尾分岐を不活性に
|
|
101
|
+
* 保ち v0.8 と byte 一致)。セルエンジンと参照ドラッグ所有者 (usePointDrag — 計画 §4.1.18) が
|
|
102
|
+
* 共有する単一写像 (同じポインタは両者で同じセルに解決する)。null = 計測不能 (ハンドル / rect
|
|
103
|
+
* 不在、detached 番兵、空軸)。
|
|
97
104
|
*/
|
|
98
105
|
export const hitTestCell = (handle: VirtualGridHandle | null, rect: DOMRect | null, rowAxis: AxisModel, colAxis: AxisModel, zoom: number, clientX: number, clientY: number): CellCoord | null => {
|
|
99
106
|
if (handle === null || rect === null) {
|
|
@@ -109,9 +116,19 @@ export const hitTestCell = (handle: VirtualGridHandle | null, rect: DOMRect | nu
|
|
|
109
116
|
return null
|
|
110
117
|
}
|
|
111
118
|
// ズーム境界 (v0.3): rect / client は視覚 px、軸は論理 px — 差分を zoom で 1 回だけ除算。
|
|
112
|
-
// 行 /
|
|
119
|
+
// 行 / 列とも凍結パーティション 3 分岐逆写像 (v0.6 / v0.5 計画 §2、末尾は v0.9 §2.1)。ctx は
|
|
120
|
+
// この判断のその場構築 (規則 1): trailingSize は TREE フィールド (規則 2)、extent は判断時の
|
|
121
|
+
// getViewportSize() 読み (未測定 {0,0} は末尾分岐が不活性のまま = v0.8 恒等の縮退)
|
|
113
122
|
const frozen = handle.getFrozenSize()
|
|
114
|
-
|
|
123
|
+
const viewport = handle.getViewportSize()
|
|
124
|
+
return clampCoord(
|
|
125
|
+
{
|
|
126
|
+
row: viewportYToRow(rowAxis, (clientY - rect.top) / zoom, { scroll: vy, frozenSize: frozen.height, trailingSize: frozen.trailingHeight, extent: viewport.height }),
|
|
127
|
+
col: viewportXToCol(colAxis, (clientX - rect.left) / zoom, { scroll: hx, frozenSize: frozen.width, trailingSize: frozen.trailingWidth, extent: viewport.width }),
|
|
128
|
+
},
|
|
129
|
+
rowAxis.count,
|
|
130
|
+
colAxis.count,
|
|
131
|
+
)
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
type UseSelectionEngineParams = {
|
|
@@ -1027,3 +1027,35 @@
|
|
|
1027
1027
|
width: 100%;
|
|
1028
1028
|
height: 100%;
|
|
1029
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
/*
|
|
1032
|
+
* v0.9 凍結分割線 (計画 §2.4 — ADR-20 / ADR-24): 全凍結境界の強い Excel / Sheets 風の線。
|
|
1033
|
+
* consumer 面は CSS 変数 2 つだけ (--aqsp-divider-color / --aqsp-divider-width — パッケージは
|
|
1034
|
+
* 読むだけで書き手は consumer スタイルシート単独)。既定は 2px の #9ca3af (gray-400 — 既存全
|
|
1035
|
+
* 罫線より 1 段濃い)。width 0px は逃げ道 — 象限別 fill 断片の継ぎ目が再露出し forced-colors も
|
|
1036
|
+
* 無描画 (意図的、README / 分冊 05 §8 登記)。位置 (leading 縁 0 / trailing 縁 0) と長さは
|
|
1037
|
+
* 各セグメントの静的インライン幾何 + ホストクリップが供給し、このブロックは太さと色だけを持つ。
|
|
1038
|
+
* pointer-events: none — バーは不活性で、リサイズの operand は帯側隣接ストリップ (ADR-20)。
|
|
1039
|
+
*/
|
|
1040
|
+
.aqsp-freeze-divider {
|
|
1041
|
+
position: absolute;
|
|
1042
|
+
background: var(--aqsp-divider-color, #9ca3af);
|
|
1043
|
+
pointer-events: none;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/* 縦線 (x 境界) は太さ = width、横線 (y 境界) は太さ = height (共有変数 1 本) */
|
|
1047
|
+
.aqsp-freeze-divider-v {
|
|
1048
|
+
width: var(--aqsp-divider-width, 2px);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.aqsp-freeze-divider-h {
|
|
1052
|
+
height: var(--aqsp-divider-width, 2px);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/* forced-colors: 不透明な実線は再着色で生存する — CanvasText へ寄せるだけ (§2.4。凍結クリップの
|
|
1056
|
+
無装飾免除とは別物で、分割線は自前の規則を持つ) */
|
|
1057
|
+
@media (forced-colors: active) {
|
|
1058
|
+
.aqsp-freeze-divider {
|
|
1059
|
+
background: CanvasText;
|
|
1060
|
+
}
|
|
1061
|
+
}
|