@aiquants/spreadsheet 0.8.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 +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- package/src/styles/standalone.entry.css +9 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fill-handle machine (§8 PT-2 — copy fill only). Wires ONE wire-once
|
|
3
|
+
* pointerdown onto the resident handle node (pointer-events exception #2). The press sets the
|
|
4
|
+
* machine's OWN dragging flag BEFORE the mandatory press repaint (the §8.1 visibility freeze
|
|
5
|
+
* must be engaged when the repaint evaluates, or display:none kills the capture). The drag is
|
|
6
|
+
* box-based (v0.3 diagonal fill: box = bounding rect of source ∪ hover — single-axis and
|
|
7
|
+
* diagonal drags share ONE derivation, an inward hover collapses to no extension) and paints
|
|
8
|
+
* only the dashed preview box through the overlay writer; edge scrolling starts BOTH
|
|
9
|
+
* construction-time axis-fixed autoscrollers once armed (each ticks only in its own edge
|
|
10
|
+
* zone — no axis selection, no flip). The confirmed box is the LAST PREVIEWED box (source-containing bounding rect; no re-hit-test at up): one
|
|
11
|
+
* commitEdits of expandFill, selection updated to the merged rect ONLY on success. A
|
|
12
|
+
* zero-extension up is a no-write cleanup (no bumpEdit / clip discard). The expansion carries
|
|
13
|
+
* what fillDown / fillRight carry (v0.8, spec 08 §4 fill row — `readFillSource` /
|
|
14
|
+
* `fillSourceEdit`): the formula source shifted per target when `getCellFormula` is declared,
|
|
15
|
+
* plus every declared aspect (format / validation / hyperlink / note), comments never; a v0.7
|
|
16
|
+
* adapter yields exactly `{ row, col, text }`.
|
|
17
|
+
*
|
|
18
|
+
* fill ハンドル機械 (§8 PT-2 — copy fill のみ)。常設ハンドルノードへ wire-once の pointerdown
|
|
19
|
+
* を 1 本。press は義務 repaint より先に自機フラグをセット (§8.1 の可視凍結が評価前に成立して
|
|
20
|
+
* いないと display:none が capture を殺す)。ドラッグはボックス方式 (v0.3 の斜め
|
|
21
|
+
* フィル: ボックス = ソース ∪ hover のバウンディング矩形 — 単軸も斜めも同一導出、内向き hover
|
|
22
|
+
* は拡張なしへ収縮) で破線プレビューボックスだけを overlay writer 経由で描く。端スクロールは
|
|
23
|
+
* 武装時に構築時軸固定の 2 インスタンスを**両方**起動 (各自の端ゾーンでのみ tick — 軸選択も
|
|
24
|
+
* 反転もなし)。
|
|
25
|
+
* 確定ボックス = 最終プレビューボックス (up の再ヒットテストなし — ソース包含の合併矩形)。
|
|
26
|
+
* expandFill を commitEdits 1 回、選択
|
|
27
|
+
* 更新 (合併矩形) は成功時のみ。拡張ゼロの up は無書込 cleanup (bumpEdit / クリップ破棄を
|
|
28
|
+
* 発火させない)。展開が運ぶものは fillDown / fillRight と同じ (v0.8、分冊 08 §4 の fill 行 —
|
|
29
|
+
* `readFillSource` / `fillSourceEdit`): `getCellFormula` 宣言時は対象毎に相対シフトした数式ソース、
|
|
30
|
+
* 宣言済み側面 (format / validation / hyperlink / note) の全部、comments は決して運ばず、v0.7
|
|
31
|
+
* アダプタは `{ row, col, text }` ちょうど。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
35
|
+
import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
|
|
36
|
+
import { estimateFillCellCount, expandFill, fillSourceEdit, readFillSource } from "../clipboard/tiling.ts"
|
|
37
|
+
import type { AxisModel } from "../model/axisModel.ts"
|
|
38
|
+
import { type CellCoord, type CellRange, clampCoord, makeRange, type SpreadsheetOperation } from "../model/cellCoord.ts"
|
|
39
|
+
import type { CellEdit, EditRejection, SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
40
|
+
import { colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
|
|
41
|
+
import type { SelectionState } from "../selection/selectionState.ts"
|
|
42
|
+
import type { AutoScroller } from "../selection/useAutoScroller.ts"
|
|
43
|
+
import type { SelectionEngine } from "../selection/useSelectionEngine.ts"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Drag-arm threshold in px (R2(B) 実証欠陥の閉塞): NO box is built until the pointer has
|
|
47
|
+
* moved this far from the press point. Without it, a coordinate-identical capture echo (or
|
|
48
|
+
* 1-3px click jitter) reaches the preview rAF, and because the handle hitbox overhangs the
|
|
49
|
+
* corner into the NEXT track, the press point itself hit-tests outside the source — a
|
|
50
|
+
* zero-movement up would silently WRITE a 1-track fill. Excel writes nothing below the drag
|
|
51
|
+
* threshold.
|
|
52
|
+
* ドラッグ開始閾値 (px — R2(B) 実証欠陥の閉塞)。押下点からこの距離を動くまでボックスを構築しない。
|
|
53
|
+
* これが無いと座標同値のキャプチャエコー (や 1〜3px のクリックジッタ) がプレビュー rAF に届き、
|
|
54
|
+
* ハンドル hit 面が隅を次トラックへ張り出しているため押下点自体が ソース外に解決 — 無移動の
|
|
55
|
+
* up が 1 トラック fill を無言で書く。Excel はドラッグ閾値未満で何も書かない。
|
|
56
|
+
*/
|
|
57
|
+
const FILL_ARM_THRESHOLD = 4
|
|
58
|
+
|
|
59
|
+
type UseFillHandleParams = {
|
|
60
|
+
/** 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
|
+
zoomRef: RefObject<number>
|
|
62
|
+
/** Inert gate (§11 enableFillHandle opt-out / empty grid). / 不活性ゲート (§11 の opt-out / 空グリッド)。 */
|
|
63
|
+
enabled: boolean
|
|
64
|
+
/** The resident handle node (overlay-owned). / 常設ハンドルノード (overlay 所有)。 */
|
|
65
|
+
handleNodeRef: RefObject<HTMLElement | null>
|
|
66
|
+
bodyRef: RefObject<HTMLElement | null>
|
|
67
|
+
rowAxis: AxisModel
|
|
68
|
+
colAxis: AxisModel
|
|
69
|
+
handleRef: RefObject<VirtualGridHandle | null>
|
|
70
|
+
getSelection: () => SelectionState
|
|
71
|
+
setSelection: (next: SelectionState) => void
|
|
72
|
+
focusSink: () => void
|
|
73
|
+
adapter: SpreadsheetDataAdapter
|
|
74
|
+
/** The §6.9-2 write funnel: the transaction id on success (the edit version is bumped + the clip commit-discarded), null when the adapter rejected — the selection expands only on an id. / §6.9-2 の書込漏斗: 成功はトランザクション id (bumpEdit + クリップの commit 破棄)、アダプタ拒否は null — 選択の拡大は id のときだけ。 */
|
|
75
|
+
commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
|
|
76
|
+
maxOperationCells: number
|
|
77
|
+
onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
|
|
78
|
+
/** Preview writer relay (overlay-owned positioner + liveFillPreviewRef). / プレビュー writer リレー。 */
|
|
79
|
+
writeFillPreview: (range: CellRange | null) => void
|
|
80
|
+
/** §8.2 repaint edges (press after flag + cleanup end). / §8.2 の repaint エッジ。 */
|
|
81
|
+
repaintOverlay: () => void
|
|
82
|
+
/** Axis-fixed edge scrollers (writer + axis frozen at construction — §8.2). / 軸固定エッジスクローラー 2 台。 */
|
|
83
|
+
xAutoScroller: AutoScroller
|
|
84
|
+
yAutoScroller: AutoScroller
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wires the fill machine onto the handle node; returns the §7.4 composite surface.
|
|
89
|
+
* ハンドルノードへ fill 機械を結線し、§7.4 の合成面を返す。
|
|
90
|
+
*/
|
|
91
|
+
export const useFillHandle = (params: UseFillHandleParams): SelectionEngine => {
|
|
92
|
+
// paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
|
|
93
|
+
const paramsRef = useRef(params)
|
|
94
|
+
paramsRef.current = params
|
|
95
|
+
|
|
96
|
+
const draggingRef = useRef(false)
|
|
97
|
+
/** Source range captured at press (the fill origin). / press 時に捕捉するソースレンジ。 */
|
|
98
|
+
const sourceRangeRef = useRef<CellRange | null>(null)
|
|
99
|
+
/** The LAST previewed fill BOX (source-containing bounding rect; null = no extension) — the §8.2 confirm truth. / 最終プレビューの fill ボックス (ソース包含の合併矩形。null = 拡張なし — §8.2 の確定真実)。 */
|
|
100
|
+
const liveBandRef = useRef<CellRange | null>(null)
|
|
101
|
+
const bodyRectRef = useRef<DOMRect | null>(null)
|
|
102
|
+
const pointerRef = useRef({ clientX: 0, clientY: 0 })
|
|
103
|
+
/** Press point for the arming arithmetic (dx/dy origin — §8.2). / 武装算術の押下点 (dx / dy の原点)。 */
|
|
104
|
+
const pressPointRef = useRef({ clientX: 0, clientY: 0 })
|
|
105
|
+
/** Armed once the pointer moved >= FILL_ARM_THRESHOLD from the press (box building enabled; stays armed). / 押下から閾値以上動いたら armed (ボックス構築の解禁 — 以後維持)。 */
|
|
106
|
+
const dragArmedRef = useRef(false)
|
|
107
|
+
/**
|
|
108
|
+
* Grab-offset normalization (R2(B) — the Excel drag-back-to-source-cancel contract): the
|
|
109
|
+
* press grabbed the HANDLE, whose hitbox straddles the corner boundary, so the raw press
|
|
110
|
+
* pixel hit-tests into the adjacent track and a return-to-press-point release could never
|
|
111
|
+
* collapse the box to the source (null). The offset re-anchors every hover hit test to "the pointer
|
|
112
|
+
* grabbed the corner's last inside pixel": effective = current − (press − (corner − 1)).
|
|
113
|
+
* grab オフセット正規化 (R2(B) — Excel の「ソースへ戻すと取り消し」契約)。押下はハンドル =
|
|
114
|
+
* 隅境界を跨ぐ hit 面を掴んでおり、生の押下 px は隣トラックに解決するため押下点へ戻した
|
|
115
|
+
* リリースがボックス null へ畳めない。オフセットで全 hover ヒットテストを「隅の内側最終 px を
|
|
116
|
+
* 掴んだ」座標系へ再アンカーする。
|
|
117
|
+
*/
|
|
118
|
+
const grabOffsetRef = useRef({ dx: 0, dy: 0 })
|
|
119
|
+
const capturedPointerRef = useRef<number | null>(null)
|
|
120
|
+
/** `buttons` reported by the press — the primary bit vanishing on a captured move is the chorded primary release (R4). / 押下時の `buttons` (主ビットの消失 = chord 中の主ボタン解放)。 */
|
|
121
|
+
const pressButtonsRef = useRef(0)
|
|
122
|
+
const moveRafRef = useRef<number | null>(null)
|
|
123
|
+
const windowListenersRef = useRef<{ move: (e: PointerEvent) => void; up: (e: PointerEvent) => void } | null>(null)
|
|
124
|
+
/**
|
|
125
|
+
* Whether an edge scroller is running (started on the FIRST genuine pointermove, NOT at
|
|
126
|
+
* press — a press-started tick would scroll/grow the box under a STATIONARY hold and the
|
|
127
|
+
* zero-movement up would silently WRITE a fill: the §8.2 zero-extension no-write pin is
|
|
128
|
+
* structurally bypassed by scroll-grown boxes. Excel never scrolls on a stationary handle
|
|
129
|
+
* hold. R1(B) 実証欠陥の閉塞).
|
|
130
|
+
* エッジスクローラー稼働中フラグ (起動は press でなく**最初の実 pointermove** — press 起動の
|
|
131
|
+
* tick は静止保持のままスクロールでボックスを育て、無移動 up が無言でデータを書く: §8.2 の
|
|
132
|
+
* 拡張ゼロ無書込ピンがスクロール成長ボックスで構造的に迂回される。Excel は静止保持でスクロール
|
|
133
|
+
* しない。R1(B) 実証欠陥の閉塞)。
|
|
134
|
+
*/
|
|
135
|
+
const scrollerActiveRef = useRef(false)
|
|
136
|
+
|
|
137
|
+
const isOwnPointer = useCallback((event: PointerEvent): boolean => (capturedPointerRef.current !== null ? event.pointerId === capturedPointerRef.current : event.pointerType === "mouse"), [])
|
|
138
|
+
|
|
139
|
+
/** Engine-style hit test (press-cached rect + freshness hierarchy). / エンジン同型のヒットテスト (press 計測 rect + 鮮度序列)。 */
|
|
140
|
+
const hitTest = useCallback((clientX: number, clientY: number): CellCoord | null => {
|
|
141
|
+
const { handleRef, rowAxis, colAxis } = paramsRef.current
|
|
142
|
+
const handle = handleRef.current
|
|
143
|
+
const rect = bodyRectRef.current
|
|
144
|
+
if (handle === null || rect === null) {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
// 両軸ともハンドルの同期読み (§7-4 — fill プレビューの着地列がドラッグ中も最新 hx で決まる)
|
|
148
|
+
const { x: hx, y: vy } = handle.getScrollPosition()
|
|
149
|
+
if (vy < 0 || hx < 0 || rowAxis.totalSize() === 0 || colAxis.totalSize() === 0) {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
// ズーム境界 (v0.3): client 差分を z で 1 回だけ除算して論理 px へ。
|
|
153
|
+
// 列は凍結パーティション逆写像 (v0.5 計画 §2)
|
|
154
|
+
const z = paramsRef.current.zoomRef.current
|
|
155
|
+
const frozen = handle.getFrozenSize()
|
|
156
|
+
return clampCoord({ row: viewportYToRow(rowAxis, (clientY - rect.top) / z, vy, frozen.height), col: viewportXToCol(colAxis, (clientX - rect.left) / z, hx, frozen.width) }, rowAxis.count, colAxis.count)
|
|
157
|
+
}, [])
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The single box/preview construction point shared by the move rAF and BOTH autoscroller
|
|
161
|
+
* ticks: the box is the bounding rect of source ∪ hover (v0.3 diagonal fill — one
|
|
162
|
+
* derivation for single-axis and diagonal drags), stored as the confirm truth
|
|
163
|
+
* (liveBandRef — box written ⇔ preview drawn, atomically in this closure) and returned as
|
|
164
|
+
* the preview rect. A hover resolving inside the source = no extension (§8.2).
|
|
165
|
+
* move rAF と両 tick が共有するボックス / プレビューの単一構築点。ボックス = ソース ∪
|
|
166
|
+
* hover のバウンディング矩形 (v0.3 の斜めフィル — 単軸も斜めも同一導出)。確定真実
|
|
167
|
+
* (liveBandRef — ボックスの記録とプレビュー描画はこのクロージャで原子的) を更新して
|
|
168
|
+
* プレビュー矩形を返す。ソース内向き hover は拡張なし (§8.2)。
|
|
169
|
+
*/
|
|
170
|
+
const buildFillFrame = useCallback((hover: CellCoord): CellRange => {
|
|
171
|
+
const source = sourceRangeRef.current
|
|
172
|
+
if (source === null) {
|
|
173
|
+
return makeRange(hover, hover) // 構造的に不到達 (press で必ず捕捉) — 型充足のみ
|
|
174
|
+
}
|
|
175
|
+
// ボックス = ソース ∪ hover のバウンディング矩形 (v0.3 の斜めフィル — 単軸も斜めも同一
|
|
176
|
+
// 導出。hover がソース内へ解決する間はボックス = ソース = 拡張なし)
|
|
177
|
+
const box = makeRange({ row: Math.min(source.startRow, hover.row), col: Math.min(source.startCol, hover.col) }, { row: Math.max(source.endRow, hover.row), col: Math.max(source.endCol, hover.col) })
|
|
178
|
+
const extended = box.startRow !== source.startRow || box.endRow !== source.endRow || box.startCol !== source.startCol || box.endCol !== source.endCol
|
|
179
|
+
liveBandRef.current = extended ? box : null
|
|
180
|
+
return extended ? box : source
|
|
181
|
+
}, [])
|
|
182
|
+
|
|
183
|
+
/** Idempotent cleanup (§8.2 — preview off, both scrollers stopped, cleanup repaint edge). / 冪等な後始末。 */
|
|
184
|
+
const cleanupDrag = useCallback(() => {
|
|
185
|
+
const p = paramsRef.current
|
|
186
|
+
draggingRef.current = false
|
|
187
|
+
sourceRangeRef.current = null
|
|
188
|
+
liveBandRef.current = null
|
|
189
|
+
bodyRectRef.current = null
|
|
190
|
+
if (moveRafRef.current !== null) {
|
|
191
|
+
cancelAnimationFrame(moveRafRef.current)
|
|
192
|
+
moveRafRef.current = null
|
|
193
|
+
}
|
|
194
|
+
p.xAutoScroller.stop()
|
|
195
|
+
p.yAutoScroller.stop()
|
|
196
|
+
scrollerActiveRef.current = false
|
|
197
|
+
dragArmedRef.current = false
|
|
198
|
+
grabOffsetRef.current = { dx: 0, dy: 0 }
|
|
199
|
+
p.writeFillPreview(null)
|
|
200
|
+
const listeners = windowListenersRef.current
|
|
201
|
+
if (listeners !== null) {
|
|
202
|
+
window.removeEventListener("pointermove", listeners.move)
|
|
203
|
+
window.removeEventListener("pointerup", listeners.up)
|
|
204
|
+
windowListenersRef.current = null
|
|
205
|
+
}
|
|
206
|
+
const node = paramsRef.current.handleNodeRef.current
|
|
207
|
+
if (node !== null && capturedPointerRef.current !== null && node.hasPointerCapture?.(capturedPointerRef.current)) {
|
|
208
|
+
node.releasePointerCapture(capturedPointerRef.current)
|
|
209
|
+
}
|
|
210
|
+
capturedPointerRef.current = null
|
|
211
|
+
p.repaintOverlay()
|
|
212
|
+
}, [])
|
|
213
|
+
|
|
214
|
+
/** The up confirm: box = last preview; estimate gate; ONE commitEdits; selection on success. / up 確定 (最終プレビューボックス・見積ゲート・commitEdits 1 回・成功時のみ選択更新)。 */
|
|
215
|
+
const confirmFill = useCallback(() => {
|
|
216
|
+
const p = paramsRef.current
|
|
217
|
+
const source = sourceRangeRef.current
|
|
218
|
+
const box = liveBandRef.current
|
|
219
|
+
cleanupDrag()
|
|
220
|
+
if (source === null || box === null) {
|
|
221
|
+
// 拡張ゼロ (ハンドル単クリック / 内向き) は無書込 cleanup — 空 edits の commit は
|
|
222
|
+
// ラッパー経由で bumpEdit + クリップ破棄を発火してしまう (§8.2 の明文ピン)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
// 上限検査は展開前かつソース読み出し前 (ADR-12 §8.3)。count = 領域 (ボックス − ソース) + ソース読み課金の和
|
|
226
|
+
const count = estimateFillCellCount(source, box)
|
|
227
|
+
if (count > p.maxOperationCells) {
|
|
228
|
+
p.onOperationRejected?.("fill", count, "limit")
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
// 展開は fillDown / fillRight と同じソース毎規則 (分冊 08 §4 の fill 行): 種 (数式ソースか正準
|
|
232
|
+
// 文字列) + 宣言済み側面を 1 回読み、対象毎に数式を相対シフト — v0.7 アダプタは { row, col, text } ちょうど
|
|
233
|
+
const edits = expandFill((row, col) => readFillSource(p.adapter, row, col), source, box, fillSourceEdit)
|
|
234
|
+
if (p.commitEdits(edits, { source: "fill" }) === null) {
|
|
235
|
+
return // 読み取り専用 adapter の throw (null) — 「埋まっていないセルへ選択だけ拡大」を作らない (§8.2)
|
|
236
|
+
}
|
|
237
|
+
// 選択更新はボックス 1 レンジ — ボックスはソースを構造的に包含する (buildFillFrame の
|
|
238
|
+
// 合併矩形 + assertFillBox の封鎖) ため v0.2 帯時代の min/max 合併算術は恒等で不要。
|
|
239
|
+
// anchor / active 不動 (両者はソース内 = ボックス内で両不変条件が自明に維持される)
|
|
240
|
+
const state = p.getSelection()
|
|
241
|
+
p.setSelection({ ranges: [box], anchor: state.anchor, active: state.active })
|
|
242
|
+
}, [cleanupDrag])
|
|
243
|
+
|
|
244
|
+
const { enabled, handleNodeRef } = params
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
const node = handleNodeRef.current
|
|
247
|
+
if (!enabled || node === null) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const onWindowPointerMove = (event: PointerEvent) => {
|
|
252
|
+
applyWindowPointerMove(event)
|
|
253
|
+
// chord 中の主ボタン解放 (R4 是正 — 解放検出の述語は選択機械と同型): 主ビットを失った own move を
|
|
254
|
+
// pointerup と同じ確定経路へ流す。確定ボックスは最終プレビュー (直前の rAF が描いた帯) — この move の
|
|
255
|
+
// 位置は反映しない (§8.2 の「up で再ヒットテストしない」規約と同一。R5 是正で明記)
|
|
256
|
+
if (draggingRef.current && isOwnPointer(event) && (pressButtonsRef.current & 1) !== 0 && (event.buttons & 1) === 0) {
|
|
257
|
+
confirmFill()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const applyWindowPointerMove = (event: PointerEvent) => {
|
|
262
|
+
if (!(draggingRef.current && isOwnPointer(event))) {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
pointerRef.current = { clientX: event.clientX, clientY: event.clientY }
|
|
266
|
+
const dx = Math.abs(event.clientX - pressPointRef.current.clientX)
|
|
267
|
+
const dy = Math.abs(event.clientY - pressPointRef.current.clientY)
|
|
268
|
+
// ドラッグ武装 (R2(B)): 押下点から閾値以上の実移動で解禁し、以後維持する。座標同値の
|
|
269
|
+
// 合成 move もジッタも武装させない — ボックス構築とスクローラーの両ドアを 1 述語で守る
|
|
270
|
+
if (!dragArmedRef.current && Math.max(dx, dy) >= FILL_ARM_THRESHOLD) {
|
|
271
|
+
dragArmedRef.current = true
|
|
272
|
+
}
|
|
273
|
+
if (!dragArmedRef.current) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
// エッジスクローラーは武装後に両軸とも起動する (v0.3 の斜めフィル — ボックスは hover
|
|
277
|
+
// 由来のため軸選択は不要。各スクローラーは自軸の端ゾーンでのみ tick する)。押下起動は
|
|
278
|
+
// 静止保持の無言書込に化ける — scrollerActiveRef の docstring 参照。tick の座標読みにも
|
|
279
|
+
// grab オフセットを適用する (ボックス構築系の単一正規化)
|
|
280
|
+
if (!scrollerActiveRef.current) {
|
|
281
|
+
const p = paramsRef.current
|
|
282
|
+
const context = {
|
|
283
|
+
getPointerPosition: () => ({ clientX: pointerRef.current.clientX - grabOffsetRef.current.dx, clientY: pointerRef.current.clientY - grabOffsetRef.current.dy }),
|
|
284
|
+
buildRange: buildFillFrame,
|
|
285
|
+
// 凍結ホームテリトリー規則 (R5 是正): ソース隅列が凍結なら帯内保持で左駆動しない
|
|
286
|
+
anchorCol: sourceRangeRef.current?.endCol,
|
|
287
|
+
anchorRow: sourceRangeRef.current?.endRow,
|
|
288
|
+
}
|
|
289
|
+
p.xAutoScroller.start(context)
|
|
290
|
+
p.yAutoScroller.start(context)
|
|
291
|
+
scrollerActiveRef.current = true
|
|
292
|
+
}
|
|
293
|
+
// プレビューは rAF 合流の直書き (§8.2) — hover は grab 正規化座標で解決する
|
|
294
|
+
if (moveRafRef.current === null) {
|
|
295
|
+
moveRafRef.current = requestAnimationFrame(() => {
|
|
296
|
+
moveRafRef.current = null
|
|
297
|
+
if (!(draggingRef.current && dragArmedRef.current)) {
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
const hover = hitTest(pointerRef.current.clientX - grabOffsetRef.current.dx, pointerRef.current.clientY - grabOffsetRef.current.dy)
|
|
301
|
+
if (hover !== null) {
|
|
302
|
+
paramsRef.current.writeFillPreview(buildFillFrame(hover))
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const onWindowPointerUp = (event: PointerEvent) => {
|
|
309
|
+
// 主ボタンのリリースのみ確定 (他機械と同じ閉塞)
|
|
310
|
+
if (event.button !== 0 || !(draggingRef.current && isOwnPointer(event))) {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
confirmFill()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
317
|
+
const p = paramsRef.current
|
|
318
|
+
if (event.pointerType !== "mouse") {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (event.button !== 0) {
|
|
322
|
+
// 非主ボタン: 既定のフォーカス移動だけ止めて素通し (全押下面共通 — R2 是正)
|
|
323
|
+
event.preventDefault()
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
// 編集中 requestCommit ゲートは置かない (§8.2): ハンドルは編集中非表示で押下が構造的に
|
|
327
|
+
// 不到達 — 観測不能ガード禁止規律 (§8.1) に従い書かない
|
|
328
|
+
event.preventDefault()
|
|
329
|
+
event.stopPropagation()
|
|
330
|
+
p.focusSink()
|
|
331
|
+
const state = p.getSelection()
|
|
332
|
+
const source = state.ranges[state.ranges.length - 1]
|
|
333
|
+
sourceRangeRef.current = source
|
|
334
|
+
liveBandRef.current = null
|
|
335
|
+
dragArmedRef.current = false
|
|
336
|
+
// 押下時自己修復 (R6 是正 — セル / ヘッダー機械と同規約): 宙吊り fill ジェスチャが
|
|
337
|
+
// 残した両軸スクローラーと stale active フラグを毎押下で掃除する (旧 anchorCol
|
|
338
|
+
// コンテキストの生存遮断)
|
|
339
|
+
p.xAutoScroller.stop()
|
|
340
|
+
p.yAutoScroller.stop()
|
|
341
|
+
scrollerActiveRef.current = false
|
|
342
|
+
pressPointRef.current = { clientX: event.clientX, clientY: event.clientY }
|
|
343
|
+
pointerRef.current = pressPointRef.current
|
|
344
|
+
pressButtonsRef.current = event.buttons
|
|
345
|
+
// 自機フラグを同期セットしてから義務の press repaint (§8.1 — 凍結が成立した状態で
|
|
346
|
+
// 可視条件が評価される。順序を崩すと押下即 display:none → lostpointercapture 死)
|
|
347
|
+
draggingRef.current = true
|
|
348
|
+
p.repaintOverlay()
|
|
349
|
+
// rect 規律 (§8.2): fill 機械自身のヒットテスト rect は押下 1 回計測をジェスチャ共有
|
|
350
|
+
bodyRectRef.current = p.bodyRef.current?.getBoundingClientRect() ?? null
|
|
351
|
+
// grab オフセット (R2(B)): 押下 px を「隅の内側最終 px を掴んだ」座標系へ再アンカー
|
|
352
|
+
// (grabOffsetRef の docstring 参照)。rect / スクロール未計測時は素の座標 (オフセット 0)
|
|
353
|
+
const rect = bodyRectRef.current
|
|
354
|
+
const pressPosition = p.handleRef.current?.getScrollPosition() ?? { x: -1, y: -1 }
|
|
355
|
+
const pressVy = pressPosition.y
|
|
356
|
+
if (rect !== null && pressVy >= 0 && pressPosition.x >= 0) {
|
|
357
|
+
const hx = pressPosition.x
|
|
358
|
+
// ズーム境界 (v0.3): 論理オフセットは ×z で視覚 px へ写してから client と比較する
|
|
359
|
+
const zPress = p.zoomRef.current
|
|
360
|
+
// 隅の x は凍結パーティション順写像 (v0.5 計画 §2 — repaintOverlay のハンドル描画と
|
|
361
|
+
// 同式)。無条件 −hx だと凍結 endCol の押下で dx が hx·z ぶん膨らみ、以後の全ヒット
|
|
362
|
+
// テストが左へ hx ずれる (誤列への fill 確定 + 暴走左オートスクロール)
|
|
363
|
+
const pressFrozen = p.handleRef.current?.getFrozenSize() ?? { cols: 0, width: 0, rows: 0, height: 0 }
|
|
364
|
+
const cornerViewportX = colToViewportX(p.colAxis, source.endCol, hx, pressFrozen.cols) + p.colAxis.sizeOf(source.endCol)
|
|
365
|
+
// 隅 y も凍結パーティション順写像 (v0.6 — repaintOverlay のハンドル描画と同式)
|
|
366
|
+
const cornerViewportY = rowToViewportY(p.rowAxis, source.endRow, pressVy, pressFrozen.rows) + p.rowAxis.sizeOf(source.endRow)
|
|
367
|
+
// 内側 1px は論理 px の概念 (「隅の内側最終 px」) — 視覚 1px のままだと z < 1 で
|
|
368
|
+
// 1/z 論理 px に膨らみ、極小トラック (< 1/z px) では隣トラックへ再アンカーされて
|
|
369
|
+
// 押下点回帰キャンセルが 1 トラック fill に化ける。×zPress で論理 1px に固定。
|
|
370
|
+
// スクロール帯の隅が W_F との 1px 未満の隙間に立つ場合 (小数 hx で到達可能)、
|
|
371
|
+
// 内側 px が凍結帯へ潜って全ヒットテストが F−1 列へ解決する — 帯下限 W_F で
|
|
372
|
+
// クランプ (R2 是正。凍結 endCol と W_F = 0 はクランプ不要 — 後者は v0.4 恒等)
|
|
373
|
+
const insideRawX = cornerViewportX - 1
|
|
374
|
+
const insideViewportX = source.endCol >= pressFrozen.cols && pressFrozen.width > 0 ? Math.max(insideRawX, pressFrozen.width) : insideRawX
|
|
375
|
+
// 内側 py も帯下限 H_F でクランプ (v0.6 — 横の insideViewportX と対称。小数 vy の隅
|
|
376
|
+
// すきまでスクロール帯 endRow が凍結帯へ潜る dy ギャップを閉塞)
|
|
377
|
+
const insideRawY = cornerViewportY - 1
|
|
378
|
+
const insideViewportY = source.endRow >= pressFrozen.rows && pressFrozen.height > 0 ? Math.max(insideRawY, pressFrozen.height) : insideRawY
|
|
379
|
+
grabOffsetRef.current = { dx: event.clientX - (rect.left + insideViewportX * zPress), dy: event.clientY - (rect.top + insideViewportY * zPress) }
|
|
380
|
+
} else {
|
|
381
|
+
grabOffsetRef.current = { dx: 0, dy: 0 }
|
|
382
|
+
}
|
|
383
|
+
capturedPointerRef.current = event.pointerId
|
|
384
|
+
try {
|
|
385
|
+
// jsdom は InvalidPointerId を投げうる — 失敗時は mouse フォールバック (両機械と同型)
|
|
386
|
+
node.setPointerCapture(event.pointerId)
|
|
387
|
+
} catch {
|
|
388
|
+
capturedPointerRef.current = null
|
|
389
|
+
}
|
|
390
|
+
const move = onWindowPointerMove
|
|
391
|
+
const up = onWindowPointerUp
|
|
392
|
+
windowListenersRef.current = { move, up }
|
|
393
|
+
window.addEventListener("pointermove", move)
|
|
394
|
+
window.addEventListener("pointerup", up)
|
|
395
|
+
// スクローラーは押下では起動しない (最初の実 pointermove で起動 — scrollerActiveRef
|
|
396
|
+
// の docstring 参照。押下直後のプレビューはソース矩形 = 拡張ゼロ)
|
|
397
|
+
p.writeFillPreview(sourceRangeRef.current)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const onLostPointerCapture = (event: PointerEvent) => {
|
|
401
|
+
// capture 喪失は中断 (pointerId 照合つき — 選択未変更のため復元不要 §8.2)
|
|
402
|
+
if (!draggingRef.current || capturedPointerRef.current === null || event.pointerId !== capturedPointerRef.current) {
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
cleanupDrag()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
node.addEventListener("pointerdown", onPointerDown)
|
|
409
|
+
node.addEventListener("lostpointercapture", onLostPointerCapture)
|
|
410
|
+
return () => {
|
|
411
|
+
node.removeEventListener("pointerdown", onPointerDown)
|
|
412
|
+
node.removeEventListener("lostpointercapture", onLostPointerCapture)
|
|
413
|
+
cleanupDrag()
|
|
414
|
+
}
|
|
415
|
+
}, [enabled, handleNodeRef, isOwnPointer, hitTest, buildFillFrame, cleanupDrag, confirmFill])
|
|
416
|
+
|
|
417
|
+
return useMemo(
|
|
418
|
+
() => ({
|
|
419
|
+
isDragging: () => draggingRef.current,
|
|
420
|
+
// fill のプレビューは専用ノード — 選択矩形の減算スタイルとは無関係 (常に加算)
|
|
421
|
+
getDragMode: (): "add" | "subtract" => "add",
|
|
422
|
+
// ドラッグ中は選択もデータも書いていない — 中断 = 消灯 + ref リセットのみ
|
|
423
|
+
abortDrag: () => {
|
|
424
|
+
if (draggingRef.current) {
|
|
425
|
+
cleanupDrag()
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
cancelDrag: cleanupDrag,
|
|
429
|
+
}),
|
|
430
|
+
[cleanupDrag],
|
|
431
|
+
)
|
|
432
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Default comment thread popover (v0.8 §4.1.16): the existing thread (author,
|
|
3
|
+
* timestamp, text — every entry keeps its consumer id) followed by a reply textarea and a Post
|
|
4
|
+
* button. Posting commits the WHOLE thread `[...current (with ids), { text }]` — the package never
|
|
5
|
+
* mints an id, author or clock (design v5 "Comments"); a blank reply posts nothing. Keys:
|
|
6
|
+
* Ctrl+Enter posts (retries after a rejection), Escape closes. Leaving by focus closes without
|
|
7
|
+
* posting. DOM hooks: `data-aqsp-popover="comment"`, `data-aqsp-popover-title`,
|
|
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`.
|
|
10
|
+
*
|
|
11
|
+
* 既定のコメントスレッド (v0.8 §4.1.16): 既存スレッド (著者 / 時刻 / 本文 — 各件は consumer の id を
|
|
12
|
+
* 保つ) + 返信 textarea + 投稿ボタン。投稿はスレッド全体 `[...current (id つき), { text }]` を
|
|
13
|
+
* commit する — id / 著者 / 時計はパッケージが採番しない (設計 v5「Comments」)。空の返信は投稿
|
|
14
|
+
* しない。キー: Ctrl+Enter 投稿 (拒否後は再試行)、Escape で閉じる。フォーカス離脱は投稿せず閉じる。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
|
|
18
|
+
import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
|
|
19
|
+
import type { CommentPopoverContext } from "./usePopovers.ts"
|
|
20
|
+
|
|
21
|
+
export type CommentPopoverProps = { ctx: CommentPopoverContext; labels: SpreadsheetLabels }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders the comment thread and the reply form for one cell.
|
|
25
|
+
* 1 セルのコメントスレッドと返信フォームを描画する。
|
|
26
|
+
*/
|
|
27
|
+
export const CommentPopover = ({ ctx, labels }: CommentPopoverProps): ReactNode => {
|
|
28
|
+
const [reply, setReply] = useState("")
|
|
29
|
+
const titleId = useId()
|
|
30
|
+
const settlingRef = useRef(false)
|
|
31
|
+
|
|
32
|
+
const settle = (action: () => void): void => {
|
|
33
|
+
settlingRef.current = true
|
|
34
|
+
try {
|
|
35
|
+
action()
|
|
36
|
+
} finally {
|
|
37
|
+
settlingRef.current = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const post = (): void => {
|
|
41
|
+
// 空 (空白のみ) の返信は投稿しない
|
|
42
|
+
if (reply.trim() === "") {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
// 既存は id つきのまま verbatim、新規は id なし — 採番は consumer
|
|
46
|
+
settle(() => ctx.commit([...ctx.current, { text: reply }]))
|
|
47
|
+
}
|
|
48
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
49
|
+
if (event.key === "Escape") {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
settle(ctx.close)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
post()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
|
60
|
+
if (settlingRef.current || (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
// 拒否状態は開いたまま (明示操作待ち)、それ以外の離脱は閉じる
|
|
64
|
+
if (ctx.rejected) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
settle(ctx.close)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="aqsp-popover-comment" data-aqsp-popover="comment" data-aqsp-popover-rejected={ctx.rejected ? "" : undefined} role="dialog" aria-labelledby={titleId} onBlur={onBlur}>
|
|
72
|
+
<div className="aqsp-popover-title" data-aqsp-popover-title="" id={titleId}>
|
|
73
|
+
{resolveLabel(labels, "commentTitle")}
|
|
74
|
+
<span className="aqsp-popover-count" data-aqsp-comment-count="">
|
|
75
|
+
{resolveLabel(labels, "commentCount", ctx.current.length)}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<ul className="aqsp-popover-thread" data-aqsp-comment-thread="">
|
|
79
|
+
{ctx.current.map((comment) => (
|
|
80
|
+
<li key={comment.id} className="aqsp-popover-comment-item" data-aqsp-comment-id={comment.id}>
|
|
81
|
+
<span className="aqsp-popover-comment-author">{comment.author}</span>
|
|
82
|
+
<time className="aqsp-popover-comment-time" dateTime={comment.createdAt}>
|
|
83
|
+
{comment.createdAt}
|
|
84
|
+
</time>
|
|
85
|
+
<p className="aqsp-popover-comment-text">{comment.text}</p>
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
<textarea
|
|
90
|
+
className="aqsp-popover-textarea"
|
|
91
|
+
data-aqsp-comment-input=""
|
|
92
|
+
data-aqsp-popover-autofocus=""
|
|
93
|
+
value={reply}
|
|
94
|
+
aria-label={resolveLabel(labels, "commentReply")}
|
|
95
|
+
aria-invalid={ctx.rejected ? true : undefined}
|
|
96
|
+
onChange={(event) => setReply(event.currentTarget.value)}
|
|
97
|
+
onKeyDown={onKeyDown}
|
|
98
|
+
/>
|
|
99
|
+
<div className="aqsp-popover-actions">
|
|
100
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-post="" onClick={post}>
|
|
101
|
+
{resolveLabel(labels, "commentPost")}
|
|
102
|
+
</button>
|
|
103
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
|
|
104
|
+
×
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Default hyperlink editor popover (v0.8 §4.1.16): text + URL fields, Save, an Open
|
|
3
|
+
* button only when the host declared `onHyperlinkOpen` (`ctx.canOpen`) and the cell has a link,
|
|
4
|
+
* and Close. The URL is validated through `normalizeHyperlink` on every keystroke: an
|
|
5
|
+
* unnormalisable non-empty value marks the popover invalid (`data-aqsp-link-invalid`,
|
|
6
|
+
* `aria-invalid`) and Save is refused — the commit only ever carries a normalised href or null
|
|
7
|
+
* (empty field = clear the link). Saving an empty text draft with a valid URL commits the href
|
|
8
|
+
* as the display text (Sheets / Excel parity — a link on an empty cell must stay visible).
|
|
9
|
+
* Keys: Enter saves, Escape closes. Leaving by focus closes
|
|
10
|
+
* without saving. DOM hooks: `data-aqsp-popover="link"`, `data-aqsp-popover-title`,
|
|
11
|
+
* `data-aqsp-link-text`, `data-aqsp-link-url`, `data-aqsp-popover-save`, `data-aqsp-link-open`
|
|
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`.
|
|
14
|
+
*
|
|
15
|
+
* 既定のハイパーリンクエディタ (v0.8 §4.1.16): text + URL 欄、保存、Open (ホストが onHyperlinkOpen を
|
|
16
|
+
* 宣言し (`ctx.canOpen`) セルにリンクがあるときだけ)、閉じる。URL は打鍵毎に normalizeHyperlink で
|
|
17
|
+
* 検証し、正規化不能な非空値は不正表示 (`data-aqsp-link-invalid` / `aria-invalid`) で保存を拒む —
|
|
18
|
+
* commit は常に正規化済み href か null (空欄 = リンク消去) だけを運ぶ。空の text 下書き + 有効 URL の
|
|
19
|
+
* 保存は href を表示テキストとして commit する (Sheets / Excel 同等 — 空セルのリンクを見えないままに
|
|
20
|
+
* しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
|
|
24
|
+
import { normalizeHyperlink } from "../model/cellAnnotations.ts"
|
|
25
|
+
import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
|
|
26
|
+
import type { LinkPopoverContext } from "./usePopovers.ts"
|
|
27
|
+
|
|
28
|
+
export type LinkPopoverProps = { ctx: LinkPopoverContext; labels: SpreadsheetLabels }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders the hyperlink editor for one cell.
|
|
32
|
+
* 1 セルのハイパーリンクエディタを描画する。
|
|
33
|
+
*/
|
|
34
|
+
export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
|
|
35
|
+
const [text, setText] = useState(ctx.current.text)
|
|
36
|
+
const [url, setUrl] = useState(ctx.current.url === undefined ? "" : ctx.current.url)
|
|
37
|
+
const titleId = useId()
|
|
38
|
+
const settlingRef = useRef(false)
|
|
39
|
+
// 空欄はリンク消去、それ以外は正規化 (null = 不正)
|
|
40
|
+
const normalized = url.trim() === "" ? null : normalizeHyperlink(url)
|
|
41
|
+
const invalid = url.trim() !== "" && normalized === null
|
|
42
|
+
|
|
43
|
+
const settle = (action: () => void): void => {
|
|
44
|
+
settlingRef.current = true
|
|
45
|
+
try {
|
|
46
|
+
action()
|
|
47
|
+
} finally {
|
|
48
|
+
settlingRef.current = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const save = (): void => {
|
|
52
|
+
// 不正 URL は保存しない (表示で知らせる — 代替値は作らない)
|
|
53
|
+
if (invalid) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
// 空テキスト + 有効 URL は href を表示テキストへ写す (Sheets / Excel 同等 — 見えないリンクを作らない)
|
|
57
|
+
settle(() => ctx.commit({ text: text === "" && normalized !== null ? normalized : text, url: normalized }))
|
|
58
|
+
}
|
|
59
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
|
60
|
+
if (event.key === "Escape") {
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
settle(ctx.close)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (event.key === "Enter" && !event.altKey && !event.shiftKey) {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
save()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
|
71
|
+
if (settlingRef.current || (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (ctx.rejected) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
settle(ctx.close)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="aqsp-popover-link" data-aqsp-popover="link" data-aqsp-popover-rejected={ctx.rejected ? "" : undefined} data-aqsp-link-invalid={invalid ? "" : undefined} role="dialog" aria-labelledby={titleId} onBlur={onBlur}>
|
|
82
|
+
<div className="aqsp-popover-title" data-aqsp-popover-title="" id={titleId}>
|
|
83
|
+
{resolveLabel(labels, "linkTitle")}
|
|
84
|
+
</div>
|
|
85
|
+
<label className="aqsp-popover-field">
|
|
86
|
+
<span className="aqsp-popover-field-label">{resolveLabel(labels, "linkTextField")}</span>
|
|
87
|
+
<input className="aqsp-popover-input" data-aqsp-link-text="" type="text" value={text} onChange={(event) => setText(event.currentTarget.value)} onKeyDown={onKeyDown} />
|
|
88
|
+
</label>
|
|
89
|
+
<label className="aqsp-popover-field">
|
|
90
|
+
<span className="aqsp-popover-field-label">{resolveLabel(labels, "linkUrlField")}</span>
|
|
91
|
+
<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
|
+
</label>
|
|
93
|
+
<div className="aqsp-popover-actions">
|
|
94
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid} onClick={save}>
|
|
95
|
+
{resolveLabel(labels, "linkSave")}
|
|
96
|
+
</button>
|
|
97
|
+
{ctx.canOpen && ctx.current.url !== undefined ? (
|
|
98
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-link-open="" onClick={ctx.open}>
|
|
99
|
+
{resolveLabel(labels, "linkOpen")}
|
|
100
|
+
</button>
|
|
101
|
+
) : null}
|
|
102
|
+
<button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
|
|
103
|
+
×
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|