@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,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Popover state machine (v0.8 §1.8 / §4.1.15 — ADR-15 interactive layer): the
|
|
3
|
+
* six popover kinds (note editor, hover note tip, comment thread, hyperlink editor, validation
|
|
4
|
+
* list picker, validation message) over the TWO resident anchors the overlay positions
|
|
5
|
+
* (`interactive` = note / comment / link / list / message, `tip` = note-tip). The hook owns:
|
|
6
|
+
* the layer derivation `layerEnabled = ∃K: popovers[K] !== false ∧ capability(K)` (a v0.7
|
|
7
|
+
* adapter with default props mounts NOTHING), the open / close / rejected transitions (a
|
|
8
|
+
* rejected `annotate` commit keeps the popover open with `rejected: true`; Ctrl+Enter retries;
|
|
9
|
+
* Escape in the rejected state closes without saving — both live in the default components),
|
|
10
|
+
* the commit gate before an interactive kind opens (`isEditing() && !requestCommit()` ⇒ abort),
|
|
11
|
+
* the focus return on close (`focusSink({ preventScroll: true })` + `rearmSink` when idle), the
|
|
12
|
+
* priority "open editor > validation message", the hover note tip (rAF-coalesced hit test on
|
|
13
|
+
* the body — the client point is queued and resolved once per frame —, `shouldShowNoteTip` —
|
|
14
|
+
* never while any drag incl. the reference drag is in flight, 150 ms leave grace, hoverable — a
|
|
15
|
+
* body `pointerleave` that is the other face of entering the tip frame starts no grace —,
|
|
16
|
+
* Escape chain step 0 `hideTip` — a re-show needs a cell CHANGE
|
|
17
|
+
* (the predicate's prev / next), so the tip stays hidden while the pointer rests on the cell —,
|
|
18
|
+
* sink `aria-describedby` while the hovered cell is active — an invariant, not an edge: it is
|
|
19
|
+
* re-evaluated by `syncTipAriaDescribedBy` on tip show / hide AND on every active-cell change
|
|
20
|
+
* (the root's active effect calls it), so arrowing away unbinds and arrowing onto the hovered
|
|
21
|
+
* cell binds —) and the
|
|
22
|
+
* IME guard the layer applies to every popover keydown (`isCompositionKeydown`). No DOM is
|
|
23
|
+
* rendered here — PopoverLayer.tsx renders from `context` / `tipContext` (lazy mount).
|
|
24
|
+
*
|
|
25
|
+
* ポップオーバー状態機械 (v0.8 §1.8 / §4.1.15 — ADR-15 対話層)。6 種 (note エディタ / ホバー
|
|
26
|
+
* note tip / コメントスレッド / ハイパーリンクエディタ / 検証リスト / 検証メッセージ) を overlay が
|
|
27
|
+
* 配置する 2 つの常設アンカー (`interactive` = note / comment / link / list / message、`tip` =
|
|
28
|
+
* note-tip) の上に載せる。本フックの所有: 層の導出 `layerEnabled = ∃K: popovers[K] !== false ∧
|
|
29
|
+
* capability(K)` (v0.7 アダプタ + 既定 props は何もマウントしない)、開閉 / 拒否遷移 (annotate の
|
|
30
|
+
* commit 拒否は `rejected: true` で開いたまま — Ctrl+Enter 再試行 / 拒否状態の Escape は保存せず
|
|
31
|
+
* 閉じる、いずれも既定コンポーネント側)、対話 kind を開く前の commit ゲート (`isEditing() &&
|
|
32
|
+
* !requestCommit()` ⇒ 中断)、閉じたときのフォーカス復帰 (`focusSink({ preventScroll: true })` +
|
|
33
|
+
* idle なら `rearmSink`)、優先順「開いているエディタ > 検証メッセージ」、ホバー note tip (body 上の
|
|
34
|
+
* rAF 合流ヒットテスト — client 座標を積みフレーム毎に 1 回解決 —、`shouldShowNoteTip` — 参照ドラッグ
|
|
35
|
+
* 含むドラッグ飛行中は表示しない、150 ms 離脱猶予、ホバー可 — tip フレーム進入の裏面である body の
|
|
36
|
+
* pointerleave は猶予を始めない —、Escape チェーン step 0 の `hideTip` — 再表示にはセル**変化** (述語の prev /
|
|
37
|
+
* next) が要るのでポインタがそのセルに留まる間は消えたまま —、ホバーセルが active の**間**シンクへ
|
|
38
|
+
* `aria-describedby` — エッジでなく不変条件: `syncTipAriaDescribedBy` が tip の表示 / 消灯と active セル
|
|
39
|
+
* 変化 (root の active 効果が呼ぶ) のたびに再評価し、離れれば外し、ホバー中セルへ乗れば結ぶ —)、
|
|
40
|
+
* 層が全 popover keydown に掛ける
|
|
41
|
+
* IME ガード (`isCompositionKeydown`)。DOM は描かない — PopoverLayer.tsx が `context` /
|
|
42
|
+
* `tipContext` から遅延マウントする。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { VirtualGridHandle } from "@aiquants/virtualscroll"
|
|
46
|
+
import { type RefObject, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
|
|
47
|
+
import { isCompositionKeydown } from "../keyboard/compositionGuard.ts"
|
|
48
|
+
import type { AxisModel } from "../model/axisModel.ts"
|
|
49
|
+
import { type CellComment, type CellCommentInput, normalizeAdapterHyperlink, normalizeHyperlink } from "../model/cellAnnotations.ts"
|
|
50
|
+
import { type CellCoord, clampCoord, coordEquals } from "../model/cellCoord.ts"
|
|
51
|
+
import type { CellEdit, EditRejection, SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
52
|
+
import { colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
|
|
53
|
+
import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
|
|
54
|
+
import { NOTE_TIP_LEAVE_GRACE_MS, type PopoverRect, shouldShowNoteTip } from "./placement.ts"
|
|
55
|
+
|
|
56
|
+
/** The six popover kinds (v0.8 §1.8). / 6 種のポップオーバー (v0.8 §1.8)。 */
|
|
57
|
+
export type PopoverKind = "note" | "note-tip" | "comment" | "link" | "list" | "message"
|
|
58
|
+
|
|
59
|
+
/** Every kind in declaration order (the layer derivation iterates it). / 宣言順の全 kind (層の導出が走査)。 */
|
|
60
|
+
export const POPOVER_KINDS: readonly PopoverKind[] = ["note", "note-tip", "comment", "link", "list", "message"]
|
|
61
|
+
|
|
62
|
+
/** The `popovers` prop: `false` disables a kind (its commands pass through, `openPopover` throws). / `popovers` prop: `false` はその kind を無効化 (コマンドは素通し、`openPopover` は RangeError)。 */
|
|
63
|
+
export type PopoverOverrides = { readonly [K in PopoverKind]?: boolean }
|
|
64
|
+
|
|
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 }
|
|
67
|
+
|
|
68
|
+
/** Hover note tip context (read-only). / ホバー note tip 文脈 (読み取り専用)。 */
|
|
69
|
+
export type NoteTipContext = { kind: "note-tip"; cell: CellCoord; anchorRect: PopoverRect; current: string; close: () => void }
|
|
70
|
+
|
|
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 }
|
|
73
|
+
|
|
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 = { kind: "link"; cell: CellCoord; anchorRect: PopoverRect; current: { text: string; url: string | undefined }; commit: (next: { text: string; url: string | null }) => void; close: () => void; canOpen: boolean; open: () => void; rejected: boolean }
|
|
76
|
+
|
|
77
|
+
/** Validation list picker context: `current` = the list rule's values, `typed` = the prefix that opened it. / 検証リスト文脈: current = list 規則の値、typed = 開いたときの入力プレフィックス。 */
|
|
78
|
+
export type ListPopoverContext = { kind: "list"; cell: CellCoord; anchorRect: PopoverRect; current: readonly string[]; typed: string; commit: (value: string) => void; close: () => void }
|
|
79
|
+
|
|
80
|
+
/** Validation message context (input message or a strict rejection). / 検証メッセージ文脈 (入力メッセージまたは strict 拒否)。 */
|
|
81
|
+
export type MessagePopoverContext = { kind: "message"; cell: CellCoord; anchorRect: PopoverRect; current: { title: string; message: string; rejected: boolean }; close: () => void }
|
|
82
|
+
|
|
83
|
+
/** The discriminated popover context handed to `renderPopover` / the default components (v0.8 §1.8). / renderPopover / 既定コンポーネントへ渡す判別文脈 (v0.8 §1.8)。 */
|
|
84
|
+
export type PopoverContext = NotePopoverContext | NoteTipContext | CommentPopoverContext | LinkPopoverContext | ListPopoverContext | MessagePopoverContext
|
|
85
|
+
|
|
86
|
+
/** Extra inputs of `open` (list: typed prefix; message: an explicit text, e.g. a strict-rejection verdict). / `open` の追加入力 (list: 入力プレフィックス、message: 明示テキスト — strict 拒否の判定文など)。 */
|
|
87
|
+
export type PopoverOpenOptions = { typed?: string; message?: string; rejected?: boolean }
|
|
88
|
+
|
|
89
|
+
/** Which cell each resident anchor sits on (null = hidden); written here, read by the overlay's repaintOverlay. / 各常設アンカーの載るセル (null = 非表示)。ここが書き、overlay の repaintOverlay が読む。 */
|
|
90
|
+
export type PopoverAnchorState = { interactive: CellCoord | null; tip: CellCoord | null }
|
|
91
|
+
|
|
92
|
+
/** The popover surface consumed by the layer, the keyboard (Escape chain), the commands and the handle. / 層 / キーボード (Escape チェーン) / コマンド / handle が消費する面。 */
|
|
93
|
+
export type PopoverController = {
|
|
94
|
+
/** §1.8 derivation: some kind is enabled AND its adapter capability is declared. / §1.8 の導出: 有効かつ能力宣言済みの kind が 1 つでもある。 */
|
|
95
|
+
layerEnabled: boolean
|
|
96
|
+
/** Per-kind enablement (popovers[kind] !== false AND capability declared). / kind 別の有効判定。 */
|
|
97
|
+
isKindEnabled: (kind: PopoverKind) => boolean
|
|
98
|
+
/** Opens a kind on the ACTIVE cell; RangeError when the kind is disabled or its capability undeclared. / active セルで kind を開く。無効 / 能力未宣言は RangeError。 */
|
|
99
|
+
open: (kind: PopoverKind, options?: PopoverOpenOptions) => void
|
|
100
|
+
/** Closes the interactive popover (idempotent) and returns focus to the sink. / 対話ポップオーバーを閉じ (冪等) シンクへフォーカスを返す。 */
|
|
101
|
+
close: () => void
|
|
102
|
+
/** The open interactive kind or null. / 開いている対話 kind か null。 */
|
|
103
|
+
isOpen: () => PopoverKind | null
|
|
104
|
+
/** rAF-coalesced hover entry for the note tip by cell (null = pointer left the cells); the body's own pointermove path queues the client point and hit-tests inside the frame. / note tip のセル指定ホバー入口 (rAF 合流。null = セル領域から離脱)。body の pointermove 経路は client 座標を積みフレーム内でヒットテストする。 */
|
|
105
|
+
onCellHover: (cell: CellCoord | null) => void
|
|
106
|
+
/** Escape chain step 0: hides a visible tip (true; false when none) — it stays hidden until the pointer moves to ANOTHER cell (a re-show needs a cell change). / Escape チェーン step 0: 可視 tip を消灯 (true、無ければ false) — 再表示にはセル変化が要るので別セルへ移るまで消えたまま。 */
|
|
107
|
+
hideTip: () => boolean
|
|
108
|
+
/** Pointer entered the tip element — marks the tip hovered (the body's same-task `pointerleave` then starts no grace) and cancels a running grace (hoverable tip). / tip 要素へのポインタ進入 — tip ホバー中と記し (同一タスクの body pointerleave は猶予を始めない)、走っている猶予を取り消す (ホバー可)。 */
|
|
109
|
+
onTipPointerEnter: () => void
|
|
110
|
+
/** Pointer left the tip element — clears the hovered mark and starts the leave grace. / tip 要素からのポインタ離脱 — ホバー中の記しを外し離脱猶予を開始。 */
|
|
111
|
+
onTipPointerLeave: () => void
|
|
112
|
+
/** Re-evaluates the "sink aria-describedby while the hovered cell is active" invariant (bind when the visible tip's cell IS active, unbind own binding otherwise); the root calls it on every active-cell change. / 「ホバーセルが active の間シンクへ aria-describedby」不変条件の再評価 (可視 tip のセルが active なら結び、でなければ自分の結合だけ外す)。root が active セル変化のたびに呼ぶ。 */
|
|
113
|
+
syncTipAriaDescribedBy: () => void
|
|
114
|
+
/** The interactive popover context (React state — lazy mount) or null. / 対話ポップオーバー文脈 (React state — 遅延マウント) か null。 */
|
|
115
|
+
context: PopoverContext | null
|
|
116
|
+
/** The hover tip context or null. / ホバー tip 文脈か null。 */
|
|
117
|
+
tipContext: NoteTipContext | null
|
|
118
|
+
/** compositionend registration for the IME guard. / IME ガード用の compositionend 登録。 */
|
|
119
|
+
registerCompositionEnd: (timeStamp: number) => void
|
|
120
|
+
/** True when a keydown is an IME artefact (the layer swallows it before any popover handler). / keydown が IME 由来なら true (層が popover ハンドラより先に握り潰す)。 */
|
|
121
|
+
isCompositionKey: (event: KeyboardEvent) => boolean
|
|
122
|
+
/** The layer's usable size (grid viewport, logical px) for placement. / 配置用の層サイズ (グリッド viewport、論理 px)。 */
|
|
123
|
+
getLayerSize: () => { width: number; height: number }
|
|
124
|
+
/** Stable id of the tip frame (the sink's aria-describedby target). / tip フレームの安定 id (シンクの aria-describedby 先)。 */
|
|
125
|
+
tipId: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type UsePopoversParams = {
|
|
129
|
+
/** Rewire gate (§6.9-1 empty branch). / 再結線ゲート。 */
|
|
130
|
+
enabled: boolean
|
|
131
|
+
adapter: SpreadsheetDataAdapter
|
|
132
|
+
popovers: PopoverOverrides | undefined
|
|
133
|
+
sinkRef: RefObject<HTMLTextAreaElement | null>
|
|
134
|
+
bodyRef: RefObject<HTMLElement | null>
|
|
135
|
+
handleRef: RefObject<VirtualGridHandle | null>
|
|
136
|
+
rowAxis: AxisModel
|
|
137
|
+
colAxis: AxisModel
|
|
138
|
+
zoomRef: RefObject<number>
|
|
139
|
+
getActive: () => CellCoord
|
|
140
|
+
isEditing: () => boolean
|
|
141
|
+
requestCommit: () => boolean
|
|
142
|
+
focusSink: (options?: { preventScroll?: boolean }) => void
|
|
143
|
+
rearmSink: () => void
|
|
144
|
+
/** Composite drag probe incl. the reference drag — the tip is never shown mid-drag. / 参照ドラッグ含む合成ドラッグ判定 — ドラッグ中は tip を出さない。 */
|
|
145
|
+
isDraggingAny: () => boolean
|
|
146
|
+
commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
|
|
147
|
+
labels: SpreadsheetLabels
|
|
148
|
+
onHyperlinkOpen?: (url: string, cell: CellCoord) => void
|
|
149
|
+
/** Root-owned anchor state the overlay positions. / root 所有のアンカー状態 (overlay が配置)。 */
|
|
150
|
+
anchors: RefObject<PopoverAnchorState>
|
|
151
|
+
repaintOverlay: () => void
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The adapter capability each kind requires (the "cannot write what you cannot read" gate). / 各 kind が要するアダプタ能力 (「読めないものは書けない」ゲート)。 */
|
|
155
|
+
const capabilityOf = (adapter: SpreadsheetDataAdapter, kind: PopoverKind): boolean => {
|
|
156
|
+
switch (kind) {
|
|
157
|
+
case "note":
|
|
158
|
+
case "note-tip":
|
|
159
|
+
return adapter.getCellNote !== undefined
|
|
160
|
+
case "comment":
|
|
161
|
+
return adapter.getCellComments !== undefined
|
|
162
|
+
case "link":
|
|
163
|
+
return adapter.getCellHyperlink !== undefined
|
|
164
|
+
default:
|
|
165
|
+
return adapter.getCellValidation !== undefined
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** popovers[kind] !== false AND the capability is declared. / popovers[kind] !== false かつ能力宣言済み。 */
|
|
170
|
+
const kindEnabled = (adapter: SpreadsheetDataAdapter, popovers: PopoverOverrides | undefined, kind: PopoverKind): boolean => (popovers === undefined ? true : popovers[kind] !== false) && capabilityOf(adapter, kind)
|
|
171
|
+
|
|
172
|
+
/** A coalesced hover request: a resolved cell (null = left the cells) or a client point resolved inside the frame. / 合流するホバー要求: 解決済みセル (null = 離脱) か、フレーム内で解決する client 座標。 */
|
|
173
|
+
type HoverRequest = { cell: CellCoord | null } | { clientX: number; clientY: number }
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Builds the popover controller; the layer renders from its `context` / `tipContext`.
|
|
177
|
+
* ポップオーバーコントローラを構築する。層は context / tipContext から描画する。
|
|
178
|
+
*/
|
|
179
|
+
export const usePopovers = (params: UsePopoversParams): PopoverController => {
|
|
180
|
+
// paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
|
|
181
|
+
const paramsRef = useRef(params)
|
|
182
|
+
paramsRef.current = params
|
|
183
|
+
|
|
184
|
+
const [context, setContext] = useState<PopoverContext | null>(null)
|
|
185
|
+
const [tipContext, setTipContext] = useState<NoteTipContext | null>(null)
|
|
186
|
+
/** Imperative mirror of the open interactive kind (state reads inside callbacks would be stale). / 開いている対話 kind の命令読みミラー。 */
|
|
187
|
+
const openKindRef = useRef<PopoverKind | null>(null)
|
|
188
|
+
/** The cell whose tip is visible (null = hidden). / tip を表示中のセル (null = 非表示)。 */
|
|
189
|
+
const tipCellRef = useRef<CellCoord | null>(null)
|
|
190
|
+
/** The last hovered cell (the predicate's `prev` — after hideTip the tip cannot re-show until this changes). / 直近のホバーセル (述語の prev — hideTip 後はこれが変わるまで再表示しない)。 */
|
|
191
|
+
const hoverPrevRef = useRef<CellCoord | null>(null)
|
|
192
|
+
/**
|
|
193
|
+
* True while the pointer is inside the tip frame. The frame is a sibling of the body, so
|
|
194
|
+
* entering it fires the body's `pointerleave` in the SAME input task as the tip's
|
|
195
|
+
* `pointerenter` — before the rAF-deferred evaluation; the evaluation consults this flag so
|
|
196
|
+
* that leave does not start the grace (hoverable tip). Reset when the tip unmounts (no
|
|
197
|
+
* `pointerleave` reaches an unmounted frame).
|
|
198
|
+
* ポインタが tip フレーム内にいる間 true。フレームは body の兄弟なので、進入は tip の
|
|
199
|
+
* pointerenter と**同じ入力タスク**で body の pointerleave を起こす (rAF 遅延の評価より前)。評価は
|
|
200
|
+
* この旗を見て離脱で猶予を始めない (ホバー可)。tip のアンマウントで戻す (消えたフレームに pointerleave は届かない)。
|
|
201
|
+
*/
|
|
202
|
+
const tipHoveredRef = useRef(false)
|
|
203
|
+
const graceTimerRef = useRef<number | null>(null)
|
|
204
|
+
const hoverRafRef = useRef<number | null>(null)
|
|
205
|
+
/** What the next frame evaluates: a cell (`onCellHover`), a client point (body pointermove — hit-tested inside the frame) or null (left the cells). / 次フレームの評価対象: セル (onCellHover) / client 座標 (body の pointermove — ヒットテストはフレーム内で) / null (セル領域から離脱)。 */
|
|
206
|
+
const pendingHoverRef = useRef<HoverRequest | null>(null)
|
|
207
|
+
const compositionEndedAtRef = useRef<number | null>(null)
|
|
208
|
+
const tipId = useId()
|
|
209
|
+
|
|
210
|
+
/** The active cell's rect in layer px (the overlay's forward map — the same recipe as the keyboard cellRect). / active セルの層 px 矩形 (overlay の順写像 — keyboard cellRect と同じ式)。 */
|
|
211
|
+
const anchorRectOf = useCallback((cell: CellCoord): PopoverRect => {
|
|
212
|
+
const p = paramsRef.current
|
|
213
|
+
const handle = p.handleRef.current
|
|
214
|
+
// detached ハンドルは原点扱い (番兵クランプは keyboard と同じ)
|
|
215
|
+
const position = handle === null ? { x: 0, y: 0 } : handle.getScrollPosition()
|
|
216
|
+
const frozen = handle === null ? { rows: 0, cols: 0, width: 0, height: 0 } : handle.getFrozenSize()
|
|
217
|
+
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),
|
|
220
|
+
width: p.colAxis.sizeOf(cell.col),
|
|
221
|
+
height: p.rowAxis.sizeOf(cell.row),
|
|
222
|
+
}
|
|
223
|
+
}, [])
|
|
224
|
+
|
|
225
|
+
/** Writes the anchor state and repaints (the overlay moves the anchor nodes). / アンカー状態を書き repaint する (overlay がアンカーノードを動かす)。 */
|
|
226
|
+
const writeAnchors = useCallback((next: Partial<PopoverAnchorState>) => {
|
|
227
|
+
const p = paramsRef.current
|
|
228
|
+
p.anchors.current = { ...p.anchors.current, ...next }
|
|
229
|
+
p.repaintOverlay()
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
const cancelGrace = useCallback(() => {
|
|
233
|
+
if (graceTimerRef.current !== null) {
|
|
234
|
+
window.clearTimeout(graceTimerRef.current)
|
|
235
|
+
graceTimerRef.current = null
|
|
236
|
+
}
|
|
237
|
+
}, [])
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Re-evaluates the ADR-15 invariant "the sink's aria-describedby points at the tip WHILE the
|
|
241
|
+
* hovered cell is active": binds when the visible tip's cell IS the active cell, unbinds —
|
|
242
|
+
* only its own binding — otherwise. Called on tip show / hide here and by the root's
|
|
243
|
+
* active-cell effect, so the binding follows arrow keys in both directions.
|
|
244
|
+
* ADR-15 の不変条件「ホバーセルが active の**間**シンクの aria-describedby が tip を指す」の
|
|
245
|
+
* 再評価。可視 tip のセルが active なら結び、でなければ**自分の結合だけ**外す。ここでは tip の
|
|
246
|
+
* 表示 / 消灯で、root の active セル効果からも呼ばれ、結合が矢印キーの両方向に追随する。
|
|
247
|
+
*/
|
|
248
|
+
const syncTipAriaDescribedBy = useCallback(() => {
|
|
249
|
+
const sink = paramsRef.current.sinkRef.current
|
|
250
|
+
if (sink === null) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
const tipCell = tipCellRef.current
|
|
254
|
+
if (tipCell !== null && coordEquals(tipCell, paramsRef.current.getActive())) {
|
|
255
|
+
sink.setAttribute("aria-describedby", tipId)
|
|
256
|
+
} else if (sink.getAttribute("aria-describedby") === tipId) {
|
|
257
|
+
// 外すのは自分の結合だけ — tip が無効 / 非表示のまま active が動いても他所の結合に触れない (v0.7 恒等)
|
|
258
|
+
sink.removeAttribute("aria-describedby")
|
|
259
|
+
}
|
|
260
|
+
}, [tipId])
|
|
261
|
+
|
|
262
|
+
/** Hides the tip now (true when one was visible). / tip を即時消灯 (可視だったら true)。 */
|
|
263
|
+
const hideTipNow = useCallback((): boolean => {
|
|
264
|
+
cancelGrace()
|
|
265
|
+
// 消えたフレームに pointerleave は届かない — ホバー旗はここで戻す (残すと次の tip が離脱で消えなくなる)
|
|
266
|
+
tipHoveredRef.current = false
|
|
267
|
+
if (tipCellRef.current === null) {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
tipCellRef.current = null
|
|
271
|
+
setTipContext(null)
|
|
272
|
+
writeAnchors({ tip: null })
|
|
273
|
+
// シンクの aria-describedby は tip の生存と同期 (不変条件の再評価 — 非表示なので自分の結合が外れる)
|
|
274
|
+
syncTipAriaDescribedBy()
|
|
275
|
+
return true
|
|
276
|
+
}, [cancelGrace, writeAnchors, syncTipAriaDescribedBy])
|
|
277
|
+
|
|
278
|
+
const startGrace = useCallback(() => {
|
|
279
|
+
cancelGrace()
|
|
280
|
+
graceTimerRef.current = window.setTimeout(() => {
|
|
281
|
+
graceTimerRef.current = null
|
|
282
|
+
hideTipNow()
|
|
283
|
+
}, NOTE_TIP_LEAVE_GRACE_MS)
|
|
284
|
+
}, [cancelGrace, hideTipNow])
|
|
285
|
+
|
|
286
|
+
/** Shows the tip for `cell` (the note read happens here, once). / cell の tip を表示する (ノート読みはここで 1 回)。 */
|
|
287
|
+
const showTip = useCallback(
|
|
288
|
+
(cell: CellCoord) => {
|
|
289
|
+
const p = paramsRef.current
|
|
290
|
+
cancelGrace()
|
|
291
|
+
// 能力は呼び手 (述語 hasNote) が確認済み — ここは読み 1 回
|
|
292
|
+
const note = p.adapter.getCellNote === undefined ? undefined : p.adapter.getCellNote(cell.row, cell.col)
|
|
293
|
+
if (note === undefined || note === "") {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
tipCellRef.current = cell
|
|
297
|
+
setTipContext({ kind: "note-tip", cell, anchorRect: anchorRectOf(cell), current: note, close: hideTipNow })
|
|
298
|
+
writeAnchors({ tip: cell })
|
|
299
|
+
// ホバーセルが active のときだけシンクの説明として結ぶ (SR がフォーカス位置の注釈として読む) — 不変条件の再評価
|
|
300
|
+
syncTipAriaDescribedBy()
|
|
301
|
+
},
|
|
302
|
+
[anchorRectOf, cancelGrace, hideTipNow, writeAnchors, syncTipAriaDescribedBy],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* One hover evaluation (rAF-coalesced): a leave (null) while the pointer is inside the tip
|
|
307
|
+
* frame is the other face of entering the tip and changes nothing (hover `prev` kept, no
|
|
308
|
+
* grace — hoverable tip); on the visible tip's own cell ⇒ keep it (cancel a running grace —
|
|
309
|
+
* no re-read, no re-render); on another cell ⇒ show (cell change + note + no drag) or start
|
|
310
|
+
* the leave grace when a tip is visible.
|
|
311
|
+
* ホバー評価 1 回 (rAF 合流): ポインタが tip フレーム内にいる間の離脱 (null) は tip 進入の裏面で
|
|
312
|
+
* 何も変えない (ホバーの prev 据置き、猶予なし — ホバー可)、可視 tip 自身のセルなら維持 (走っている
|
|
313
|
+
* 猶予を取消 — 再読も再描画もしない)、別セルなら表示 (セル変化 + ノート + 非ドラッグ) か、tip 可視なら
|
|
314
|
+
* 離脱猶予の開始。
|
|
315
|
+
*/
|
|
316
|
+
const evaluateHover = useCallback(
|
|
317
|
+
(next: CellCoord | null) => {
|
|
318
|
+
const p = paramsRef.current
|
|
319
|
+
// tip 上にいる間の body 離脱は tip 進入の裏面 — 猶予も prev も動かさない (ホバー可)
|
|
320
|
+
if (next === null && tipHoveredRef.current) {
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
const prev = hoverPrevRef.current
|
|
324
|
+
hoverPrevRef.current = next
|
|
325
|
+
const tipCell = tipCellRef.current
|
|
326
|
+
// tip のセルに留まった / 戻った ⇒ 猶予取消だけ (再表示ではない)
|
|
327
|
+
if (tipCell !== null && next !== null && coordEquals(tipCell, next)) {
|
|
328
|
+
cancelGrace()
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const hasNote = (cell: CellCoord): boolean => {
|
|
332
|
+
const note = p.adapter.getCellNote === undefined ? undefined : p.adapter.getCellNote(cell.row, cell.col)
|
|
333
|
+
return note !== undefined && note !== ""
|
|
334
|
+
}
|
|
335
|
+
// 表示はセル変化のときだけ (hideTip 後に同じセルへ留まっても再表示しない)
|
|
336
|
+
if (shouldShowNoteTip(prev, next, p.isDraggingAny(), hasNote)) {
|
|
337
|
+
// next は非 null (述語が保証)
|
|
338
|
+
if (next !== null) {
|
|
339
|
+
showTip(next)
|
|
340
|
+
}
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
// tip のセルを離れた ⇒ 猶予開始 (tip 上へ移った離脱は先頭で除外済み、tip からの離脱は onTipPointerLeave が再開)
|
|
344
|
+
if (tipCell !== null) {
|
|
345
|
+
startGrace()
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
[showTip, cancelGrace, startGrace],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
/** Hit-tests a client position to a cell (the fill machine's recipe). / client 座標をセルへヒットテスト (fill 機械と同じ式)。 */
|
|
352
|
+
const hitTest = useCallback((clientX: number, clientY: number): CellCoord | null => {
|
|
353
|
+
const p = paramsRef.current
|
|
354
|
+
const body = p.bodyRef.current
|
|
355
|
+
const handle = p.handleRef.current
|
|
356
|
+
if (body === null || handle === null) {
|
|
357
|
+
return null
|
|
358
|
+
}
|
|
359
|
+
const rect = body.getBoundingClientRect()
|
|
360
|
+
const z = p.zoomRef.current
|
|
361
|
+
const position = handle.getScrollPosition()
|
|
362
|
+
const frozen = handle.getFrozenSize()
|
|
363
|
+
return clampCoord({ row: viewportYToRow(p.rowAxis, (clientY - rect.top) / z, Math.max(0, position.y), frozen.height), col: viewportXToCol(p.colAxis, (clientX - rect.left) / z, Math.max(0, position.x), frozen.width) }, p.rowAxis.count, p.colAxis.count)
|
|
364
|
+
}, [])
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Queues a hover request for the next frame (one evaluation per frame — the LAST request
|
|
368
|
+
* wins). A client point is hit-tested inside the frame, so a burst of pointermove events
|
|
369
|
+
* costs one body layout read per frame (the engines' press-cached-rect discipline), not one
|
|
370
|
+
* per event.
|
|
371
|
+
* ホバー要求を次フレームへ積む (1 フレーム 1 評価 — 最後の要求が勝つ)。client 座標のヒットテストは
|
|
372
|
+
* フレーム内で行うため、pointermove の連発でも body のレイアウト読みは 1 フレーム 1 回
|
|
373
|
+
* (エンジンの押下時矩形キャッシュと同じ規律) — イベント毎ではない。
|
|
374
|
+
*/
|
|
375
|
+
const scheduleHover = useCallback(
|
|
376
|
+
(request: HoverRequest) => {
|
|
377
|
+
pendingHoverRef.current = request
|
|
378
|
+
// rAF 合流: 1 フレーム 1 評価
|
|
379
|
+
if (hoverRafRef.current === null) {
|
|
380
|
+
hoverRafRef.current = requestAnimationFrame(() => {
|
|
381
|
+
hoverRafRef.current = null
|
|
382
|
+
const pending = pendingHoverRef.current
|
|
383
|
+
// 要求はフレーム内で解決 (セルはそのまま、client 座標はここで 1 回だけヒットテスト)
|
|
384
|
+
evaluateHover(pending === null ? null : "cell" in pending ? pending.cell : hitTest(pending.clientX, pending.clientY))
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
[evaluateHover, hitTest],
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
const onCellHover = useCallback((cell: CellCoord | null) => scheduleHover({ cell }), [scheduleHover])
|
|
392
|
+
|
|
393
|
+
/** Escape chain step 0 (the hover `prev` stays on the cell, so no re-show until the pointer moves to another cell). / Escape チェーン step 0 (ホバーの prev はそのセルのままなので、別セルへ移るまで再表示しない)。 */
|
|
394
|
+
const hideTip = useCallback((): boolean => hideTipNow(), [hideTipNow])
|
|
395
|
+
|
|
396
|
+
/** Closes the interactive popover (idempotent) and returns focus to the sink. / 対話ポップオーバーを閉じ (冪等) フォーカスをシンクへ返す。 */
|
|
397
|
+
const close = useCallback(() => {
|
|
398
|
+
if (openKindRef.current === null) {
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const p = paramsRef.current
|
|
402
|
+
openKindRef.current = null
|
|
403
|
+
setContext(null)
|
|
404
|
+
writeAnchors({ interactive: null })
|
|
405
|
+
// フォーカス復帰 (スクロールさせない) + idle なら再武装 (編集セッションの値は守る)
|
|
406
|
+
p.focusSink({ preventScroll: true })
|
|
407
|
+
if (!p.isEditing()) {
|
|
408
|
+
p.rearmSink()
|
|
409
|
+
}
|
|
410
|
+
}, [writeAnchors])
|
|
411
|
+
|
|
412
|
+
/** Places `ctx` on the interactive anchor (replacing whatever was open). / ctx を対話アンカーへ載せる (開いていたものは置換)。 */
|
|
413
|
+
const present = useCallback(
|
|
414
|
+
(ctx: PopoverContext) => {
|
|
415
|
+
openKindRef.current = ctx.kind
|
|
416
|
+
setContext(ctx)
|
|
417
|
+
writeAnchors({ interactive: ctx.cell })
|
|
418
|
+
},
|
|
419
|
+
[writeAnchors],
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
/** Runs an annotate commit; a rejection re-presents the context with `rejected: true`. / annotate の commit を実行し、拒否なら rejected: true で再提示する。 */
|
|
423
|
+
const commitOrReject = useCallback(
|
|
424
|
+
(ctx: NotePopoverContext | CommentPopoverContext | LinkPopoverContext, edits: readonly CellEdit[]) => {
|
|
425
|
+
const p = paramsRef.current
|
|
426
|
+
if (p.commitEdits(edits, { source: "annotate" }) === null) {
|
|
427
|
+
// 拒否: 開いたまま rejected 状態へ (既定コンポーネントが Ctrl+Enter 再試行 / Escape 破棄を担う)
|
|
428
|
+
present({ ...ctx, rejected: true })
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
close()
|
|
432
|
+
},
|
|
433
|
+
[present, close],
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
const open = useCallback(
|
|
437
|
+
(kind: PopoverKind, options?: PopoverOpenOptions) => {
|
|
438
|
+
const p = paramsRef.current
|
|
439
|
+
if (!kindEnabled(p.adapter, p.popovers, kind)) {
|
|
440
|
+
throw new RangeError(`[spreadsheet] openPopover("${kind}") is unavailable: the kind is disabled by the popovers prop or its adapter capability is undeclared`)
|
|
441
|
+
}
|
|
442
|
+
const cell = p.getActive()
|
|
443
|
+
if (kind === "note-tip") {
|
|
444
|
+
// 手動表示: active セルにノートがあるときだけ (ホバー述語とは独立)
|
|
445
|
+
showTip(cell)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
if (kind === "message") {
|
|
449
|
+
// 優先順: 開いているエディタ (note / comment / link / list) は検証メッセージに置換されない
|
|
450
|
+
if (openKindRef.current !== null && openKindRef.current !== "message") {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const explicit = options !== undefined && options.message !== undefined ? options.message : undefined
|
|
454
|
+
const rule = p.adapter.getCellValidation === undefined ? undefined : p.adapter.getCellValidation(cell.row, cell.col)
|
|
455
|
+
const message = explicit !== undefined ? explicit : rule === undefined ? undefined : rule.inputMessage
|
|
456
|
+
if (message === undefined) {
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const rejected = options !== undefined && options.rejected === true
|
|
460
|
+
// メッセージはフォーカスを奪わない (入力中の告知) — アンカーへ載せるだけ
|
|
461
|
+
present({ kind: "message", cell, anchorRect: anchorRectOf(cell), current: { title: resolveLabel(p.labels, "messageTitle"), message, rejected }, close })
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
// 対話 kind: 開く前の commit ゲート (拒否ならセッション維持で中断)
|
|
465
|
+
if (p.isEditing() && !p.requestCommit()) {
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
if (kind === "list") {
|
|
469
|
+
const rule = p.adapter.getCellValidation === undefined ? undefined : p.adapter.getCellValidation(cell.row, cell.col)
|
|
470
|
+
// list 規則のないセルでは何も開かない (Sheets の Alt+↓ と同じ)
|
|
471
|
+
if (rule === undefined || rule.type !== "list") {
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
const typed = options !== undefined && options.typed !== undefined ? options.typed : ""
|
|
475
|
+
// ホバー tip はエディタに席を譲る
|
|
476
|
+
hideTipNow()
|
|
477
|
+
// 開く前にシンクへフォーカス (root の迷子回収に popover の初期フォーカスを奪わせない)
|
|
478
|
+
p.focusSink({ preventScroll: true })
|
|
479
|
+
present({
|
|
480
|
+
kind: "list",
|
|
481
|
+
cell,
|
|
482
|
+
anchorRect: anchorRectOf(cell),
|
|
483
|
+
current: rule.values,
|
|
484
|
+
typed,
|
|
485
|
+
commit: (value: string) => {
|
|
486
|
+
// リスト確定はセル編集 (source "edit"、セッションなし = discarded)
|
|
487
|
+
paramsRef.current.commitEdits([{ row: cell.row, col: cell.col, text: value }], { source: "edit", editSession: "discarded" })
|
|
488
|
+
close()
|
|
489
|
+
},
|
|
490
|
+
close,
|
|
491
|
+
})
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
hideTipNow()
|
|
495
|
+
p.focusSink({ preventScroll: true })
|
|
496
|
+
if (kind === "note") {
|
|
497
|
+
const current = p.adapter.getCellNote === undefined ? undefined : p.adapter.getCellNote(cell.row, cell.col)
|
|
498
|
+
const ctx: NotePopoverContext = {
|
|
499
|
+
kind: "note",
|
|
500
|
+
cell,
|
|
501
|
+
anchorRect: anchorRectOf(cell),
|
|
502
|
+
current,
|
|
503
|
+
commit: (next: string | null) => {
|
|
504
|
+
commitOrReject(ctx, [{ row: cell.row, col: cell.col, note: next }])
|
|
505
|
+
},
|
|
506
|
+
close,
|
|
507
|
+
rejected: false,
|
|
508
|
+
}
|
|
509
|
+
present(ctx)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
if (kind === "comment") {
|
|
513
|
+
const thread = p.adapter.getCellComments === undefined ? undefined : p.adapter.getCellComments(cell.row, cell.col)
|
|
514
|
+
const ctx: CommentPopoverContext = {
|
|
515
|
+
kind: "comment",
|
|
516
|
+
cell,
|
|
517
|
+
anchorRect: anchorRectOf(cell),
|
|
518
|
+
current: thread === undefined ? [] : thread,
|
|
519
|
+
commit: (next: readonly CellCommentInput[] | null) => {
|
|
520
|
+
// スレッド全体を運ぶ (id つき既存 + id なし新規、null は消去) — id の採番は consumer
|
|
521
|
+
commitOrReject(ctx, [{ row: cell.row, col: cell.col, comments: next }])
|
|
522
|
+
},
|
|
523
|
+
close,
|
|
524
|
+
rejected: false,
|
|
525
|
+
}
|
|
526
|
+
present(ctx)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
// 信頼境界 (ADR-10): 許可外スキームの生 URL はリンク無し扱い — 表示にも Open にも渡さない
|
|
530
|
+
const url = p.adapter.getCellHyperlink === undefined ? undefined : normalizeAdapterHyperlink(p.adapter.getCellHyperlink(cell.row, cell.col))
|
|
531
|
+
const text = p.adapter.getCellText(cell.row, cell.col)
|
|
532
|
+
const ctx: LinkPopoverContext = {
|
|
533
|
+
kind: "link",
|
|
534
|
+
cell,
|
|
535
|
+
anchorRect: anchorRectOf(cell),
|
|
536
|
+
current: { text, url },
|
|
537
|
+
commit: (next: { text: string; url: string | null }) => {
|
|
538
|
+
// url は正規化済み href か null — 正規化不能はレンダラのバグ (既定コンポーネントは保存前に弾く)
|
|
539
|
+
const normalized = next.url === null ? null : normalizeHyperlink(next.url)
|
|
540
|
+
if (normalized === null && next.url !== null) {
|
|
541
|
+
throw new RangeError(`[spreadsheet] link commit received an unnormalisable url ${JSON.stringify(next.url)}`)
|
|
542
|
+
}
|
|
543
|
+
// text が変わらなければ text キーは出さない (未指定 = 触らない)
|
|
544
|
+
const edit: CellEdit = next.text === text ? { row: cell.row, col: cell.col, hyperlink: normalized } : { row: cell.row, col: cell.col, text: next.text, hyperlink: normalized }
|
|
545
|
+
commitOrReject(ctx, [edit])
|
|
546
|
+
},
|
|
547
|
+
close,
|
|
548
|
+
canOpen: p.onHyperlinkOpen !== undefined,
|
|
549
|
+
open: () => {
|
|
550
|
+
const open = paramsRef.current.onHyperlinkOpen
|
|
551
|
+
if (open !== undefined && url !== undefined) {
|
|
552
|
+
open(url, cell)
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
rejected: false,
|
|
556
|
+
}
|
|
557
|
+
present(ctx)
|
|
558
|
+
},
|
|
559
|
+
[anchorRectOf, close, commitOrReject, hideTipNow, present, showTip],
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
const { enabled, bodyRef } = params
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
const body = bodyRef.current
|
|
565
|
+
if (!enabled || body === null) {
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
569
|
+
const p = paramsRef.current
|
|
570
|
+
// tip が無効 (prop / 能力) なら何も積まない (v0.7 恒等 — 読みゼロ)
|
|
571
|
+
if (!kindEnabled(p.adapter, p.popovers, "note-tip")) {
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
// 座標だけ積む — ヒットテスト (body の layout 読み) はフレーム内で 1 回
|
|
575
|
+
scheduleHover({ clientX: event.clientX, clientY: event.clientY })
|
|
576
|
+
}
|
|
577
|
+
const onPointerLeave = () => {
|
|
578
|
+
if (!kindEnabled(paramsRef.current.adapter, paramsRef.current.popovers, "note-tip")) {
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
scheduleHover({ cell: null })
|
|
582
|
+
}
|
|
583
|
+
body.addEventListener("pointermove", onPointerMove)
|
|
584
|
+
body.addEventListener("pointerleave", onPointerLeave)
|
|
585
|
+
return () => {
|
|
586
|
+
body.removeEventListener("pointermove", onPointerMove)
|
|
587
|
+
body.removeEventListener("pointerleave", onPointerLeave)
|
|
588
|
+
}
|
|
589
|
+
}, [enabled, bodyRef, scheduleHover])
|
|
590
|
+
|
|
591
|
+
// アンマウント時に rAF / 猶予タイマを回収する (全タイマ回収の規律)
|
|
592
|
+
useEffect(
|
|
593
|
+
() => () => {
|
|
594
|
+
if (hoverRafRef.current !== null) {
|
|
595
|
+
cancelAnimationFrame(hoverRafRef.current)
|
|
596
|
+
}
|
|
597
|
+
if (graceTimerRef.current !== null) {
|
|
598
|
+
window.clearTimeout(graceTimerRef.current)
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
[],
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
const { adapter, popovers } = params
|
|
605
|
+
const layerEnabled = useMemo(() => POPOVER_KINDS.some((kind) => kindEnabled(adapter, popovers, kind)), [adapter, popovers])
|
|
606
|
+
|
|
607
|
+
return useMemo(
|
|
608
|
+
() => ({
|
|
609
|
+
layerEnabled,
|
|
610
|
+
isKindEnabled: (kind: PopoverKind) => kindEnabled(paramsRef.current.adapter, paramsRef.current.popovers, kind),
|
|
611
|
+
open,
|
|
612
|
+
close,
|
|
613
|
+
isOpen: () => openKindRef.current,
|
|
614
|
+
onCellHover,
|
|
615
|
+
hideTip,
|
|
616
|
+
onTipPointerEnter: () => {
|
|
617
|
+
// 旗が先 — 同一タスクで後から届く body の pointerleave (rAF 評価) が猶予を始めない
|
|
618
|
+
tipHoveredRef.current = true
|
|
619
|
+
cancelGrace()
|
|
620
|
+
},
|
|
621
|
+
onTipPointerLeave: () => {
|
|
622
|
+
tipHoveredRef.current = false
|
|
623
|
+
if (tipCellRef.current !== null) {
|
|
624
|
+
startGrace()
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
syncTipAriaDescribedBy,
|
|
628
|
+
context,
|
|
629
|
+
tipContext,
|
|
630
|
+
registerCompositionEnd: (timeStamp: number) => {
|
|
631
|
+
compositionEndedAtRef.current = timeStamp
|
|
632
|
+
},
|
|
633
|
+
isCompositionKey: (event: KeyboardEvent) => isCompositionKeydown(event, compositionEndedAtRef.current),
|
|
634
|
+
getLayerSize: () => {
|
|
635
|
+
const handle = paramsRef.current.handleRef.current
|
|
636
|
+
return handle === null ? { width: 0, height: 0 } : handle.getViewportSize()
|
|
637
|
+
},
|
|
638
|
+
tipId,
|
|
639
|
+
}),
|
|
640
|
+
[layerEnabled, open, close, onCellHover, hideTip, cancelGrace, startGrace, syncTipAriaDescribedBy, context, tipContext, tipId],
|
|
641
|
+
)
|
|
642
|
+
}
|