@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,1154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Clipboard wiring on the sink (§6.5.4, v0.8 §4.2.5 / ADR-14): copy / cut write
|
|
3
|
+
* three flavours — `text/plain` (raw TSV), `text/html` (the interop table carrying the lossless
|
|
4
|
+
* payload in `data-aqsp-clip`, its FIRST attribute) and, for the paste EVENT channel only, the
|
|
5
|
+
* custom MIME `application/x-aqsp-clip` (the same JSON). Paste reads exactly ONE source chosen by
|
|
6
|
+
* marker detection (`aqsp` → `sheets` → `excel` → `html` → `tsv`), estimates the write BEFORE
|
|
7
|
+
* materialising anything (ADR-12: header dimensions / HTML estimator / TSV counting scan), and treats a
|
|
8
|
+
* parse failure of the detected source as `onClipboardUnavailable("unreadable")` — never a slide
|
|
9
|
+
* to another reader. The armed clip is a ref (no re-render) that records the payload and its
|
|
10
|
+
* source adapter: an `aqsp` paste whose header `clipId` equals the armed id reuses the armed
|
|
11
|
+
* payload by identity (no parse, on BOTH channels); a plain paste of an armed CUT is the
|
|
12
|
+
* non-tiling cut-move (same sheet: one transaction of clears + writes; another sheet: the target
|
|
13
|
+
* commit first, then the source clears under the SAME transaction id). A paste of the armed
|
|
14
|
+
* payload itself keeps the clip whatever its kind (Excel keeps the ants across a paste and treats
|
|
15
|
+
* Paste Special of a cut as copy semantics); a paste of any other source keeps only a copy (v0.7
|
|
16
|
+
* rule). Paste Special options arrive through `pendingPasteOptionsRef` (event) or `paste(options)`
|
|
17
|
+
* (menu — `navigator.clipboard.read({ unsanitized: ["text/html"] })`), transforms run in
|
|
18
|
+
* `pasteSpecial.ts`, tiling in the generic `expandRichPaste`, sizes through `gateResizeEntries`
|
|
19
|
+
* (non-undo geometry). Every v0.8 parameter is a capability declaration — absent ⇒ the v0.7 branch
|
|
20
|
+
* byte-for-byte (TSV-only adapters — the commit seam `commitPaste` is the one required member). rearm stays with the setSelection
|
|
21
|
+
* wrapper (§6.9-1 no-double-actor rule).
|
|
22
|
+
*
|
|
23
|
+
* シンク上のクリップボード結線 (§6.5.4、v0.8 §4.2.5 / ADR-14)。copy / cut は 3 フレーバー —
|
|
24
|
+
* `text/plain` (素の TSV)、`text/html` (lossless payload を先頭属性 `data-aqsp-clip` に運ぶ相互運用
|
|
25
|
+
* 表)、paste イベント経路専用のカスタム MIME `application/x-aqsp-clip` (同じ JSON) — を書く。
|
|
26
|
+
* paste はマーカー検出 (`aqsp` → `sheets` → `excel` → `html` → `tsv`) で選んだ唯一のソースを読み、
|
|
27
|
+
* 実体化前に見積もり (ADR-12: ヘッダ寸法 / HTML 見積もり / TSV 計数走査)、検出ソースの parse 失敗は
|
|
28
|
+
* `onClipboardUnavailable("unreadable")` — 別 reader へは滑らない。装填クリップは payload と元
|
|
29
|
+
* アダプタを記録する ref (再レンダーなし): ヘッダ `clipId` が装填 id と一致する `aqsp` 貼り付けは
|
|
30
|
+
* 両経路とも装填 payload を identity で採用 (parse なし)、装填 CUT の素の貼り付けはタイルしない
|
|
31
|
+
* cut-move (同一シート: clear + 書込の 1 トランザクション、跨ぎ: 対象 commit → 同一 transaction id
|
|
32
|
+
* で元 clear)。装填 payload 自身の貼り付けは種別を問わずクリップを保ち (Excel はペースト越しに
|
|
33
|
+
* アンツを保ち、cut の Paste Special は copy 意味論)、他ソースの貼り付けは copy だけ保つ (v0.7
|
|
34
|
+
* 規則)。Paste Special は `pendingPasteOptionsRef` (イベント) / `paste(options)` (メニュー —
|
|
35
|
+
* `navigator.clipboard.read({ unsanitized: ["text/html"] })`) から、変換は `pasteSpecial.ts`、タイル
|
|
36
|
+
* は generic `expandRichPaste`、寸法は `gateResizeEntries` (非 undo 幾何)。v0.8 のパラメータは全て
|
|
37
|
+
* 能力宣言 — 不在なら v0.7 分岐そのまま (TSV のみのアダプタ — commit シーム `commitPaste` だけが必須メンバー)。rearm は
|
|
38
|
+
* setSelection ラッパーの専有 (§6.9-1)。
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { type RefObject, useCallback, useEffect, useMemo, useRef } from "react"
|
|
42
|
+
import { gateResizeEntries, MAX_TRACK_SIZE_PX, type ResizeEntry, ROW_HIDDEN_LIMIT } from "../header/resizeGates.ts"
|
|
43
|
+
import type { AxisModel } from "../model/axisModel.ts"
|
|
44
|
+
import { isWholeCols, isWholeRows, normalizeAdapterHyperlink, normalizeHyperlink } from "../model/cellAnnotations.ts"
|
|
45
|
+
import { type CellCoord, type CellRange, countRangeCells, enumerateRangeCells, makeRange, rangeSize, type SpreadsheetOperation } from "../model/cellCoord.ts"
|
|
46
|
+
import type { CellFormat } from "../model/cellFormat.ts"
|
|
47
|
+
import type { ConditionalFormatRule, ConditionalFormatRuleInput } from "../model/conditionalFormat.ts"
|
|
48
|
+
import { type AspectCapabilities, aspectCapabilities, type CellEdit, type EditRejection, type EditSource, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
|
|
49
|
+
import { toR1C1 } from "../model/formulaRefs.ts"
|
|
50
|
+
import type { SelectionState } from "../selection/selectionState.ts"
|
|
51
|
+
import {
|
|
52
|
+
CLIP_MIME,
|
|
53
|
+
type ClipCell,
|
|
54
|
+
type ClipChannel,
|
|
55
|
+
type ClipFlavours,
|
|
56
|
+
type ClipFormatInterner,
|
|
57
|
+
type ClipPayload,
|
|
58
|
+
type ClipValidationInterner,
|
|
59
|
+
createFormatInterner,
|
|
60
|
+
createValidationInterner,
|
|
61
|
+
detectClipSource,
|
|
62
|
+
estimateHtmlTableCells,
|
|
63
|
+
newClipId,
|
|
64
|
+
readClipPayload,
|
|
65
|
+
readClipSourceHeader,
|
|
66
|
+
serializeClipPayload,
|
|
67
|
+
} from "./clipPayload.ts"
|
|
68
|
+
import { parseHtmlTable, serializeHtmlTable } from "./htmlInterop.ts"
|
|
69
|
+
import { DEFAULT_PASTE_OPTIONS, MODE_ASPECTS, type PasteAspects, type PasteOptions, patchFromClipCell, stripUndeclaredAspects, translateConditionalFormats, transposePayload } from "./pasteSpecial.ts"
|
|
70
|
+
import { expandRichPaste } from "./tiling.ts"
|
|
71
|
+
import { measureTsv, parseTsv, serializeTsv } from "./tsv.ts"
|
|
72
|
+
|
|
73
|
+
/** The armed clip surface a consumer sees (`onClipChange` / `getClip`): `foreign` = the record belongs to another adapter (sheet switch kept a cut). / consumer が見る装填クリップ (`onClipChange` / `getClip`): `foreign` = 記録が別アダプタのもの (シート切替が cut を保持)。 */
|
|
74
|
+
export type ClipInfo = { kind: "copy" | "cut"; range: CellRange; foreign: boolean }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The armed clipboard source (marching-ants marquee state): kind, source range, the clip id the
|
|
78
|
+
* three flavours carry, the adapter the cells were read from and the payload captured at the
|
|
79
|
+
* gesture. A COPY pastes that snapshot by identity on a clip-id hit (Sheets semantics); a CUT is
|
|
80
|
+
* a reference to the range, as in Excel — its cells are re-read from `sourceAdapter` at paste
|
|
81
|
+
* time (`armedPayload`) so a format / annotate commit applied after Ctrl+X travels with the
|
|
82
|
+
* move, while the snapshot keeps the identity, the dimensions and the whole-row / whole-column
|
|
83
|
+
* sizes (the source sheet's axes are not at hand after a sheet switch).
|
|
84
|
+
* 装填済みクリップボードソース (マーチングアンツの状態): 種別、元レンジ、3 フレーバーが運ぶ
|
|
85
|
+
* clip id、セルを読んだアダプタ、ジェスチャ時点の payload。copy は clip id 一致でその snapshot を
|
|
86
|
+
* identity 採用 (Sheets 意味論)、cut は Excel と同じくレンジへの参照 — セルは貼り付け時に
|
|
87
|
+
* `sourceAdapter` から再読 (`armedPayload`) するので Ctrl+X 後の書式 / 注釈 commit が move に
|
|
88
|
+
* 同伴し、snapshot は同一性・寸法・全行 / 全列の寸法を保つ (シート切替後は元シートの軸が無い)。
|
|
89
|
+
*/
|
|
90
|
+
export type ClipRecord = { kind: "copy" | "cut"; range: CellRange; clipId: string; sourceAdapter: SpreadsheetDataAdapter; payload: ClipPayload }
|
|
91
|
+
|
|
92
|
+
/** Why the menu path could not read the clipboard (§1.9 `onClipboardUnavailable`). / メニュー経路がクリップボードを読めなかった理由 (§1.9 `onClipboardUnavailable`)。 */
|
|
93
|
+
export type ClipboardUnavailableCause = "insecure-origin" | "permission-denied" | "empty" | "unreadable"
|
|
94
|
+
|
|
95
|
+
/** One paste's outcome: the adapter was called / nothing was written / refused (§1.10 `paste`). / 1 回の貼り付けの結果: アダプタが呼ばれた / 何も書かなかった / 拒否 (§1.10 `paste`)。 */
|
|
96
|
+
export type PasteOutcome = "applied" | "empty" | "rejected"
|
|
97
|
+
|
|
98
|
+
/** The paste-only part of `PasteEditContext` the hook hands to the root's transaction seam. / フックがルートのトランザクションシームへ渡す `PasteEditContext` の貼り付け固有部。 */
|
|
99
|
+
export type PasteSheet = { sameSheet: boolean; conditionalFormats: readonly ConditionalFormatRuleInput[] }
|
|
100
|
+
|
|
101
|
+
/** The track-size seam (copy reads sizes of whole rows / columns, paste writes them through the gate). / トラック寸法シーム (copy は全行 / 全列の寸法を読み、paste はゲート経由で書く)。 */
|
|
102
|
+
export type ClipboardResizeSeam = { rowAxis: AxisModel; colAxis: AxisModel; resizeTracks: (axis: "row" | "col", entries: readonly ResizeEntry[]) => void }
|
|
103
|
+
|
|
104
|
+
/** Hook parameters (every v0.8 member is a capability declaration — absent ⇒ v0.7 branch). / フックのパラメータ (v0.8 の各メンバーは能力宣言 — 不在なら v0.7 分岐)。 */
|
|
105
|
+
export type UseClipboardParams = {
|
|
106
|
+
/** Rewire gate (§6.9-1 empty branch). / 再結線ゲート。 */
|
|
107
|
+
enabled: boolean
|
|
108
|
+
sinkRef: RefObject<HTMLTextAreaElement | null>
|
|
109
|
+
adapter: SpreadsheetDataAdapter
|
|
110
|
+
isEditing: () => boolean
|
|
111
|
+
getSelection: () => SelectionState
|
|
112
|
+
setSelection: (next: SelectionState) => void
|
|
113
|
+
/**
|
|
114
|
+
* THE commit seam (ADR-16): commits ONE paste transaction with its sheet context and returns
|
|
115
|
+
* the transaction id, or null when the adapter rejected (a cross-sheet cut clears the source
|
|
116
|
+
* under the same id).
|
|
117
|
+
* 唯一の commit シーム (ADR-16): 貼り付け 1 トランザクションをシート文脈つきで commit し、
|
|
118
|
+
* transaction id か拒否なら null を返す (跨ぎ cut は同じ id で元を clear する)。
|
|
119
|
+
*/
|
|
120
|
+
commitPaste: (edits: readonly CellEdit[], sheet: PasteSheet) => number | null
|
|
121
|
+
maxOperationCells: number
|
|
122
|
+
onOperationRejected?: (op: SpreadsheetOperation, cellCount: number, reason: "limit" | "bounds") => void
|
|
123
|
+
onCopyMultiRange?: () => void
|
|
124
|
+
/** Stable repaint indirection for the marquee sync (§6.5.4-4). / マーキー同期用の安定間接。 */
|
|
125
|
+
repaintOverlay: () => void
|
|
126
|
+
/** Drag-freeze probe — clipboard ops consume + no-op mid-drag (mirrors the keydown freeze). / ドラッグ凍結プローブ。 */
|
|
127
|
+
isDragging: () => boolean
|
|
128
|
+
/** Track sizes (v0.8): absent ⇒ copies carry no sizes and pastes never resize. / トラック寸法 (v0.8): 不在なら copy は寸法を運ばず paste は寸法を書かない。 */
|
|
129
|
+
resize?: ClipboardResizeSeam
|
|
130
|
+
/** Ctrl+Shift+V arms `{ mode: "values" }` here; the next paste event consumes it once. Absent ⇒ every paste is `all`. / Ctrl+Shift+V がここへ `{ mode: "values" }` を武装し、次の paste イベントが 1 回で消費。不在なら常に `all`。 */
|
|
131
|
+
pendingPasteOptionsRef?: RefObject<PasteOptions | null>
|
|
132
|
+
/** The host's declared currency / locale for foreign number formats (§1.9 `formats`). / 外来数値書式の解釈に使う宣言通貨 / locale (§1.9 `formats`)。 */
|
|
133
|
+
formats?: { readonly currency: string; readonly locale?: string }
|
|
134
|
+
onClipChange?: (clip: ClipInfo | null) => void
|
|
135
|
+
/** The refusal channel of the read paths — whenever it fires the paste resolves `"rejected"`. / 読み経路の拒否チャネル — 発火した貼り付けは例外なく `"rejected"`。 */
|
|
136
|
+
onClipboardUnavailable?: (cause: ClipboardUnavailableCause) => void
|
|
137
|
+
/** Source-side rejection of a cross-sheet cut (the target commit already stands). / 跨ぎ cut の元側拒否 (対象 commit は立っている)。 */
|
|
138
|
+
onEditRejected?: (error: unknown, rejection: EditRejection) => void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The clip surface (§6.5.4-4 — copy/cut unified in v0.3.2, widened in v0.8). / クリップ状態の公開面 (v0.3.2 で copy / cut 統一、v0.8 で拡張)。 */
|
|
142
|
+
export type ClipboardController = {
|
|
143
|
+
/** The armed clip or null; `foreign` = another adapter's record. / 装填中クリップか null。`foreign` = 別アダプタの記録。 */
|
|
144
|
+
getClip: () => ClipInfo | null
|
|
145
|
+
/** Unconditional discard (Escape / edit start / undo-redo — foreign or not). / 無条件破棄 (foreign でも)。 */
|
|
146
|
+
discardClip: () => void
|
|
147
|
+
/**
|
|
148
|
+
* Commit-triggered discard: a `format` / `annotate` commit keeps the clip (Excel keeps the
|
|
149
|
+
* ants under Ctrl+B), a COPY survives this hook's own paste commit, every other successful
|
|
150
|
+
* commit discards both kinds. Without a source (v0.7 root) only the own-paste rule applies.
|
|
151
|
+
* commit 起点の破棄: `format` / `annotate` の commit はクリップを保つ (Excel は Ctrl+B でアンツ
|
|
152
|
+
* 存続)、copy は自フックの paste commit を生き延び、他の成功 commit は両種とも破棄。source なし
|
|
153
|
+
* (v0.7 ルート) は自 paste 規則のみ。
|
|
154
|
+
*/
|
|
155
|
+
discardClipOnCommit: (source?: EditSource) => void
|
|
156
|
+
/** Adapter switch: discard a COPY record, keep a CUT record (cross-sheet move). / 切替: copy 破棄、cut 保持 (跨ぎ move)。 */
|
|
157
|
+
onAdapterSwitch: () => void
|
|
158
|
+
/** Menu path (`navigator.clipboard`) — `handle.paste`. / メニュー経路 — `handle.paste`。 */
|
|
159
|
+
paste: (options: PasteOptions) => Promise<PasteOutcome>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The flavours the menu path materialises before detection (`getData` must be synchronous). / メニュー経路が検出前に実体化するフレーバー (`getData` は同期でなければならない)。 */
|
|
163
|
+
const MENU_FLAVOURS: readonly string[] = ["text/html", "text/plain"]
|
|
164
|
+
|
|
165
|
+
/** `Clipboard.read` with the `unsanitized` option the DOM lib does not type yet; `Clipboard` is assignable to it. / DOM lib が未型付けの `unsanitized` オプションつき `Clipboard.read`。`Clipboard` はこれへ代入可能。 */
|
|
166
|
+
type UnsanitizedClipboardReader = { read: (options: { unsanitized: readonly string[] }) => Promise<ClipboardItems> }
|
|
167
|
+
|
|
168
|
+
/** The one target cursor `patchFromClipCell` reads: coordinates rewritten per visited cell, thunks bound to the cursor. / `patchFromClipCell` が読む唯一のターゲットカーソル: 座標は訪問セル毎に書き換え、thunk はカーソルに束縛。 */
|
|
169
|
+
type PasteCursor = { row: number; col: number; readonly readText: () => string; readonly readFormat: () => CellFormat | undefined }
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tile-aware paste cell count from DIMENSIONS only (ADR-12 pre-check for the sources whose
|
|
173
|
+
* dimensions are known before any cell exists: the `aqsp` header, the HTML estimator, the TSV
|
|
174
|
+
* counting scan). The `estimatePasteCellCount` table: a non-1×1 target whose both extents are exact
|
|
175
|
+
* multiples tiles (target area), anything else expands once (source area); no rows or no columns
|
|
176
|
+
* ⇒ 0.
|
|
177
|
+
* 寸法だけからのタイル込み貼り付けセル数 (ADR-12 の事前検査 — `aqsp` ヘッダ / HTML 見積もり /
|
|
178
|
+
* TSV 計数走査のようにセル実体化前に寸法が分かるソース用)。`estimatePasteCellCount` の表:
|
|
179
|
+
* 1×1 でなく両軸が整数倍のターゲットはタイル (ターゲット面積)、それ以外は 1 回展開 (ソース面積)、
|
|
180
|
+
* 行なし / 列なしは 0。
|
|
181
|
+
*/
|
|
182
|
+
export const estimateTiledCells = (rows: number, cols: number, target: CellRange): number => {
|
|
183
|
+
if (rows === 0 || cols === 0) {
|
|
184
|
+
return 0
|
|
185
|
+
}
|
|
186
|
+
const size = rangeSize(target)
|
|
187
|
+
// 整数倍レンジ (1×1 を除く) はタイル = ターゲット面積、それ以外はソース面積
|
|
188
|
+
const isTileTarget = !(size.rows === 1 && size.cols === 1) && size.rows % rows === 0 && size.cols % cols === 0
|
|
189
|
+
return isTileTarget ? size.rows * size.cols : rows * cols
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True for the plain Ctrl+V options — the only paste that may move a cut (transforms are copy semantics). / 素の Ctrl+V オプションだけ真 — cut を move できる唯一の貼り付け (変換は copy 意味論)。 */
|
|
193
|
+
const isPlainPaste = (options: PasteOptions): boolean => options.mode === "all" && options.operation === undefined && options.transpose !== true && options.skipBlanks !== true
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The event channel's flavours: the DataTransfer types and a `getData` memoised per type for the
|
|
197
|
+
* gesture (a paste event's `getData` re-materialises the string on every call — 91 ms for 15.8 MB).
|
|
198
|
+
* イベント経路のフレーバー: DataTransfer の types と、ジェスチャ内で種別毎に memo した `getData`
|
|
199
|
+
* (paste イベントの `getData` は呼ぶ度に文字列を再実体化する — 15.8 MB で 91 ms)。
|
|
200
|
+
*/
|
|
201
|
+
const flavoursOf = (data: DataTransfer): ClipFlavours => {
|
|
202
|
+
const types = Array.from(data.types)
|
|
203
|
+
const cache = new Map<string, string>()
|
|
204
|
+
return {
|
|
205
|
+
types,
|
|
206
|
+
getData: (type) => {
|
|
207
|
+
const hit = cache.get(type)
|
|
208
|
+
if (hit !== undefined) {
|
|
209
|
+
return hit
|
|
210
|
+
}
|
|
211
|
+
const value = data.getData(type)
|
|
212
|
+
cache.set(type, value)
|
|
213
|
+
return value
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The event's DataTransfer, or null when the event carries none — a synthetic `Event("paste")`
|
|
220
|
+
* has no `clipboardData` property at all (v0.7's `?.` semantics, one judgment point).
|
|
221
|
+
* イベントの DataTransfer、無ければ null — 合成 `Event("paste")` は `clipboardData` 自体を持たない
|
|
222
|
+
* (v0.7 の `?.` と同じ意味論、判定点は 1 つ)。
|
|
223
|
+
*/
|
|
224
|
+
const clipboardDataOf = (event: ClipboardEvent): DataTransfer | null => {
|
|
225
|
+
const data = event.clipboardData
|
|
226
|
+
return data === undefined || data === null ? null : data
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The intersection of two ranges, or null when they do not overlap. / 2 レンジの交差、重ならなければ null。 */
|
|
230
|
+
const intersectRange = (a: CellRange, b: CellRange): CellRange | null => {
|
|
231
|
+
const startRow = Math.max(a.startRow, b.startRow)
|
|
232
|
+
const endRow = Math.min(a.endRow, b.endRow)
|
|
233
|
+
const startCol = Math.max(a.startCol, b.startCol)
|
|
234
|
+
const endCol = Math.min(a.endCol, b.endCol)
|
|
235
|
+
return startRow > endRow || startCol > endCol ? null : { startRow, startCol, endRow, endCol }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The conditional-format inputs a copy carries: every rule with a range intersecting the copied
|
|
240
|
+
* rectangle, each range clipped to the intersection and translated to origin-relative
|
|
241
|
+
* coordinates; rules without any intersection are omitted, `stopIfTrue` travels only when set.
|
|
242
|
+
* copy が運ぶ条件付き書式入力: コピー矩形と交差するレンジを持つ全規則、各レンジは交差へ切り詰めて
|
|
243
|
+
* 原点相対へ平行移動。交差の無い規則は省き、`stopIfTrue` は設定時だけ運ぶ。
|
|
244
|
+
*/
|
|
245
|
+
const clipConditionalFormats = (rules: readonly ConditionalFormatRule[], range: CellRange): ConditionalFormatRuleInput[] => {
|
|
246
|
+
const inputs: ConditionalFormatRuleInput[] = []
|
|
247
|
+
for (const rule of rules) {
|
|
248
|
+
const ranges: CellRange[] = []
|
|
249
|
+
for (const ruleRange of rule.ranges) {
|
|
250
|
+
const hit = intersectRange(ruleRange, range)
|
|
251
|
+
if (hit !== null) {
|
|
252
|
+
ranges.push({ startRow: hit.startRow - range.startRow, startCol: hit.startCol - range.startCol, endRow: hit.endRow - range.startRow, endCol: hit.endCol - range.startCol })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// コピー矩形と交差しない規則は運ばない
|
|
256
|
+
if (ranges.length === 0) {
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const input: ConditionalFormatRuleInput = { sourceRuleId: rule.id, ranges, condition: rule.condition, style: rule.style }
|
|
260
|
+
if (rule.stopIfTrue !== undefined) {
|
|
261
|
+
input.stopIfTrue = rule.stopIfTrue
|
|
262
|
+
}
|
|
263
|
+
inputs.push(input)
|
|
264
|
+
}
|
|
265
|
+
return inputs
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Reads one cell into its wire form: raw text always; then each aspect ONLY when its read is
|
|
270
|
+
* declared (a capability branch per aspect, never a value substitute) — the formula as
|
|
271
|
+
* cell-relative R1C1 (`resolveEditText` enforces the leading "="), the format and validation
|
|
272
|
+
* through the interners (an empty canonical format has no `fmt`), the note, the id-less comment
|
|
273
|
+
* thread (an empty thread is no thread) and the hyperlink verbatim.
|
|
274
|
+
* セル 1 つを wire 形へ読む: 素の文字列は常に、各側面は読みが宣言されているときだけ (側面毎の
|
|
275
|
+
* 能力分岐、値代替はしない) — 数式はセル相対 R1C1 (`resolveEditText` が "=" 始まりを強制)、書式と
|
|
276
|
+
* 検証は interner 経由 (空の正準書式は `fmt` なし)、ノート、id を剥がしたコメント (空スレッドは
|
|
277
|
+
* 不在)、ハイパーリンクは原文。
|
|
278
|
+
*/
|
|
279
|
+
const readClipCell = (adapter: SpreadsheetDataAdapter, formats: ClipFormatInterner, validations: ClipValidationInterner, row: number, col: number): ClipCell => {
|
|
280
|
+
// 数式の能力分岐と "=" の fail-fast は resolveEditText が担う (数式セルだけ getCellText を追加で読む)
|
|
281
|
+
const edit = resolveEditText(adapter, row, col)
|
|
282
|
+
const cell: ClipCell = { t: edit.isFormula ? adapter.getCellText(row, col) : edit.text }
|
|
283
|
+
if (edit.isFormula) {
|
|
284
|
+
cell.f = toR1C1(edit.text, { row, col })
|
|
285
|
+
}
|
|
286
|
+
if (adapter.getCellFormat !== undefined) {
|
|
287
|
+
const format = adapter.getCellFormat(row, col)
|
|
288
|
+
if (format !== undefined) {
|
|
289
|
+
const fmt = formats.intern(format)
|
|
290
|
+
// 空の正準形は側面なし (辞書にも載らない)
|
|
291
|
+
if (fmt !== undefined) {
|
|
292
|
+
cell.fmt = fmt
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (adapter.getCellNote !== undefined) {
|
|
297
|
+
const note = adapter.getCellNote(row, col)
|
|
298
|
+
if (note !== undefined) {
|
|
299
|
+
cell.note = note
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (adapter.getCellComments !== undefined) {
|
|
303
|
+
const thread = adapter.getCellComments(row, col)
|
|
304
|
+
// wire は consumer の id を運ばない (貼り付け先では全件新規)
|
|
305
|
+
if (thread !== undefined && thread.length > 0) {
|
|
306
|
+
cell.comments = thread.map(({ text, author, createdAt }) => ({ text, author, createdAt }))
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (adapter.getCellHyperlink !== undefined) {
|
|
310
|
+
// 信頼境界 (ADR-10): 生のアダプタ URL を許可リストへ正規化 — 正規化不能は側面ごと落とす
|
|
311
|
+
// (sanitizeLinks と同じ規則)。装填 payload と text/html 書出はこの読みだけを源に持つため、
|
|
312
|
+
// 許可外スキームはクリップのどのフレーバーにも CellPatch.hyperlink にも決して載らない
|
|
313
|
+
const link = normalizeAdapterHyperlink(adapter.getCellHyperlink(row, col))
|
|
314
|
+
if (link !== undefined) {
|
|
315
|
+
cell.link = link
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (adapter.getCellValidation !== undefined) {
|
|
319
|
+
const rule = adapter.getCellValidation(row, col)
|
|
320
|
+
if (rule !== undefined) {
|
|
321
|
+
cell.val = validations.intern(rule)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return cell
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The cell-content part of a payload (what a paste writes): the dictionaries, the cells and the origin-relative conditional formats. / payload のセル内容部 (貼り付けが書くもの): 辞書、セル、原点相対の条件付き書式。 */
|
|
328
|
+
type ClipContent = Pick<ClipPayload, "formats" | "validations" | "cells" | "conditionalFormats">
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Reads the content of `range` from `adapter`: every cell through `readClipCell` with one format
|
|
332
|
+
* and one validation interner per read, and the intersecting conditional formats origin-relative
|
|
333
|
+
* (capability branch: only when `getConditionalFormats` is declared).
|
|
334
|
+
* `adapter` から `range` の内容を読む: 全セルを `readClipCell` で (読み 1 回につき書式 / 検証の
|
|
335
|
+
* interner 1 つずつ)、交差する条件付き書式は原点相対 (能力分岐: `getConditionalFormats` 宣言時だけ)。
|
|
336
|
+
*/
|
|
337
|
+
const readRangeContent = (adapter: SpreadsheetDataAdapter, range: CellRange): ClipContent => {
|
|
338
|
+
const formats = createFormatInterner()
|
|
339
|
+
const validations = createValidationInterner()
|
|
340
|
+
const cells: ClipCell[][] = []
|
|
341
|
+
for (let row = range.startRow; row <= range.endRow; row += 1) {
|
|
342
|
+
const line: ClipCell[] = []
|
|
343
|
+
for (let col = range.startCol; col <= range.endCol; col += 1) {
|
|
344
|
+
line.push(readClipCell(adapter, formats, validations, row, col))
|
|
345
|
+
}
|
|
346
|
+
cells.push(line)
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
formats: formats.entries,
|
|
350
|
+
validations: validations.entries,
|
|
351
|
+
cells,
|
|
352
|
+
// 能力分岐: 条件付き書式は getConditionalFormats 宣言時だけ運ぶ
|
|
353
|
+
conditionalFormats: adapter.getConditionalFormats === undefined ? [] : clipConditionalFormats(adapter.getConditionalFormats(), range),
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The payload an armed record pastes on a clip-id hit: a COPY is its snapshot; a CUT re-reads the
|
|
359
|
+
* range from the SOURCE adapter now (Excel's deferred cut — a format / note / link / validation
|
|
360
|
+
* written after Ctrl+X moves with the cells, nothing captured at Ctrl+X is silently destroyed)
|
|
361
|
+
* while keeping the snapshot's clip id, dimensions, whole flags and sizes. The read happens after
|
|
362
|
+
* the ADR-12 estimate (the dimensions are the snapshot's).
|
|
363
|
+
* clip id 一致で装填記録が貼る payload: copy は snapshot、cut は**今**元アダプタからレンジを再読
|
|
364
|
+
* (Excel の遅延 cut — Ctrl+X 後に書いた書式 / ノート / リンク / 検証がセルと共に動き、Ctrl+X 時点
|
|
365
|
+
* の捕捉が黙って壊されない) し、snapshot の clip id・寸法・whole フラグ・寸法列は保つ。読みは
|
|
366
|
+
* ADR-12 見積もり (寸法は snapshot のもの) の後。
|
|
367
|
+
*/
|
|
368
|
+
const armedPayload = (record: ClipRecord): ClipPayload => (record.kind === "cut" ? { ...record.payload, ...readRangeContent(record.sourceAdapter, record.range) } : record.payload)
|
|
369
|
+
|
|
370
|
+
/** A plain TSV matrix as a payload: text-only cells, empty dictionaries, a fresh clip id (never an identity hit). / 素の TSV 行列を payload に: text だけのセル、空辞書、新規 clip id (identity ヒットは起きない)。 */
|
|
371
|
+
const payloadFromTsv = (matrix: readonly (readonly string[])[]): ClipPayload => ({
|
|
372
|
+
v: 1,
|
|
373
|
+
rows: matrix.length,
|
|
374
|
+
cols: matrix[0].length,
|
|
375
|
+
clipId: newClipId(),
|
|
376
|
+
formats: [],
|
|
377
|
+
validations: [],
|
|
378
|
+
cells: matrix.map((line) => line.map((t) => ({ t }))),
|
|
379
|
+
wholeRows: false,
|
|
380
|
+
wholeCols: false,
|
|
381
|
+
conditionalFormats: [],
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Reader-side hyperlink rule for a FOREIGN JSON payload (`parseClipPayload` validates `link`
|
|
386
|
+
* structurally only): every `link` goes through `normalizeHyperlink`; an unnormalisable link drops
|
|
387
|
+
* the aspect (the patch contract says a hyperlink patch IS a `normalizeHyperlink` output). The HTML
|
|
388
|
+
* reader already normalises and an armed payload is normalised at read time (`readClipCell` routes
|
|
389
|
+
* every adapter link through `normalizeAdapterHyperlink` — ADR-10), so neither is re-scanned.
|
|
390
|
+
* Copy-on-write: untouched rows and cells keep identity, a payload without any bad link is returned
|
|
391
|
+
* as is.
|
|
392
|
+
* 外来 JSON payload の reader 側ハイパーリンク規則 (`parseClipPayload` は `link` を構造だけ検証):
|
|
393
|
+
* 全 `link` を `normalizeHyperlink` に通し、正規化できないリンクは側面ごと落とす (パッチ契約:
|
|
394
|
+
* hyperlink パッチは `normalizeHyperlink` の出力)。HTML reader は正規化済み、装填 payload は読み口
|
|
395
|
+
* (`readClipCell` が全アダプタリンクを `normalizeAdapterHyperlink` へ通す — ADR-10) で正規化済み
|
|
396
|
+
* なので再走査しない。copy-on-write: 触れない行 / セルは identity、不正リンクの無い payload は
|
|
397
|
+
* そのまま返す。
|
|
398
|
+
*/
|
|
399
|
+
const sanitizeLinks = (payload: ClipPayload): ClipPayload => {
|
|
400
|
+
let changed = false
|
|
401
|
+
const cells: (readonly ClipCell[])[] = []
|
|
402
|
+
for (const line of payload.cells) {
|
|
403
|
+
let lineChanged = false
|
|
404
|
+
const next: ClipCell[] = []
|
|
405
|
+
for (const cell of line) {
|
|
406
|
+
if (cell.link === undefined) {
|
|
407
|
+
next.push(cell)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
const link = normalizeHyperlink(cell.link)
|
|
411
|
+
if (link === cell.link) {
|
|
412
|
+
next.push(cell)
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
lineChanged = true
|
|
416
|
+
if (link === null) {
|
|
417
|
+
// 正規化できないリンクは側面ごと落とす (代替 URL は書かない)
|
|
418
|
+
const { link: _dropped, ...rest } = cell
|
|
419
|
+
next.push(rest)
|
|
420
|
+
} else {
|
|
421
|
+
next.push({ ...cell, link })
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (lineChanged) {
|
|
425
|
+
changed = true
|
|
426
|
+
}
|
|
427
|
+
cells.push(lineChanged ? next : line)
|
|
428
|
+
}
|
|
429
|
+
return changed ? { ...payload, cells } : payload
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The cut-move clear patch of one source cell: `text: ""` plus a null for every aspect the SOURCE
|
|
434
|
+
* adapter declares (a v0.7 adapter gets exactly `{ row, col, text: "" }`).
|
|
435
|
+
* cut-move の元セル 1 つの clear パッチ: `text: ""` と、元アダプタが宣言する側面それぞれの null
|
|
436
|
+
* (v0.7 アダプタは正確に `{ row, col, text: "" }`)。
|
|
437
|
+
*/
|
|
438
|
+
const clearPatch = (row: number, col: number, caps: AspectCapabilities): CellEdit => {
|
|
439
|
+
const edit: CellEdit = { row, col, text: "" }
|
|
440
|
+
if (caps.format) {
|
|
441
|
+
edit.format = null
|
|
442
|
+
}
|
|
443
|
+
if (caps.note) {
|
|
444
|
+
edit.note = null
|
|
445
|
+
}
|
|
446
|
+
if (caps.comments) {
|
|
447
|
+
edit.comments = null
|
|
448
|
+
}
|
|
449
|
+
if (caps.hyperlink) {
|
|
450
|
+
edit.hyperlink = null
|
|
451
|
+
}
|
|
452
|
+
if (caps.validation) {
|
|
453
|
+
edit.validation = null
|
|
454
|
+
}
|
|
455
|
+
return edit
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The rectangle a paste occupies: from the first (top-left) tile origin to the farthest tile's
|
|
460
|
+
* far corner, clamped to the grid (the v0.7 "actual written rectangle" — identical to the min /
|
|
461
|
+
* max over the edits when every cell is written, and still defined for a CF-only or skip-blanks
|
|
462
|
+
* paste). Null when no tile entered the grid.
|
|
463
|
+
* 貼り付けが占める矩形: 先頭 (左上) タイル原点から最遠タイルの端まで、グリッドへクランプ (v0.7 の
|
|
464
|
+
* 「実書込矩形」 — 全セルを書くときは edits の min / max と同一で、CF だけ / skipBlanks の貼り付け
|
|
465
|
+
* でも定義される)。グリッドに入ったタイルが無ければ null。
|
|
466
|
+
*/
|
|
467
|
+
const pasteRect = (tiles: readonly CellCoord[], rows: number, cols: number, rowCount: number, colCount: number): CellRange | null => {
|
|
468
|
+
if (tiles.length === 0) {
|
|
469
|
+
return null
|
|
470
|
+
}
|
|
471
|
+
let endRow = 0
|
|
472
|
+
let endCol = 0
|
|
473
|
+
for (const tile of tiles) {
|
|
474
|
+
endRow = Math.max(endRow, tile.row + rows - 1)
|
|
475
|
+
endCol = Math.max(endCol, tile.col + cols - 1)
|
|
476
|
+
}
|
|
477
|
+
return makeRange({ row: tiles[0].row, col: tiles[0].col }, { row: Math.min(endRow, rowCount - 1), col: Math.min(endCol, colCount - 1) })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Wires clipboard events to the sink and owns the armed clip (copy/cut marquee + cut-move + the
|
|
482
|
+
* menu paste path).
|
|
483
|
+
* クリップボードイベントをシンクへ結線し、装填中クリップ (copy / cut マーキー + cut-move +
|
|
484
|
+
* メニュー貼り付け経路) を所有する。
|
|
485
|
+
*/
|
|
486
|
+
export const useClipboard = (params: UseClipboardParams): ClipboardController => {
|
|
487
|
+
// paramsRef パターン: リスナーは結線 1 回、最新パラメータはレンダー毎にここへ写す
|
|
488
|
+
const paramsRef = useRef(params)
|
|
489
|
+
paramsRef.current = params
|
|
490
|
+
|
|
491
|
+
/** The armed clip (ref — changing it never re-renders §6.5.4-4). / 装填中クリップ (ref — 変更で再レンダーしない §6.5.4-4)。 */
|
|
492
|
+
const clipRef = useRef<ClipRecord | null>(null)
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Which paste of THIS hook is inside the commit seam: `"own"` = the armed payload itself
|
|
496
|
+
* (clip-id identity hit — the clip survives whatever its kind), `"foreign"` = any other source
|
|
497
|
+
* (a copy survives, a cut is consumed — v0.7 rule), `"none"` outside the seam.
|
|
498
|
+
* 自フックのどの paste が commit シーム中か: `"own"` = 装填 payload そのもの (clip id 一致 —
|
|
499
|
+
* 種別を問わずクリップ存続)、`"foreign"` = 他ソース (copy は存続、cut は消費 — v0.7 規則)、
|
|
500
|
+
* `"none"` = シーム外。
|
|
501
|
+
*/
|
|
502
|
+
const pasteInFlightRef = useRef<"none" | "own" | "foreign">("none")
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Discards the clip, erases the marquee synchronously and notifies (idempotent — no-op when clear).
|
|
506
|
+
* クリップを破棄しマーキーを同期消去して通知する (冪等 — 無ければ no-op)。
|
|
507
|
+
*/
|
|
508
|
+
const discardClip = useCallback(() => {
|
|
509
|
+
if (clipRef.current === null) {
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
clipRef.current = null
|
|
513
|
+
// マーキーの同期消去 (§6.5.4-4 — repaintOverlay が getClip を命令読みして描く)
|
|
514
|
+
paramsRef.current.repaintOverlay()
|
|
515
|
+
paramsRef.current.onClipChange?.(null)
|
|
516
|
+
}, [])
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Commit-triggered discard (see ClipboardController.discardClipOnCommit).
|
|
520
|
+
* commit 起点の破棄 (ClipboardController.discardClipOnCommit 参照)。
|
|
521
|
+
*/
|
|
522
|
+
const discardClipOnCommit = useCallback(
|
|
523
|
+
(source?: EditSource) => {
|
|
524
|
+
const clip = clipRef.current
|
|
525
|
+
if (clip === null) {
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
// 書式 / 注釈の commit はクリップを保つ (Excel: コピー中の Ctrl+B でアンツは消えない)
|
|
529
|
+
if (source === "format" || source === "annotate") {
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
// 装填 payload 自身の paste は種別を問わず温存 (copy は連続ペースト可、cut の Paste Special は
|
|
533
|
+
// copy 意味論 — move は別経路で消費済み)。他ソースの paste 中は copy だけ温存 (v0.7 規則)、
|
|
534
|
+
// 編集 / Delete / fill 等の他 commit は両種とも破棄
|
|
535
|
+
const inFlight = pasteInFlightRef.current
|
|
536
|
+
if (inFlight === "own" || (inFlight === "foreign" && clip.kind === "copy")) {
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
discardClip()
|
|
540
|
+
},
|
|
541
|
+
[discardClip],
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Adapter switch: a copy record is discarded, a cut record survives for the cross-sheet move
|
|
546
|
+
* (its marquee hides through `foreign`; the consumer is told the record is now foreign).
|
|
547
|
+
* アダプタ切替: copy 記録は破棄、cut 記録は跨ぎ move のため保持 (マーキーは `foreign` で非表示、
|
|
548
|
+
* consumer へは記録が foreign になったと通知)。
|
|
549
|
+
*/
|
|
550
|
+
const onAdapterSwitch = useCallback(() => {
|
|
551
|
+
const clip = clipRef.current
|
|
552
|
+
if (clip === null) {
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
if (clip.kind === "copy") {
|
|
556
|
+
discardClip()
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
const p = paramsRef.current
|
|
560
|
+
p.repaintOverlay()
|
|
561
|
+
p.onClipChange?.({ kind: "cut", range: clip.range, foreign: clip.sourceAdapter !== p.adapter })
|
|
562
|
+
}, [discardClip])
|
|
563
|
+
|
|
564
|
+
/** Arms a record and notifies (copy / cut success). / 記録を装填して通知する (copy / cut 成功)。 */
|
|
565
|
+
const armClip = useCallback((kind: "copy" | "cut", range: CellRange, payload: ClipPayload) => {
|
|
566
|
+
const p = paramsRef.current
|
|
567
|
+
clipRef.current = { kind, range, clipId: payload.clipId, sourceAdapter: p.adapter, payload }
|
|
568
|
+
p.repaintOverlay()
|
|
569
|
+
p.onClipChange?.({ kind, range, foreign: false })
|
|
570
|
+
}, [])
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Reads the range into a payload: the content through `readRangeContent` (cells, dictionaries,
|
|
574
|
+
* origin-relative conditional formats), the whole-row / whole-column flags, and — only with the
|
|
575
|
+
* resize seam — the sizes of a whole-column (widths) / whole-row (heights) copy, each clamped to
|
|
576
|
+
* MAX_TRACK_SIZE_PX (the writer must never emit a payload the wire validator rejects). A fresh
|
|
577
|
+
* clip id per copy.
|
|
578
|
+
* レンジを payload へ読む: 内容は `readRangeContent` (セル、辞書、原点相対の条件付き書式)、全行 /
|
|
579
|
+
* 全列フラグ、resize シームがあるときだけ全列 copy の幅 / 全行 copy の高さ (各寸法は
|
|
580
|
+
* MAX_TRACK_SIZE_PX へクランプ — writer は wire 検証器が拒む payload を出さない)。clip id は
|
|
581
|
+
* copy 毎に採番。
|
|
582
|
+
*/
|
|
583
|
+
const buildClipPayload = useCallback((range: CellRange): ClipPayload => {
|
|
584
|
+
const p = paramsRef.current
|
|
585
|
+
const adapter = p.adapter
|
|
586
|
+
const size = rangeSize(range)
|
|
587
|
+
const payload: ClipPayload = {
|
|
588
|
+
v: 1,
|
|
589
|
+
rows: size.rows,
|
|
590
|
+
cols: size.cols,
|
|
591
|
+
clipId: newClipId(),
|
|
592
|
+
...readRangeContent(adapter, range),
|
|
593
|
+
wholeRows: isWholeRows(range, adapter.colCount),
|
|
594
|
+
wholeCols: isWholeCols(range, adapter.rowCount),
|
|
595
|
+
}
|
|
596
|
+
// 寸法は resize シーム (能力) があり、かつ全列 / 全行の copy のときだけ運ぶ。
|
|
597
|
+
// 命令 resize API (v0.1 契約) は上限を持たず軸は wire 上限超の寸法を合法に保持し得るため、
|
|
598
|
+
// 「parser が拒む文字列を出さない」不変量を守るには writer 側で MAX_TRACK_SIZE_PX へ削る
|
|
599
|
+
if (p.resize !== undefined) {
|
|
600
|
+
if (payload.wholeCols) {
|
|
601
|
+
const widths: number[] = []
|
|
602
|
+
for (let col = range.startCol; col <= range.endCol; col += 1) {
|
|
603
|
+
widths.push(Math.min(p.resize.colAxis.sizeOf(col), MAX_TRACK_SIZE_PX))
|
|
604
|
+
}
|
|
605
|
+
payload.colWidths = widths
|
|
606
|
+
}
|
|
607
|
+
if (payload.wholeRows) {
|
|
608
|
+
const heights: number[] = []
|
|
609
|
+
for (let row = range.startRow; row <= range.endRow; row += 1) {
|
|
610
|
+
heights.push(Math.min(p.resize.rowAxis.sizeOf(row), MAX_TRACK_SIZE_PX))
|
|
611
|
+
}
|
|
612
|
+
payload.rowHeights = heights
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return payload
|
|
616
|
+
}, [])
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The copy/cut common part (§6.5.4-3): check → read → serialize the three flavours → setData.
|
|
620
|
+
* Returns the payload and its range on success.
|
|
621
|
+
* copy / cut の共通部 (§6.5.4-3): 検査 → 読み → 3 フレーバーの直列化 → setData。成功時に
|
|
622
|
+
* payload とレンジを返す。
|
|
623
|
+
*/
|
|
624
|
+
const serializeCurrentRange = useCallback(
|
|
625
|
+
(event: ClipboardEvent, op: "copy" | "cut"): { payload: ClipPayload; range: CellRange } | null => {
|
|
626
|
+
const p = paramsRef.current
|
|
627
|
+
const selection = p.getSelection()
|
|
628
|
+
const range = selection.ranges[selection.ranges.length - 1]
|
|
629
|
+
// 上限検査が常に先 (ADR-12)
|
|
630
|
+
const count = countRangeCells([range])
|
|
631
|
+
if (count > p.maxOperationCells) {
|
|
632
|
+
event.preventDefault() // 「何もしない」= ネイティブがシンクの " " をコピーするのも防ぐ
|
|
633
|
+
p.onOperationRejected?.(op, count, "limit")
|
|
634
|
+
return null
|
|
635
|
+
}
|
|
636
|
+
// 複数レンジ選択は末尾レンジのみ + 通知 (明示逸脱 — Excel の整列連結コピーは非対応 §6.5.4-3)
|
|
637
|
+
if (selection.ranges.length > 1) {
|
|
638
|
+
p.onCopyMultiRange?.()
|
|
639
|
+
}
|
|
640
|
+
const payload = buildClipPayload(range)
|
|
641
|
+
const json = serializeClipPayload(payload)
|
|
642
|
+
const data = clipboardDataOf(event)
|
|
643
|
+
if (data !== null) {
|
|
644
|
+
// TSV は素の文字列 (数式セルも値文字列)、HTML は先頭属性に同じ JSON、カスタム MIME は
|
|
645
|
+
// paste イベント経路の高速マーカー (read() 経路には届かない)
|
|
646
|
+
data.setData("text/plain", serializeTsv(payload.cells.map((line) => line.map((cell) => cell.t))))
|
|
647
|
+
data.setData("text/html", serializeHtmlTable(payload, json))
|
|
648
|
+
data.setData(CLIP_MIME, json)
|
|
649
|
+
}
|
|
650
|
+
event.preventDefault()
|
|
651
|
+
return { payload, range }
|
|
652
|
+
},
|
|
653
|
+
[buildClipPayload],
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Commits ONE paste transaction through the commit seam (sheet context, transaction id) and
|
|
658
|
+
* returns whether the adapter accepted (the id itself is only needed by the cross-sheet cut).
|
|
659
|
+
* 貼り付け 1 トランザクションを commit シーム (シート文脈、transaction id) で commit し、アダプタが
|
|
660
|
+
* 受理したかを返す (id 自体は跨ぎ cut だけが使う)。
|
|
661
|
+
*/
|
|
662
|
+
const commitPasteEdits = useCallback((edits: readonly CellEdit[], sheet: PasteSheet): boolean => paramsRef.current.commitPaste(edits, sheet) !== null, [])
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Writes the sizes a paste carries (mode with `sizes`, resize seam present, payload sizes
|
|
666
|
+
* present): one gated `resizeTracks` batch per axis over the paste rectangle, the payload's
|
|
667
|
+
* pattern repeated per tile; a gate refusal reports `onOperationRejected("resize", …)`.
|
|
668
|
+
* 貼り付けが運ぶ寸法を書く (`sizes` を持つモード、resize シームあり、payload に寸法あり):
|
|
669
|
+
* 貼り付け矩形の軸毎にゲート済み `resizeTracks` 1 バッチ、payload のパターンをタイル毎に反復。
|
|
670
|
+
* ゲート拒否は `onOperationRejected("resize", …)`。
|
|
671
|
+
*/
|
|
672
|
+
const applyPasteSizes = useCallback((payload: ClipPayload, rect: CellRange, aspects: PasteAspects) => {
|
|
673
|
+
const p = paramsRef.current
|
|
674
|
+
if (!aspects.sizes || p.resize === undefined) {
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
const { rowAxis, colAxis, resizeTracks } = p.resize
|
|
678
|
+
const gate = (axisKind: "row" | "col", axis: AxisModel, entries: readonly ResizeEntry[]) => {
|
|
679
|
+
const result = gateResizeEntries(axis, entries, null, { maxOperationCells: p.maxOperationCells, rowHiddenLimit: ROW_HIDDEN_LIMIT, axisKind })
|
|
680
|
+
if (!result.ok) {
|
|
681
|
+
p.onOperationRejected?.("resize", result.count, result.reason)
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
resizeTracks(axisKind, result.entries)
|
|
685
|
+
}
|
|
686
|
+
if (payload.colWidths !== undefined) {
|
|
687
|
+
const entries: ResizeEntry[] = []
|
|
688
|
+
for (let col = rect.startCol; col <= rect.endCol; col += 1) {
|
|
689
|
+
entries.push({ index: col, size: payload.colWidths[(col - rect.startCol) % payload.cols] })
|
|
690
|
+
}
|
|
691
|
+
gate("col", colAxis, entries)
|
|
692
|
+
}
|
|
693
|
+
if (payload.rowHeights !== undefined) {
|
|
694
|
+
const entries: ResizeEntry[] = []
|
|
695
|
+
for (let row = rect.startRow; row <= rect.endRow; row += 1) {
|
|
696
|
+
entries.push({ index: row, size: payload.rowHeights[(row - rect.startRow) % payload.rows] })
|
|
697
|
+
}
|
|
698
|
+
gate("row", rowAxis, entries)
|
|
699
|
+
}
|
|
700
|
+
}, [])
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The non-tiling cut-move of the armed CUT record (a plain paste with a clip-id hit): estimate
|
|
704
|
+
* = source area + clear count (never the tile formula), bounds refusal when the block would
|
|
705
|
+
* leave the grid OR when the armed source range no longer fits the source adapter's CURRENT
|
|
706
|
+
* extents (rows / columns deleted after Ctrl+X — the deferred read and the clears must never
|
|
707
|
+
* touch outside the grid), then the DEFERRED read of the source range (`armedPayload` — the
|
|
708
|
+
* cells as they are now), clears (full-null of the SOURCE adapter's aspects) then writes.
|
|
709
|
+
* Formulas move VERBATIM (Excel / Sheets: a move never re-anchors relative references — each
|
|
710
|
+
* cell's R1C1 form is resolved at its SOURCE coordinate, a round-trip identity). Same
|
|
711
|
+
* sheet: ONE transaction (overlap resolves clear → write, §6.2.3). Another sheet: the target
|
|
712
|
+
* commit first (translated conditional formats travel), then the source clears under the same
|
|
713
|
+
* transaction id; a source throw is reported through `onEditRejected` while the target stands.
|
|
714
|
+
* 装填 CUT 記録のタイルしない cut-move (clip id 一致の素の貼り付け): 見積もり = ソース面積 +
|
|
715
|
+
* clear 数 (タイル式ではない)、ブロックがグリッドを出るか、装填後の行 / 列削除で元レンジが元
|
|
716
|
+
* アダプタの**今の**軸から外れているなら bounds 拒否 (遅延読みと clear はグリッド外に触れない)、
|
|
717
|
+
* 次に元レンジの**遅延読み** (`armedPayload` — 今のセル)、clear (元アダプタの側面の全 null) →
|
|
718
|
+
* 書込。数式は**原文のまま**動く (Excel / Sheets: move は相対参照を再アンカーしない — 各セルの
|
|
719
|
+
* R1C1 を元座標で解決する往復恒等)。同一シート: 1 トランザクション (重なりは clear → 書込の後勝ち、§6.2.3)。跨ぎ: 対象
|
|
720
|
+
* commit が先 (翻訳した条件付き書式を同伴)、次に同一 transaction id で元 clear。元の throw は
|
|
721
|
+
* `onEditRejected` へ通知し対象は立つ。
|
|
722
|
+
*/
|
|
723
|
+
const runCutMove = useCallback(
|
|
724
|
+
(armed: ClipRecord, target: CellRange): PasteOutcome => {
|
|
725
|
+
const p = paramsRef.current
|
|
726
|
+
const adapter = p.adapter
|
|
727
|
+
const snapshot = armed.payload
|
|
728
|
+
const origin: CellCoord = { row: target.startRow, col: target.startCol }
|
|
729
|
+
// cut 経路の上限見積もりはタイルでなくソース寸法 + clear ぶん (§6.5.4-4 — タイル前提の
|
|
730
|
+
// 見積もりは過大で正当な move を誤拒否する)。寸法は snapshot のもの (読みは後)
|
|
731
|
+
const cellCount = snapshot.rows * snapshot.cols + countRangeCells([armed.range])
|
|
732
|
+
if (cellCount > p.maxOperationCells) {
|
|
733
|
+
p.onOperationRejected?.("paste", cellCount, "limit")
|
|
734
|
+
return "rejected" // クリップは維持
|
|
735
|
+
}
|
|
736
|
+
// cut の貼り付けはタイリングしない (Excel 準拠)。クリップ発生 = 拒否 (§6.5.4-4 —
|
|
737
|
+
// 許すと「はみ出したセルは clear だけされて書かれない」静かなデータ喪失)
|
|
738
|
+
if (origin.row + snapshot.rows > adapter.rowCount || origin.col + snapshot.cols > adapter.colCount) {
|
|
739
|
+
p.onOperationRejected?.("paste", cellCount, "bounds")
|
|
740
|
+
return "rejected" // クリップは維持 — consumer は「シート端を超えて貼り付けできません」を出せる
|
|
741
|
+
}
|
|
742
|
+
// 元レンジの staleness 検査: Ctrl+X 後の行 / 列削除で元レンジが今の軸から外れ得る。遅延読みと
|
|
743
|
+
// clear がグリッド外へ触れる前に文書化済みの bounds 規則で拒否する (トランザクション途中の
|
|
744
|
+
// throw / 範囲外 clear の送出にはしない — クリップは維持)
|
|
745
|
+
if (armed.range.endRow >= armed.sourceAdapter.rowCount || armed.range.endCol >= armed.sourceAdapter.colCount) {
|
|
746
|
+
p.onOperationRejected?.("paste", cellCount, "bounds")
|
|
747
|
+
return "rejected"
|
|
748
|
+
}
|
|
749
|
+
// 遅延読み: Ctrl+X 後の書式 / 注釈 commit を運ぶ (ゲートは snapshot 寸法で済んでいる)
|
|
750
|
+
const payload = armedPayload(armed)
|
|
751
|
+
const caps = aspectCapabilities(adapter)
|
|
752
|
+
const shaped = stripUndeclaredAspects(payload, caps)
|
|
753
|
+
const cursor: PasteCursor = {
|
|
754
|
+
row: 0,
|
|
755
|
+
col: 0,
|
|
756
|
+
readText: () => resolveEditText(adapter, cursor.row, cursor.col).text,
|
|
757
|
+
readFormat: () => (adapter.getCellFormat === undefined ? undefined : adapter.getCellFormat(cursor.row, cursor.col)),
|
|
758
|
+
}
|
|
759
|
+
// 1×1 ターゲット = 原点から 1 回だけ展開 (境界は上で検査済みなので切り捨ては起きない)。
|
|
760
|
+
// move は数式を原文のまま運ぶ (Excel / Sheets: cut-paste は相対参照を再アンカーしない) —
|
|
761
|
+
// R1C1 の解決座標を移動先でなく元セルへ置けば toR1C1 → fromR1C1 が恒等になり原文が動く。
|
|
762
|
+
// cursor の thunk (readText / readFormat) は素の all モード (演算 / 注入なし) では呼ばれない
|
|
763
|
+
const { edits: moves, tiles } = expandRichPaste(shaped.cells, makeRange(origin, origin), origin, adapter.rowCount, adapter.colCount, (cell, row, col) => {
|
|
764
|
+
cursor.row = armed.range.startRow + (row - origin.row)
|
|
765
|
+
cursor.col = armed.range.startCol + (col - origin.col)
|
|
766
|
+
return patchFromClipCell(cell, shaped, cursor, DEFAULT_PASTE_OPTIONS, caps)
|
|
767
|
+
})
|
|
768
|
+
// clear は元アダプタが宣言する側面の全 null (v0.7 アダプタは text: "" だけ)
|
|
769
|
+
const sourceCaps = aspectCapabilities(armed.sourceAdapter)
|
|
770
|
+
const clears: CellEdit[] = []
|
|
771
|
+
enumerateRangeCells([armed.range], (cell) => {
|
|
772
|
+
clears.push(clearPatch(cell.row, cell.col, sourceCaps))
|
|
773
|
+
})
|
|
774
|
+
if (armed.sourceAdapter === adapter) {
|
|
775
|
+
// 同一シート: clear → 書込の順に積む (重なりは後勝ち = §6.2.3 の契約)。同一トランザクション
|
|
776
|
+
if (!commitPasteEdits([...clears, ...moves], { sameSheet: true, conditionalFormats: [] })) {
|
|
777
|
+
return "rejected" // 失敗時はクリップも選択も不変 (ラッパーは成功時のみ discard する)
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
// 跨ぎ: 対象への書込が先 (CF は翻訳して同伴)、成功したら元シートを同一 transaction id で clear。
|
|
781
|
+
// 元の規則は動かさない (逸脱行)
|
|
782
|
+
const transaction = p.commitPaste(moves, { sameSheet: false, conditionalFormats: translateConditionalFormats(shaped.conditionalFormats, tiles) })
|
|
783
|
+
if (transaction === null) {
|
|
784
|
+
return "rejected"
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
armed.sourceAdapter.applyEdits(clears, { transaction, source: "paste", sameSheet: false, conditionalFormats: [] })
|
|
788
|
+
} catch (error) {
|
|
789
|
+
// 対象は立っている — 元側の拒否だけを通知する
|
|
790
|
+
p.onEditRejected?.(error, { source: "paste" })
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// 選択を移動先の単一矩形へ (active = anchor = 原点)。rearm はラッパー第 5 段が担う
|
|
794
|
+
p.setSelection({ ranges: [makeRange(origin, { row: origin.row + payload.rows - 1, col: origin.col + payload.cols - 1 })], anchor: origin, active: origin })
|
|
795
|
+
return "applied"
|
|
796
|
+
},
|
|
797
|
+
[commitPasteEdits],
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* The one paste pipeline of both channels: detect → estimate before materialising (ADR-12) →
|
|
802
|
+
* read the ONE detected source (clip-id identity reuses the armed payload; a cut hit under a
|
|
803
|
+
* plain paste is the cut-move) → transpose / strip undeclared aspects → tile through
|
|
804
|
+
* `expandRichPaste` with `patchFromClipCell` → conditional formats per tile (tiles × rule
|
|
805
|
+
* ranges gated by the same cap BEFORE materialising — the CF write channel is independent of
|
|
806
|
+
* the cell count) → commit (an empty
|
|
807
|
+
* transaction never reaches the adapter) → sizes → selection to the paste rectangle.
|
|
808
|
+
* 両経路共通の貼り付け経路: 検出 → 実体化前の見積もり (ADR-12) → 検出した唯一のソースを読む
|
|
809
|
+
* (clip id 一致は装填 payload を再利用、素の貼り付けでの cut 一致は cut-move) → 転置 / 未宣言側面の
|
|
810
|
+
* 除去 → `expandRichPaste` + `patchFromClipCell` でタイル → タイル毎の条件付き書式 (タイル数 ×
|
|
811
|
+
* レンジ数を実体化**前**に同じ上限で門番 — CF 書込チャネルはセル数と独立) → commit (空
|
|
812
|
+
* トランザクションはアダプタへ届かない) → 寸法 → 選択を貼り付け矩形へ。
|
|
813
|
+
*/
|
|
814
|
+
const runPaste = useCallback(
|
|
815
|
+
(flavours: ClipFlavours, channel: ClipChannel, options: PasteOptions): PasteOutcome => {
|
|
816
|
+
const p = paramsRef.current
|
|
817
|
+
const adapter = p.adapter
|
|
818
|
+
/** Fires the refusal channel and resolves the paste as rejected. / 拒否チャネルを鳴らし貼り付けを rejected で終える。 */
|
|
819
|
+
const unavailable = (cause: ClipboardUnavailableCause): PasteOutcome => {
|
|
820
|
+
p.onClipboardUnavailable?.(cause)
|
|
821
|
+
return "rejected"
|
|
822
|
+
}
|
|
823
|
+
const source = detectClipSource(flavours, channel)
|
|
824
|
+
if (source === "none") {
|
|
825
|
+
// イベント経路は v0.7 と同じ無言 no-op (画像等のみのクリップボード)、メニュー経路は「空」を通知
|
|
826
|
+
return channel === "menu" ? unavailable("empty") : "empty"
|
|
827
|
+
}
|
|
828
|
+
const selection = p.getSelection()
|
|
829
|
+
const target = selection.ranges[selection.ranges.length - 1]
|
|
830
|
+
const transpose = options.transpose === true
|
|
831
|
+
/** ADR-12 gate on dimensions (transposed shape when transposing). / 寸法での ADR-12 ゲート (転置時は転置後の形)。 */
|
|
832
|
+
const overLimit = (rows: number, cols: number): boolean => {
|
|
833
|
+
const estimate = estimateTiledCells(transpose ? cols : rows, transpose ? rows : cols, target)
|
|
834
|
+
if (estimate > p.maxOperationCells) {
|
|
835
|
+
p.onOperationRejected?.("paste", estimate, "limit")
|
|
836
|
+
return true
|
|
837
|
+
}
|
|
838
|
+
return false
|
|
839
|
+
}
|
|
840
|
+
let payload: ClipPayload
|
|
841
|
+
/** The armed record when the source IS the armed payload (clip-id identity hit). / ソースが装填 payload そのもの (clip id 一致) のときの装填記録。 */
|
|
842
|
+
let own: ClipRecord | null = null
|
|
843
|
+
if (source === "aqsp") {
|
|
844
|
+
// ヘッダだけを読む (本文 parse なし): 同一性判定と見積もりの両方がここで済む
|
|
845
|
+
const header = readClipSourceHeader(flavours, channel)
|
|
846
|
+
if (header === null) {
|
|
847
|
+
return unavailable("unreadable")
|
|
848
|
+
}
|
|
849
|
+
const pending = clipRef.current
|
|
850
|
+
if (pending !== null && header.clipId === pending.clipId) {
|
|
851
|
+
own = pending
|
|
852
|
+
}
|
|
853
|
+
// cut-move は clip id 同一性 + 素の貼り付けだけ (paste special の cut は copy 意味論)
|
|
854
|
+
if (own !== null && own.kind === "cut" && isPlainPaste(options)) {
|
|
855
|
+
return runCutMove(own, target)
|
|
856
|
+
}
|
|
857
|
+
if (overLimit(header.rows, header.cols)) {
|
|
858
|
+
return "rejected"
|
|
859
|
+
}
|
|
860
|
+
if (own !== null) {
|
|
861
|
+
// cut の identity ヒットは元レンジを今から再読する (Paste Special の copy 意味論でも) —
|
|
862
|
+
// 装填後の行 / 列削除で元レンジが元アダプタの今の軸から外れていたら、再読が範囲外へ
|
|
863
|
+
// 触れる前に文書化済みの bounds 規則で拒否する (runCutMove の staleness 検査と対)
|
|
864
|
+
if (own.kind === "cut" && (own.range.endRow >= own.sourceAdapter.rowCount || own.range.endCol >= own.sourceAdapter.colCount)) {
|
|
865
|
+
p.onOperationRejected?.("paste", own.payload.rows * own.payload.cols, "bounds")
|
|
866
|
+
return "rejected"
|
|
867
|
+
}
|
|
868
|
+
// identity ヒット: parse しない — copy は snapshot、cut は元レンジの遅延読み (copy 意味論の Paste Special でも今のセル)
|
|
869
|
+
payload = armedPayload(own)
|
|
870
|
+
} else {
|
|
871
|
+
const parsed = readClipPayload(flavours, channel)
|
|
872
|
+
if (parsed === null) {
|
|
873
|
+
return unavailable("unreadable")
|
|
874
|
+
}
|
|
875
|
+
// 実寸をタイル込みで再ゲート (html 経路と対称の第 2 の壁): ヘッダは先頭 512 code unit の
|
|
876
|
+
// 正規表現読みで本文と独立 — 重複キー等で寸法を過小申告した敵対 payload が JSON.parse 側
|
|
877
|
+
// では実寸を持ち、ヘッダゲートだけだと maxOperationCells を素通りする
|
|
878
|
+
if (overLimit(parsed.rows, parsed.cols)) {
|
|
879
|
+
return "rejected"
|
|
880
|
+
}
|
|
881
|
+
// 外来 JSON のリンクだけは信頼しない (HTML reader は正規化済み、装填 payload は自アダプタ由来)
|
|
882
|
+
payload = sanitizeLinks(parsed)
|
|
883
|
+
}
|
|
884
|
+
} else if (source === "tsv") {
|
|
885
|
+
const text = flavours.getData("text/plain")
|
|
886
|
+
// 寸法は同一文法の計数走査 (measureTsv) で実体化前に得る (ADR-12 — html 経路の見積もりと
|
|
887
|
+
// 対称の壁): 改行だけの巨大クリップボードは行列の実体化 (入力 1 byte あたり数百 byte) が
|
|
888
|
+
// 拒否より先に OOM するため、parseTsv はゲート通過後にだけ呼ぶ (走査は parse と同じ
|
|
889
|
+
// scanTsv 文法 — 寸法は常に一致し、quoted 内の埋め込み改行も行と数えない)
|
|
890
|
+
const dims = measureTsv(text)
|
|
891
|
+
if (overLimit(dims.rows, dims.cols)) {
|
|
892
|
+
return "rejected"
|
|
893
|
+
}
|
|
894
|
+
payload = payloadFromTsv(parseTsv(text))
|
|
895
|
+
} else {
|
|
896
|
+
// sheets / excel / html: 見積もりは 1 パスの上限値、DOMParser は上限内でしか構築しない
|
|
897
|
+
const html = flavours.getData("text/html")
|
|
898
|
+
const estimated = estimateHtmlTableCells(html)
|
|
899
|
+
// セル未出現の見積もり (cols = 0) は面積 0 でタイルゲートを素通りするが、DOM 構築と行走査は
|
|
900
|
+
// O(行数) — 行数だけで上限を超える表は reader も (r + 1) × max(cols, 1) の下界で必ず limit に
|
|
901
|
+
// なるため、DOMParser を建てる前に同じ拒否を返す (ADR-12: DOMParser は上限内でしか構築しない)
|
|
902
|
+
if (estimated.cols === 0 && estimated.rows > p.maxOperationCells) {
|
|
903
|
+
p.onOperationRejected?.("paste", estimated.rows, "limit")
|
|
904
|
+
return "rejected"
|
|
905
|
+
}
|
|
906
|
+
if (overLimit(estimated.rows, estimated.cols)) {
|
|
907
|
+
return "rejected"
|
|
908
|
+
}
|
|
909
|
+
// 能力分岐: 宣言通貨 / locale は formats 宣言時だけ渡す。reader 自身も上限で止まる (見積もりが
|
|
910
|
+
// 見ないもの — 属性値の実体参照等 — に対する ADR-12 の第 2 の壁)
|
|
911
|
+
const read = parseHtmlTable(html, source, p.formats === undefined ? {} : { currency: p.formats.currency, locale: p.formats.locale }, p.maxOperationCells)
|
|
912
|
+
if (read.outcome === "none") {
|
|
913
|
+
return unavailable("unreadable")
|
|
914
|
+
}
|
|
915
|
+
if (read.outcome === "limit") {
|
|
916
|
+
p.onOperationRejected?.("paste", read.cells, "limit")
|
|
917
|
+
return "rejected"
|
|
918
|
+
}
|
|
919
|
+
// 実寸をタイル込みで再ゲート (見積もりより広い実表はタイル数を変え得る)
|
|
920
|
+
if (overLimit(read.payload.rows, read.payload.cols)) {
|
|
921
|
+
return "rejected"
|
|
922
|
+
}
|
|
923
|
+
payload = read.payload
|
|
924
|
+
}
|
|
925
|
+
const caps = aspectCapabilities(adapter)
|
|
926
|
+
const shaped = stripUndeclaredAspects(transpose ? transposePayload(payload) : payload, caps)
|
|
927
|
+
const aspects = MODE_ASPECTS[options.mode]
|
|
928
|
+
const cursor: PasteCursor = {
|
|
929
|
+
row: 0,
|
|
930
|
+
col: 0,
|
|
931
|
+
readText: () => resolveEditText(adapter, cursor.row, cursor.col).text,
|
|
932
|
+
readFormat: () => (adapter.getCellFormat === undefined ? undefined : adapter.getCellFormat(cursor.row, cursor.col)),
|
|
933
|
+
}
|
|
934
|
+
const { edits, tiles } = expandRichPaste(shaped.cells, target, selection.active, adapter.rowCount, adapter.colCount, (cell, row, col) => {
|
|
935
|
+
cursor.row = row
|
|
936
|
+
cursor.col = col
|
|
937
|
+
return patchFromClipCell(cell, shaped, cursor, options, caps)
|
|
938
|
+
})
|
|
939
|
+
const rect = pasteRect(tiles, shaped.rows, shaped.cols, adapter.rowCount, adapter.colCount)
|
|
940
|
+
if (rect === null) {
|
|
941
|
+
return "empty" // 全クリップ (原点自体がグリッド外) — 書き込みゼロなら選択も動かさない (v0.7 と同じ無言)
|
|
942
|
+
}
|
|
943
|
+
// CF は「タイル数 × 規則のレンジ数」で実体化する第 2 の書込チャネル (セル数ゲートと独立) —
|
|
944
|
+
// 外来 payload の規則洪水やタイル倍加が maxOperationCells を素通りしないよう、平行移動の
|
|
945
|
+
// 実体化前に同じ上限で門番する (未宣言 CF は stripUndeclaredAspects が空にしている)
|
|
946
|
+
if (aspects.cf && shaped.conditionalFormats.length > 0) {
|
|
947
|
+
let ruleRanges = 0
|
|
948
|
+
for (const rule of shaped.conditionalFormats) {
|
|
949
|
+
ruleRanges += rule.ranges.length
|
|
950
|
+
}
|
|
951
|
+
const cfCount = tiles.length * ruleRanges
|
|
952
|
+
if (cfCount > p.maxOperationCells) {
|
|
953
|
+
p.onOperationRejected?.("paste", cfCount, "limit")
|
|
954
|
+
return "rejected"
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const conditionalFormats = aspects.cf ? translateConditionalFormats(shaped.conditionalFormats, tiles) : []
|
|
958
|
+
if (edits.length === 0 && conditionalFormats.length === 0) {
|
|
959
|
+
// 書き込みゼロ (全セル skip / セル側面のないモード) — 空トランザクションはアダプタへ届けず選択も
|
|
960
|
+
// 動かさない。寸法 (非 undo 幾何) だけは書く
|
|
961
|
+
applyPasteSizes(shaped, rect, aspects)
|
|
962
|
+
return "empty"
|
|
963
|
+
}
|
|
964
|
+
// クリップの存続判定 (discardClipOnCommit): 装填 payload 自身の paste は種別を問わず温存、他ソースの
|
|
965
|
+
// paste は copy だけ温存 (v0.7 規則)。sameSheet = 装填 payload が現アダプタ由来
|
|
966
|
+
pasteInFlightRef.current = own === null ? "foreign" : "own"
|
|
967
|
+
let committed = false
|
|
968
|
+
try {
|
|
969
|
+
committed = commitPasteEdits(edits, { sameSheet: own !== null && own.sourceAdapter === adapter, conditionalFormats })
|
|
970
|
+
} finally {
|
|
971
|
+
pasteInFlightRef.current = "none"
|
|
972
|
+
}
|
|
973
|
+
if (!committed) {
|
|
974
|
+
return "rejected"
|
|
975
|
+
}
|
|
976
|
+
applyPasteSizes(shaped, rect, aspects)
|
|
977
|
+
// 選択は実書込範囲の単一矩形 (名目 outRows × outCols でなく切り捨て後 — §6.5.4-5)
|
|
978
|
+
const origin: CellCoord = { row: rect.startRow, col: rect.startCol }
|
|
979
|
+
p.setSelection({ ranges: [rect], anchor: origin, active: origin })
|
|
980
|
+
return "applied"
|
|
981
|
+
},
|
|
982
|
+
[runCutMove, commitPasteEdits, applyPasteSizes],
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Menu path: one `read({ unsanitized: ["text/html"] })`, the present `text/html` / `text/plain`
|
|
987
|
+
* blobs materialised BEFORE detection (the flavour reader must be synchronous), then the common
|
|
988
|
+
* pipeline. Every refusal of the read itself — including a `getType()` / `Blob.text()`
|
|
989
|
+
* rejection during flavour materialisation (`"unreadable"`) — fires `onClipboardUnavailable`
|
|
990
|
+
* and resolves `"rejected"` (the promise never rejects); the event-path freeze / editing /
|
|
991
|
+
* disabled gates apply at write time.
|
|
992
|
+
* メニュー経路: `read({ unsanitized: ["text/html"] })` 1 回、在る `text/html` / `text/plain` の
|
|
993
|
+
* Blob を検出前に実体化 (フレーバー読みは同期でなければならない)、以後は共通経路。読み自体の
|
|
994
|
+
* 拒否は全て — フレーバー実体化中の `getType()` / `Blob.text()` の拒否 (`"unreadable"`) も含め —
|
|
995
|
+
* `onClipboardUnavailable` を鳴らして `"rejected"` (promise は決して reject しない)。イベント
|
|
996
|
+
* 経路の凍結 / 編集中 / 無効ゲートは書込時点で適用。
|
|
997
|
+
*/
|
|
998
|
+
const paste = useCallback(
|
|
999
|
+
async (options: PasteOptions): Promise<PasteOutcome> => {
|
|
1000
|
+
const unavailable = (cause: ClipboardUnavailableCause): PasteOutcome => {
|
|
1001
|
+
paramsRef.current.onClipboardUnavailable?.(cause)
|
|
1002
|
+
return "rejected"
|
|
1003
|
+
}
|
|
1004
|
+
// 非セキュアオリジン / 非対応環境では navigator.clipboard が無い
|
|
1005
|
+
const clipboardHost: { clipboard?: UnsanitizedClipboardReader } = navigator
|
|
1006
|
+
if (clipboardHost.clipboard === undefined) {
|
|
1007
|
+
return unavailable("insecure-origin")
|
|
1008
|
+
}
|
|
1009
|
+
let items: ClipboardItems
|
|
1010
|
+
try {
|
|
1011
|
+
items = await clipboardHost.clipboard.read({ unsanitized: ["text/html"] })
|
|
1012
|
+
} catch {
|
|
1013
|
+
return unavailable("permission-denied")
|
|
1014
|
+
}
|
|
1015
|
+
if (items.length === 0) {
|
|
1016
|
+
return unavailable("empty")
|
|
1017
|
+
}
|
|
1018
|
+
const item = items[0]
|
|
1019
|
+
const types = Array.from(item.types)
|
|
1020
|
+
const texts = new Map<string, string>()
|
|
1021
|
+
// 非同期 read と Blob 実体化の間にクリップボードが差し替わると getType / text() は拒否で終わる —
|
|
1022
|
+
// 実体化の失敗も「読み自体の拒否」なので unreadable として解決する (try の外だと paste() 自体が
|
|
1023
|
+
// reject し、呼び手の void 呼びで unhandledrejection になる)
|
|
1024
|
+
try {
|
|
1025
|
+
for (const type of MENU_FLAVOURS) {
|
|
1026
|
+
if (types.includes(type)) {
|
|
1027
|
+
texts.set(type, await (await item.getType(type)).text())
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
} catch {
|
|
1031
|
+
return unavailable("unreadable")
|
|
1032
|
+
}
|
|
1033
|
+
const flavours: ClipFlavours = {
|
|
1034
|
+
types,
|
|
1035
|
+
getData: (type) => {
|
|
1036
|
+
const text = texts.get(type)
|
|
1037
|
+
// 検出は実体化済みフレーバーにしか触れない — それ以外の要求は経路のバグ
|
|
1038
|
+
if (text === undefined) {
|
|
1039
|
+
throw new RangeError(`[spreadsheet] clipboard flavour ${type} was not materialised for the menu path`)
|
|
1040
|
+
}
|
|
1041
|
+
return text
|
|
1042
|
+
},
|
|
1043
|
+
}
|
|
1044
|
+
// 非同期読みの間に状態は変わり得る — 書込時点でイベント経路と同じゲートを見る
|
|
1045
|
+
const p = paramsRef.current
|
|
1046
|
+
if (!p.enabled || p.isDragging() || p.isEditing()) {
|
|
1047
|
+
return "rejected"
|
|
1048
|
+
}
|
|
1049
|
+
return runPaste(flavours, "menu", options)
|
|
1050
|
+
},
|
|
1051
|
+
[runPaste],
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
const { enabled, sinkRef } = params
|
|
1055
|
+
useEffect(() => {
|
|
1056
|
+
const sink = sinkRef.current
|
|
1057
|
+
if (!enabled || sink === null) {
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ドラッグ凍結 (keydown 面の consume と対称): メニュー / AT 経由の clipboard 操作が飛行中
|
|
1062
|
+
// ドラッグの選択や adapter を変異させない。preventDefault で ネイティブ動作 (シンクの " "
|
|
1063
|
+
// コピー / 巨大テキスト挿入) も遮断する
|
|
1064
|
+
const consumeIfDragging = (event: ClipboardEvent): boolean => {
|
|
1065
|
+
if (paramsRef.current.isDragging()) {
|
|
1066
|
+
event.preventDefault()
|
|
1067
|
+
return true
|
|
1068
|
+
}
|
|
1069
|
+
return false
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const onCopy = (event: ClipboardEvent) => {
|
|
1073
|
+
const p = paramsRef.current
|
|
1074
|
+
if (consumeIfDragging(event)) {
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
if (p.isEditing()) {
|
|
1078
|
+
return // 編集中は textarea ネイティブへ (§6.5.4-1)
|
|
1079
|
+
}
|
|
1080
|
+
const result = serializeCurrentRange(event, "copy")
|
|
1081
|
+
if (result !== null) {
|
|
1082
|
+
// 成功 copy はアンツを装填し直す (v0.3.2 — Excel / Sheets の copy マーキー)。旧 cut
|
|
1083
|
+
// クリップの置換も兼ねる (§6.5.4-4 — 置換しないと cut → Ctrl+C → Ctrl+V の clip id が
|
|
1084
|
+
// 一致して「コピーのつもりが move (元セル clear)」の静かなデータ喪失)。拒否 (null) 時は
|
|
1085
|
+
// 何も起きていないので既存クリップ維持
|
|
1086
|
+
armClip("copy", result.range, result.payload)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const onCut = (event: ClipboardEvent) => {
|
|
1091
|
+
const p = paramsRef.current
|
|
1092
|
+
if (consumeIfDragging(event)) {
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
if (p.isEditing()) {
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
const result = serializeCurrentRange(event, "cut")
|
|
1099
|
+
if (result === null) {
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
// 次の copy / cut は破棄条件 (§6.5.4-4) — 新しい cut クリップで置き換える
|
|
1103
|
+
armClip("cut", result.range, result.payload)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
1107
|
+
const p = paramsRef.current
|
|
1108
|
+
if (consumeIfDragging(event)) {
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
if (p.isEditing()) {
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
// preventDefault 必須 (§6.5.4-5) — 無いと数 MB の TSV がシンクへネイティブ挿入される
|
|
1115
|
+
event.preventDefault()
|
|
1116
|
+
// 武装済みオプション (Ctrl+Shift+V の values モード) は 1 回で消費。ref 未結線 (能力不在) は素の Ctrl+V
|
|
1117
|
+
let options: PasteOptions = DEFAULT_PASTE_OPTIONS
|
|
1118
|
+
const armedRef = p.pendingPasteOptionsRef
|
|
1119
|
+
if (armedRef !== undefined && armedRef.current !== null) {
|
|
1120
|
+
options = armedRef.current
|
|
1121
|
+
armedRef.current = null
|
|
1122
|
+
}
|
|
1123
|
+
const data = clipboardDataOf(event)
|
|
1124
|
+
// clipboardData の無い paste イベントは読むものが無い (v0.7 の空文字 no-op と同じ)
|
|
1125
|
+
if (data === null) {
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
runPaste(flavoursOf(data), "event", options)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
sink.addEventListener("copy", onCopy)
|
|
1132
|
+
sink.addEventListener("cut", onCut)
|
|
1133
|
+
sink.addEventListener("paste", onPaste)
|
|
1134
|
+
return () => {
|
|
1135
|
+
sink.removeEventListener("copy", onCopy)
|
|
1136
|
+
sink.removeEventListener("cut", onCut)
|
|
1137
|
+
sink.removeEventListener("paste", onPaste)
|
|
1138
|
+
}
|
|
1139
|
+
}, [enabled, sinkRef, serializeCurrentRange, armClip, runPaste])
|
|
1140
|
+
|
|
1141
|
+
return useMemo(
|
|
1142
|
+
() => ({
|
|
1143
|
+
getClip: () => {
|
|
1144
|
+
const clip = clipRef.current
|
|
1145
|
+
return clip === null ? null : { kind: clip.kind, range: clip.range, foreign: clip.sourceAdapter !== paramsRef.current.adapter }
|
|
1146
|
+
},
|
|
1147
|
+
discardClip,
|
|
1148
|
+
discardClipOnCommit,
|
|
1149
|
+
onAdapterSwitch,
|
|
1150
|
+
paste,
|
|
1151
|
+
}),
|
|
1152
|
+
[discardClip, discardClipOnCommit, onAdapterSwitch, paste],
|
|
1153
|
+
)
|
|
1154
|
+
}
|