@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
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
* plus every declared aspect (format / validation / hyperlink / note), comments never; a v0.7
|
|
16
16
|
* adapter yields exactly `{ row, col, text }`.
|
|
17
17
|
*
|
|
18
|
+
* v0.10 read-only wall (G7 — design ADR-28 rule 5): `confirmFill` walks the ≤2-rect disjoint
|
|
19
|
+
* decomposition of box ∖ source (`fillExtensionRects` — diagonal L legs included) after the limit
|
|
20
|
+
* gate and before `expandFill`; a hit refuses the WHOLE fill through the required
|
|
21
|
+
* `rejectReadOnly` funnel with `cellCount` = box area − source area (exact — deliberately not the
|
|
22
|
+
* limit gate's estimate) and the limit-refusal cleanup (no selection growth, no bumpEdit). Fill
|
|
23
|
+
* FROM a read-only source stays legal; capability undeclared = byte-identical behaviour.
|
|
24
|
+
*
|
|
25
|
+
* v0.9 trailing freeze (plan §2.1 / §4.5 — census #5 / #6): the hover hit test builds BOTH
|
|
26
|
+
* inverse ctx literals inline from one handle snapshot (a NEW judgment-time `getViewportSize()`
|
|
27
|
+
* read supplies `extent`; `trailingSize` cites the TREE fields); the press-time grab arithmetic
|
|
28
|
+
* builds the forward ctx pair, classifies the corner strictly (`endCol >= count − T` is
|
|
29
|
+
* trailing — the boundary track is SCROLL) and gains the ceiling clamp twins
|
|
30
|
+
* `min(inside, extent − vis − 1)` so a non-trailing corner's inside pixel never re-anchors into
|
|
31
|
+
* the trailing band (the FZ19 floor's far mirror). T = 0 keeps every path byte-identical.
|
|
32
|
+
*
|
|
18
33
|
* fill ハンドル機械 (§8 PT-2 — copy fill のみ)。常設ハンドルノードへ wire-once の pointerdown
|
|
19
34
|
* を 1 本。press は義務 repaint より先に自機フラグをセット (§8.1 の可視凍結が評価前に成立して
|
|
20
35
|
* いないと display:none が capture を殺す)。ドラッグはボックス方式 (v0.3 の斜め
|
|
@@ -29,15 +44,28 @@
|
|
|
29
44
|
* `readFillSource` / `fillSourceEdit`): `getCellFormula` 宣言時は対象毎に相対シフトした数式ソース、
|
|
30
45
|
* 宣言済み側面 (format / validation / hyperlink / note) の全部、comments は決して運ばず、v0.7
|
|
31
46
|
* アダプタは `{ row, col, text }` ちょうど。
|
|
47
|
+
*
|
|
48
|
+
* v0.10 読み取り専用壁 (G7 — 設計 ADR-28 rule 5): `confirmFill` は限度ゲート後・`expandFill` 前に
|
|
49
|
+
* box ∖ source の ≤2 非交矩形分解 (`fillExtensionRects` — 斜めの L 字 2 レッグ込み) を walk し、
|
|
50
|
+
* hit は必須 `rejectReadOnly` 漏斗経由で fill 全体を拒否する (cellCount = box 面積 − source 面積 —
|
|
51
|
+
* 限度ゲートの見積りとは意図的に別の数)。後始末は限度拒否と同一 (選択不伸長・bumpEdit なし)。
|
|
52
|
+
* 読み取り専用ソースからの fill は合法のまま。能力未宣言は挙動バイト恒等。
|
|
53
|
+
*
|
|
54
|
+
* v0.9 末尾凍結 (計画 §2.1 / §4.5 — census #5 / #6): hover ヒットテストは 1 ハンドル快照から
|
|
55
|
+
* 両軸の逆写像 ctx をインライン構築 (新規の判断時 `getViewportSize()` 読みが `extent` を供給、
|
|
56
|
+
* `trailingSize` は TREE フィールドを引用)。押下時の grab 算術は順写像 ctx 対を構築し、隅を
|
|
57
|
+
* 厳密分類 (`endCol >= count − T` が末尾 — 境界トラックは SCROLL) して天井クランプ双子
|
|
58
|
+
* `min(inside, extent − vis − 1)` を得る (非末尾隅の内側 px が末尾帯へ再アンカーされない —
|
|
59
|
+
* FZ19 床の遠ミラー)。T = 0 は全経路 byte 恒等。
|
|
32
60
|
*/
|
|
33
61
|
|
|
34
62
|
import type { VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
35
63
|
import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
|
|
36
|
-
import { estimateFillCellCount, expandFill, fillSourceEdit, readFillSource } from "../clipboard/tiling.ts"
|
|
64
|
+
import { estimateFillCellCount, expandFill, fillExtensionRects, fillSourceEdit, readFillSource } from "../clipboard/tiling.ts"
|
|
37
65
|
import type { AxisModel } from "../model/axisModel.ts"
|
|
38
66
|
import { type CellCoord, type CellRange, clampCoord, makeRange, type SpreadsheetOperation } from "../model/cellCoord.ts"
|
|
39
|
-
import type
|
|
40
|
-
import { colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
|
|
67
|
+
import { type CellEdit, type EditRejection, type ReadOnlyRejection, rangesContainReadOnly, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
68
|
+
import { type AxisForwardCtx, colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
|
|
41
69
|
import type { SelectionState } from "../selection/selectionState.ts"
|
|
42
70
|
import type { AutoScroller } from "../selection/useAutoScroller.ts"
|
|
43
71
|
import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
|
|
@@ -56,6 +84,17 @@ import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
|
|
|
56
84
|
*/
|
|
57
85
|
const FILL_ARM_THRESHOLD = 4
|
|
58
86
|
|
|
87
|
+
/** `getFrozenSize()` snapshot shape (engine 3.5.0 — 10 fields incl. the trailing family). / `getFrozenSize()` 快照の形 (エンジン 3.5.0 — 末尾族込みの 10 フィールド)。 */
|
|
88
|
+
type FrozenSnapshot = ReturnType<VirtualGridHandle["getFrozenSize"]>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pre-attach frozen-size snapshot (all zeros — the capability branch for a not-yet-attached
|
|
92
|
+
* handle, byte-matching the engine's pre-measure state, not a value fallback).
|
|
93
|
+
* 装着前の凍結寸法快照 (全 0 — 未装着ハンドルの能力分岐。エンジンの未測定状態と byte 一致で、
|
|
94
|
+
* 値フォールバックではない)。
|
|
95
|
+
*/
|
|
96
|
+
const EMPTY_FROZEN_SIZE: FrozenSnapshot = Object.freeze({ cols: 0, width: 0, rows: 0, height: 0, trailingCols: 0, trailingRows: 0, trailingWidth: 0, trailingHeight: 0, trailingVisibleWidth: 0, trailingVisibleHeight: 0 })
|
|
97
|
+
|
|
59
98
|
type UseFillHandleParams = {
|
|
60
99
|
/** Zoom scale reader (v0.3): client px = logical px × zoom — divide client deltas once at this boundary. / ズーム倍率 reader (v0.3)。client px = 論理 px × zoom — client 差分はこの境界で 1 回だけ除算する。 */
|
|
61
100
|
zoomRef: RefObject<number>
|
|
@@ -75,6 +114,14 @@ type UseFillHandleParams = {
|
|
|
75
114
|
commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
|
|
76
115
|
maxOperationCells: number
|
|
77
116
|
onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
|
|
117
|
+
/**
|
|
118
|
+
* v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
|
|
119
|
+
* read through paramsRef): G7 fires it once per refused confirm. Capability absent ⇒ never
|
|
120
|
+
* called (byte identity).
|
|
121
|
+
* v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
|
|
122
|
+
* G7 が拒否された確定毎に 1 回発火する。能力未宣言なら一度も呼ばれない (バイト恒等)。
|
|
123
|
+
*/
|
|
124
|
+
rejectReadOnly: (rejection: ReadOnlyRejection) => void
|
|
78
125
|
/** Preview writer relay (overlay-owned positioner + liveFillPreviewRef). / プレビュー writer リレー。 */
|
|
79
126
|
writeFillPreview: (range: CellRange | null) => void
|
|
80
127
|
/** §8.2 repaint edges (press after flag + cleanup end). / §8.2 の repaint エッジ。 */
|
|
@@ -150,10 +197,21 @@ export const useFillHandle = (params: UseFillHandleParams): SelectionEngine => {
|
|
|
150
197
|
return null
|
|
151
198
|
}
|
|
152
199
|
// ズーム境界 (v0.3): client 差分を z で 1 回だけ除算して論理 px へ。
|
|
153
|
-
//
|
|
200
|
+
// 両軸とも凍結パーティション 3 分岐逆写像 (v0.5 / v0.6 計画 §2、v0.9 §2.1)
|
|
154
201
|
const z = paramsRef.current.zoomRef.current
|
|
155
202
|
const frozen = handle.getFrozenSize()
|
|
156
|
-
|
|
203
|
+
// 判断時 extent (v0.9 §2.1 — census #5 の新規 getViewportSize() 読み)。逆写像 ctx は
|
|
204
|
+
// この場でインライン構築し (跨サイト共有ビルダ禁止 — §5.3)、trailingSize は TREE
|
|
205
|
+
// フィールド trailingWidth / trailingHeight を正準に引く (vis 代替は分類等価ゆえ命名ピン)
|
|
206
|
+
const viewport = handle.getViewportSize()
|
|
207
|
+
return clampCoord(
|
|
208
|
+
{
|
|
209
|
+
row: viewportYToRow(rowAxis, (clientY - rect.top) / z, { scroll: vy, frozenSize: frozen.height, trailingSize: frozen.trailingHeight, extent: viewport.height }),
|
|
210
|
+
col: viewportXToCol(colAxis, (clientX - rect.left) / z, { scroll: hx, frozenSize: frozen.width, trailingSize: frozen.trailingWidth, extent: viewport.width }),
|
|
211
|
+
},
|
|
212
|
+
rowAxis.count,
|
|
213
|
+
colAxis.count,
|
|
214
|
+
)
|
|
157
215
|
}, [])
|
|
158
216
|
|
|
159
217
|
/**
|
|
@@ -228,6 +286,18 @@ export const useFillHandle = (params: UseFillHandleParams): SelectionEngine => {
|
|
|
228
286
|
p.onOperationRejected?.("fill", count, "limit")
|
|
229
287
|
return
|
|
230
288
|
}
|
|
289
|
+
// G7 (v0.10 §6.8 / 設計 ADR-28 rule 5): 読み取り専用壁 — walk は拡張域 (box ∖ source) の
|
|
290
|
+
// ≤2 非交矩形分解 fillExtensionRects (斜めドラッグの L 字 2 レッグ対応)。ソース行 / 列は
|
|
291
|
+
// 書込対象ではないため walk 外 = 読み取り専用ソースからの fill は合法 (Excel 同等)。座席は
|
|
292
|
+
// 限度ゲート後・expandFill (実体化) 前。cellCount = box 面積 − source 面積 (正確 — 限度
|
|
293
|
+
// チャネルの見積り estimateFillCellCount とは別の数、登記済み非対称)。後始末は限度拒否と
|
|
294
|
+
// 同一 (cleanupDrag 済み — 選択不伸長・bumpEdit なし)
|
|
295
|
+
if (rangesContainReadOnly(p.adapter, fillExtensionRects(source, box))) {
|
|
296
|
+
const boxArea = (box.endRow - box.startRow + 1) * (box.endCol - box.startCol + 1)
|
|
297
|
+
const sourceArea = (source.endRow - source.startRow + 1) * (source.endCol - source.startCol + 1)
|
|
298
|
+
p.rejectReadOnly({ kind: "operation", op: "fill", cellCount: boxArea - sourceArea })
|
|
299
|
+
return
|
|
300
|
+
}
|
|
231
301
|
// 展開は fillDown / fillRight と同じソース毎規則 (分冊 08 §4 の fill 行): 種 (数式ソースか正準
|
|
232
302
|
// 文字列) + 宣言済み側面を 1 回読み、対象毎に数式を相対シフト — v0.7 アダプタは { row, col, text } ちょうど
|
|
233
303
|
const edits = expandFill((row, col) => readFillSource(p.adapter, row, col), source, box, fillSourceEdit)
|
|
@@ -357,13 +427,19 @@ export const useFillHandle = (params: UseFillHandleParams): SelectionEngine => {
|
|
|
357
427
|
const hx = pressPosition.x
|
|
358
428
|
// ズーム境界 (v0.3): 論理オフセットは ×z で視覚 px へ写してから client と比較する
|
|
359
429
|
const zPress = p.zoomRef.current
|
|
360
|
-
// 隅の x は凍結パーティション順写像 (v0.5 計画 §2
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
const
|
|
430
|
+
// 隅の x は凍結パーティション順写像 (v0.5 計画 §2、v0.9 §2.1 で 3 分岐 —
|
|
431
|
+
// repaintOverlay のハンドル描画と同式)。無条件 −hx だと凍結 endCol の押下で dx が
|
|
432
|
+
// hx·z ぶん膨らみ、以後の全ヒットテストが左へ hx ずれる (誤列への fill 確定 +
|
|
433
|
+
// 暴走左オートスクロール)
|
|
434
|
+
const pressFrozen = p.handleRef.current?.getFrozenSize() ?? EMPTY_FROZEN_SIZE
|
|
435
|
+
// 判断時 forward ctx (v0.9 §2.1 — census #6): 押下 1 スナップショットの
|
|
436
|
+
// getViewportSize() を extent に両軸をその場インライン構築 (§5.3)
|
|
437
|
+
const pressViewport = p.handleRef.current?.getViewportSize() ?? { width: 0, height: 0 }
|
|
438
|
+
const pressCtxX: AxisForwardCtx = { scroll: hx, frozenCount: pressFrozen.cols, trailingCount: pressFrozen.trailingCols, extent: pressViewport.width }
|
|
439
|
+
const pressCtxY: AxisForwardCtx = { scroll: pressVy, frozenCount: pressFrozen.rows, trailingCount: pressFrozen.trailingRows, extent: pressViewport.height }
|
|
440
|
+
const cornerViewportX = colToViewportX(p.colAxis, source.endCol, pressCtxX) + p.colAxis.sizeOf(source.endCol)
|
|
365
441
|
// 隅 y も凍結パーティション順写像 (v0.6 — repaintOverlay のハンドル描画と同式)
|
|
366
|
-
const cornerViewportY = rowToViewportY(p.rowAxis, source.endRow,
|
|
442
|
+
const cornerViewportY = rowToViewportY(p.rowAxis, source.endRow, pressCtxY) + p.rowAxis.sizeOf(source.endRow)
|
|
367
443
|
// 内側 1px は論理 px の概念 (「隅の内側最終 px」) — 視覚 1px のままだと z < 1 で
|
|
368
444
|
// 1/z 論理 px に膨らみ、極小トラック (< 1/z px) では隣トラックへ再アンカーされて
|
|
369
445
|
// 押下点回帰キャンセルが 1 トラック fill に化ける。×zPress で論理 1px に固定。
|
|
@@ -371,11 +447,22 @@ export const useFillHandle = (params: UseFillHandleParams): SelectionEngine => {
|
|
|
371
447
|
// 内側 px が凍結帯へ潜って全ヒットテストが F−1 列へ解決する — 帯下限 W_F で
|
|
372
448
|
// クランプ (R2 是正。凍結 endCol と W_F = 0 はクランプ不要 — 後者は v0.4 恒等)
|
|
373
449
|
const insideRawX = cornerViewportX - 1
|
|
374
|
-
const
|
|
450
|
+
const flooredInsideX = source.endCol >= pressFrozen.cols && pressFrozen.width > 0 ? Math.max(insideRawX, pressFrozen.width) : insideRawX
|
|
451
|
+
// 厳密分類 (v0.9 §4.5 — FZ21 ミラー): endCol >= count − T が末尾 (境界列
|
|
452
|
+
// count − T − 1 は SCROLL)。T = 0 は count − 0 = count で恒偽
|
|
453
|
+
const endColIsTrailing = source.endCol >= p.colAxis.count - pressFrozen.trailingCols
|
|
454
|
+
// 天井双子 (v0.9 §4.5 — FZ19 の帯床クランプのミラー): 非末尾 endCol の内側 px が
|
|
455
|
+
// 末尾帯 [extent − vis, extent) へ潜ると全ヒットテストが末尾列へ解決し、押下点回帰
|
|
456
|
+
// キャンセルが末尾への fill 書込に化ける — 帯直前の最終 px extent − vis − 1 で
|
|
457
|
+
// クランプ (末尾 endCol 自身は帯内が正位置なのでクランプしない。T_px = 0 は恒等)
|
|
458
|
+
const insideViewportX = !endColIsTrailing && pressFrozen.trailingWidth > 0 ? Math.min(flooredInsideX, pressViewport.width - pressFrozen.trailingVisibleWidth - 1) : flooredInsideX
|
|
375
459
|
// 内側 py も帯下限 H_F でクランプ (v0.6 — 横の insideViewportX と対称。小数 vy の隅
|
|
376
460
|
// すきまでスクロール帯 endRow が凍結帯へ潜る dy ギャップを閉塞)
|
|
377
461
|
const insideRawY = cornerViewportY - 1
|
|
378
|
-
const
|
|
462
|
+
const flooredInsideY = source.endRow >= pressFrozen.rows && pressFrozen.height > 0 ? Math.max(insideRawY, pressFrozen.height) : insideRawY
|
|
463
|
+
// 縦の天井双子 (v0.9 §4.5 — 横と対称の末尾帯クランプ)
|
|
464
|
+
const endRowIsTrailing = source.endRow >= p.rowAxis.count - pressFrozen.trailingRows
|
|
465
|
+
const insideViewportY = !endRowIsTrailing && pressFrozen.trailingHeight > 0 ? Math.min(flooredInsideY, pressViewport.height - pressFrozen.trailingVisibleHeight - 1) : flooredInsideY
|
|
379
466
|
grabOffsetRef.current = { dx: event.clientX - (rect.left + insideViewportX * zPress), dy: event.clientY - (rect.top + insideViewportY * zPress) }
|
|
380
467
|
} else {
|
|
381
468
|
grabOffsetRef.current = { dx: 0, dy: 0 }
|
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
* Ctrl+Enter posts (retries after a rejection), Escape closes. Leaving by focus closes without
|
|
7
7
|
* posting. DOM hooks: `data-aqsp-popover="comment"`, `data-aqsp-popover-title`,
|
|
8
8
|
* `data-aqsp-comment-thread`, `data-aqsp-comment-id`, `data-aqsp-comment-input`,
|
|
9
|
-
* `data-aqsp-popover-post`, `data-aqsp-popover-close`, `data-aqsp-popover-rejected`.
|
|
9
|
+
* `data-aqsp-popover-post`, `data-aqsp-popover-close`, `data-aqsp-popover-rejected`. v0.10 viewer
|
|
10
|
+
* mode (`ctx.cellReadOnly === true`): the Post button is `disabled` + `aria-disabled` and
|
|
11
|
+
* Ctrl+Enter does nothing — the thread stays readable (the wall is G14a; key absent =
|
|
12
|
+
* v0.9-identical conduct).
|
|
10
13
|
*
|
|
11
14
|
* 既定のコメントスレッド (v0.8 §4.1.16): 既存スレッド (著者 / 時刻 / 本文 — 各件は consumer の id を
|
|
12
15
|
* 保つ) + 返信 textarea + 投稿ボタン。投稿はスレッド全体 `[...current (id つき), { text }]` を
|
|
13
16
|
* commit する — id / 著者 / 時計はパッケージが採番しない (設計 v5「Comments」)。空の返信は投稿
|
|
14
17
|
* しない。キー: Ctrl+Enter 投稿 (拒否後は再試行)、Escape で閉じる。フォーカス離脱は投稿せず閉じる。
|
|
18
|
+
* v0.10 ビューアモード (`ctx.cellReadOnly === true`): 投稿ボタンは `disabled` + `aria-disabled`、
|
|
19
|
+
* Ctrl+Enter は何もしない — スレッドは読めるまま (壁は G14a。キー不在 = v0.9 恒等)。
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
|
|
@@ -26,6 +31,9 @@ export type CommentPopoverProps = { ctx: CommentPopoverContext; labels: Spreadsh
|
|
|
26
31
|
*/
|
|
27
32
|
export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode => {
|
|
28
33
|
const [reply, setReply] = useState("")
|
|
34
|
+
// v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
|
|
35
|
+
// 壁は G14a — ここは表示であって防壁ではない
|
|
36
|
+
const readOnly = ctx.cellReadOnly === true
|
|
29
37
|
const titleId = useId()
|
|
30
38
|
const settlingRef = useRef(false)
|
|
31
39
|
|
|
@@ -38,6 +46,10 @@ export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode
|
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
const post = (): void => {
|
|
49
|
+
// 読み取り専用ビューア: 投稿は不可 (disabled ボタンと同じ品行 — Ctrl+Enter も何もしない)
|
|
50
|
+
if (readOnly) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
41
53
|
// 空 (空白のみ) の返信は投稿しない
|
|
42
54
|
if (reply.trim() === "") {
|
|
43
55
|
return
|
|
@@ -97,7 +109,7 @@ export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode
|
|
|
97
109
|
onKeyDown={onKeyDown}
|
|
98
110
|
/>
|
|
99
111
|
<div className="aqsp-popover-actions">
|
|
100
|
-
<button type="button" className="aqsp-popover-button" data-aqsp-popover-post="" onClick={post}>
|
|
112
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-post="" disabled={readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={post}>
|
|
101
113
|
{resolveLabel(labels, "commentPost")}
|
|
102
114
|
</button>
|
|
103
115
|
<button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
|
|
@@ -10,14 +10,19 @@
|
|
|
10
10
|
* without saving. DOM hooks: `data-aqsp-popover="link"`, `data-aqsp-popover-title`,
|
|
11
11
|
* `data-aqsp-link-text`, `data-aqsp-link-url`, `data-aqsp-popover-save`, `data-aqsp-link-open`
|
|
12
12
|
* (the Open button — distinct from the layer's anchor open-state `data-aqsp-popover-open`),
|
|
13
|
-
* `data-aqsp-popover-close`, `data-aqsp-popover-rejected`.
|
|
13
|
+
* `data-aqsp-popover-close`, `data-aqsp-popover-rejected`. v0.10 viewer mode
|
|
14
|
+
* (`ctx.cellReadOnly === true`): Save is `disabled` + `aria-disabled` and Enter does nothing,
|
|
15
|
+
* while the URL stays readable and Open stays live (navigation is a read — the wall is G14a; key
|
|
16
|
+
* absent = v0.9-identical conduct).
|
|
14
17
|
*
|
|
15
18
|
* 既定のハイパーリンクエディタ (v0.8 §4.1.16): text + URL 欄、保存、Open (ホストが onHyperlinkOpen を
|
|
16
19
|
* 宣言し (`ctx.canOpen`) セルにリンクがあるときだけ)、閉じる。URL は打鍵毎に normalizeHyperlink で
|
|
17
20
|
* 検証し、正規化不能な非空値は不正表示 (`data-aqsp-link-invalid` / `aria-invalid`) で保存を拒む —
|
|
18
21
|
* commit は常に正規化済み href か null (空欄 = リンク消去) だけを運ぶ。空の text 下書き + 有効 URL の
|
|
19
22
|
* 保存は href を表示テキストとして commit する (Sheets / Excel 同等 — 空セルのリンクを見えないままに
|
|
20
|
-
* しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。
|
|
23
|
+
* しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。v0.10 ビューアモード
|
|
24
|
+
* (`ctx.cellReadOnly === true`): 保存は `disabled` + `aria-disabled` で Enter も何もしないが、URL は
|
|
25
|
+
* 読めたまま Open も生きている (ナビゲーションは読み — 壁は G14a。キー不在 = v0.9 恒等)。
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
28
|
import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
|
|
@@ -34,6 +39,9 @@ export type LinkPopoverProps = { ctx: LinkPopoverContext; labels: SpreadsheetLab
|
|
|
34
39
|
export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
|
|
35
40
|
const [text, setText] = useState(ctx.current.text)
|
|
36
41
|
const [url, setUrl] = useState(ctx.current.url === undefined ? "" : ctx.current.url)
|
|
42
|
+
// v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
|
|
43
|
+
// Open は読み (ナビゲーション) なので生きたまま。壁は G14a — ここは表示であって防壁ではない
|
|
44
|
+
const readOnly = ctx.cellReadOnly === true
|
|
37
45
|
const titleId = useId()
|
|
38
46
|
const settlingRef = useRef(false)
|
|
39
47
|
// 空欄はリンク消去、それ以外は正規化 (null = 不正)
|
|
@@ -49,6 +57,10 @@ export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
|
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
const save = (): void => {
|
|
60
|
+
// 読み取り専用ビューア: 保存は不可 (disabled ボタンと同じ品行 — Enter も何もしない)
|
|
61
|
+
if (readOnly) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
52
64
|
// 不正 URL は保存しない (表示で知らせる — 代替値は作らない)
|
|
53
65
|
if (invalid) {
|
|
54
66
|
return
|
|
@@ -91,7 +103,7 @@ export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
|
|
|
91
103
|
<input className="aqsp-popover-input" data-aqsp-link-url="" data-aqsp-popover-autofocus="" type="url" value={url} aria-invalid={invalid || ctx.rejected ? true : undefined} onChange={(event) => setUrl(event.currentTarget.value)} onKeyDown={onKeyDown} />
|
|
92
104
|
</label>
|
|
93
105
|
<div className="aqsp-popover-actions">
|
|
94
|
-
<button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid} onClick={save}>
|
|
106
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid || readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={save}>
|
|
95
107
|
{resolveLabel(labels, "linkSave")}
|
|
96
108
|
</button>
|
|
97
109
|
{ctx.canOpen && ctx.current.url !== undefined ? (
|
|
@@ -5,17 +5,26 @@
|
|
|
5
5
|
* focus saves-if-changed — an anchor scrolled under a frozen band is clipped, not un-rendered
|
|
6
6
|
* (SelectionOverlay `placeAnchor`), so the textarea keeps focus and that is NOT a leave: the
|
|
7
7
|
* editor hides and comes back with its draft. The textarea's `aria-describedby` points at the hint paragraph, which
|
|
8
|
-
* swaps
|
|
8
|
+
* swaps among `labels.noteEscapeHint` (normal), `labels.noteRejectedHint` (rejected) and
|
|
9
|
+
* `labels.noteViewerHint` (viewer mode — precedence over the rejected wording, since neither save
|
|
10
|
+
* nor retry is possible there and the hint must not promise either).
|
|
9
11
|
* DOM hooks: `data-aqsp-popover="note"`, `data-aqsp-popover-title`, `data-aqsp-note-input`,
|
|
10
12
|
* `data-aqsp-popover-save`, `data-aqsp-popover-close`, `data-aqsp-popover-hint`,
|
|
11
|
-
* `data-aqsp-popover-rejected`.
|
|
13
|
+
* `data-aqsp-popover-rejected`. v0.10 viewer mode (`ctx.cellReadOnly === true`): the Save button
|
|
14
|
+
* is `disabled` + `aria-disabled` (native only — no new attribute, no package CSS, design ADR-31),
|
|
15
|
+
* Ctrl+Enter does nothing, Escape / focus-leave close WITHOUT saving (the wall is G14a; key
|
|
16
|
+
* absent = v0.9-identical conduct), and the hint swaps to `noteViewerHint`.
|
|
12
17
|
*
|
|
13
18
|
* 既定の note エディタ (v0.8 §4.1.16): textarea の下書き + 保存 / 閉じるボタン。キー: Escape は
|
|
14
19
|
* 通常状態で「変更があれば保存して閉じる」、拒否状態では保存せず閉じる。Ctrl+Enter は保存 (拒否後は
|
|
15
20
|
* 再試行)。フォーカスがポップオーバーを離れたら変更があれば保存 — 凍結帯の下へ潜ったアンカーは
|
|
16
21
|
* clip されるだけで描画から外れない (SelectionOverlay の placeAnchor) ので textarea はフォーカスを保ち、
|
|
17
22
|
* 離脱には当たらない: エディタは隠れて下書きごと戻る。textarea の aria-describedby は
|
|
18
|
-
*
|
|
23
|
+
* ヒント段落を指し、`labels.noteEscapeHint` (通常) ↔ `labels.noteRejectedHint` (拒否状態) ↔
|
|
24
|
+
* `labels.noteViewerHint` (ビューアモード — 保存も再試行も不能のため拒否文言より優先) が差し替わる。
|
|
25
|
+
* v0.10 ビューアモード (`ctx.cellReadOnly === true`): 保存ボタンは `disabled` + `aria-disabled`
|
|
26
|
+
* (ネイティブのみ — 新属性 / パッケージ CSS なし、設計 ADR-31)、Ctrl+Enter は何もせず、Escape /
|
|
27
|
+
* フォーカス離脱は保存せず閉じ、ヒントは `noteViewerHint` に差し替わる (壁は G14a。キー不在 = v0.9 恒等)。
|
|
19
28
|
*/
|
|
20
29
|
|
|
21
30
|
import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
|
|
@@ -31,6 +40,9 @@ export type NotePopoverProps = { ctx: NotePopoverContext; labels: SpreadsheetLab
|
|
|
31
40
|
export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
|
|
32
41
|
const initial = ctx.current === undefined ? "" : ctx.current
|
|
33
42
|
const [draft, setDraft] = useState(initial)
|
|
43
|
+
// v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
|
|
44
|
+
// 壁は G14a — ここは表示であって防壁ではない
|
|
45
|
+
const readOnly = ctx.cellReadOnly === true
|
|
34
46
|
const titleId = useId()
|
|
35
47
|
const hintId = useId()
|
|
36
48
|
/** True while THIS component drives a commit / close (its synchronous focusout must not re-fire the save). / 自分が commit / close を駆動している間 true (同期 focusout の二重保存を防ぐ)。 */
|
|
@@ -46,11 +58,16 @@ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
const save = (): void => {
|
|
61
|
+
// 読み取り専用ビューア: 確定操作は不可 (disabled ボタンと同じ品行 — Ctrl+Enter も何もしない)
|
|
62
|
+
if (readOnly) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
49
65
|
// 空の下書きはノート消去
|
|
50
66
|
settle(() => ctx.commit(draft === "" ? null : draft))
|
|
51
67
|
}
|
|
52
68
|
const saveIfChanged = (): void => {
|
|
53
|
-
|
|
69
|
+
// 読み取り専用ビューア: Escape / フォーカス離脱は保存せず閉じる (下書きは commit できない)
|
|
70
|
+
if (draft !== initial && !readOnly) {
|
|
54
71
|
save()
|
|
55
72
|
} else {
|
|
56
73
|
settle(ctx.close)
|
|
@@ -91,10 +108,11 @@ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
|
|
|
91
108
|
</div>
|
|
92
109
|
<textarea className="aqsp-popover-textarea" data-aqsp-note-input="" data-aqsp-popover-autofocus="" value={draft} aria-describedby={hintId} aria-invalid={ctx.rejected ? true : undefined} onChange={(event) => setDraft(event.currentTarget.value)} onKeyDown={onKeyDown} />
|
|
93
110
|
<p className="aqsp-popover-hint" id={hintId} data-aqsp-popover-hint="">
|
|
94
|
-
{
|
|
111
|
+
{/* ビューアは保存 (noteEscapeHint) も再試行 (noteRejectedHint) も約束できない — Escape は保存せず閉じるため noteViewerHint を最優先 */}
|
|
112
|
+
{resolveLabel(labels, readOnly ? "noteViewerHint" : ctx.rejected ? "noteRejectedHint" : "noteEscapeHint")}
|
|
95
113
|
</p>
|
|
96
114
|
<div className="aqsp-popover-actions">
|
|
97
|
-
<button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" onClick={save}>
|
|
115
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={save}>
|
|
98
116
|
{resolveLabel(labels, "noteSave")}
|
|
99
117
|
</button>
|
|
100
118
|
<button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
|