@aiquants/spreadsheet 0.15.0 → 0.16.1
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 +105 -0
- package/README.md +209 -23
- package/dist/index.cjs +8 -8
- package/dist/index.d.cts +123 -5
- package/dist/index.d.ts +123 -5
- package/dist/index.js +4315 -4166
- package/dist/styles/spreadsheet.css +1 -1
- package/dist/styles/spreadsheet.standalone.css +1 -1
- package/dist/tsv.cjs +8 -1
- package/dist/tsv.d.cts +79 -4
- package/dist/tsv.d.ts +79 -4
- package/dist/tsv.js +85 -3
- package/dist/view-BkwCUzTl.cjs +1 -0
- package/dist/view-DycJZi_t.js +41 -0
- package/dist/view.cjs +1 -1
- package/dist/view.d.cts +31 -7
- package/dist/view.d.ts +31 -7
- package/dist/view.js +12 -12
- package/package.json +3 -3
- package/src/Spreadsheet.tsx +110 -14
- package/src/clipboard/tsv.ts +236 -89
- package/src/clipboard/useClipboard.ts +3 -2
- package/src/commands/useCommands.ts +43 -1
- package/src/grid/GridBody.tsx +51 -6
- package/src/header/resizeGates.ts +34 -0
- package/src/header/useHeaderResize.ts +83 -16
- package/src/index.ts +1 -1
- package/src/keyboard/shortcuts.ts +67 -0
- package/src/model/dataAdapter.ts +74 -0
- package/src/overlay/SelectionOverlay.tsx +22 -2
- package/src/styles/spreadsheet.css +193 -123
- package/src/tsv.ts +17 -10
- package/src/view.ts +67 -14
- package/dist/tsv-B6TDExlk.js +0 -91
- package/dist/tsv-BHXbWK5W.cjs +0 -8
package/src/clipboard/tsv.ts
CHANGED
|
@@ -1,34 +1,106 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Clipboard TSV serialize/parse
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* @fileoverview Clipboard TSV serialize/parse (§6.5.1) whose round trip is exact for every
|
|
3
|
+
* rectangular matrix of strings of at least 1 × 1: `parseTsv(serializeTsv(m))` deep-equals `m`.
|
|
4
|
+
* `serializeClipboardTsv` is the clipboard's text/plain writer (the grid's copy, and a host's own
|
|
5
|
+
* copy button): Excel's form, which quotes only fields holding a tab, CR or LF, so a plain-text
|
|
6
|
+
* target receives `"Important" note` as typed (the grid's own paste reads its JSON payload first, so
|
|
7
|
+
* a grid-to-grid paste stays exact). Files and stored bodies use the strict `serializeTsv`. The writer quotes a
|
|
8
|
+
* field (doubling its inner `"`) when it holds a tab, CR or LF — the only cells Excel quotes — OR
|
|
9
|
+
* when it starts with `"`, the one other cell a reader could mistake for a quoted field (a ditto
|
|
10
|
+
* mark `"` beside another one would otherwise read back as a single cell). The reader adopts the
|
|
11
|
+
* quoted reading only when the quoted field closes properly (the closing quote is followed by a
|
|
12
|
+
* tab, a line break or the end) AND its unescaped content holds a tab, CR, LF or `"` — exactly the
|
|
13
|
+
* fields the writer quotes. Everything else is a literal, so Excel's raw output keeps reading as
|
|
14
|
+
* before: a cell Excel emitted untouched such as `"hi"` (content without any of the four) stays
|
|
15
|
+
* `"hi"`. The one raw shape that changed reading is a cell that looks exactly like a quoted field
|
|
16
|
+
* holding a doubled quote (a literal `"a""b"` now reads `a"b`), which no Excel cell produces
|
|
17
|
+
* unquoted. Every reader shares ONE grammar walk (`scanTsv`): `parseTsv` materialises the matrix,
|
|
18
|
+
* `measureTsv` only counts rows × columns so the ADR-12 paste gate can refuse an over-cap
|
|
19
|
+
* clipboard BEFORE any row array exists (a newline-only clipboard costs ~hundreds of bytes of
|
|
20
|
+
* matrix per input byte when materialised), and `iterateTsvPrefixRows` reads a text that may be
|
|
21
|
+
* only a TSV's beginning, stopping where the rest could change a row: at a row without its line
|
|
22
|
+
* terminator, and at a quote the text does not close, which the whole text reads as a literal.
|
|
10
23
|
*
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
24
|
+
* クリップボード TSV の直列化 / 解析 (§6.5.1)。文字列の少なくとも 1 × 1 のどの矩形行列でも往復が厳密
|
|
25
|
+
* (`parseTsv(serializeTsv(m))` が `m` と深く等しい)。`serializeClipboardTsv` はクリップボードの text/plain の
|
|
26
|
+
* 書き手 (グリッドのコピーと、ホスト自身のコピーのボタン) で、Excel の形 (タブ・CR・LF を含む値だけを囲む) なので、平文の
|
|
27
|
+
* 貼り先には `"Important" note` がそのまま届く (グリッド自身の貼り付けは JSON の payload を先に読むので、グリッドからグリッドへの
|
|
28
|
+
* 貼り付けは厳密なまま)。ファイルと保存する本文は厳密な `serializeTsv` を使う。書き手はタブ・CR・LF を含む値 (Excel が引用符で
|
|
29
|
+
* 囲む唯一のセル) に加え、`"` で始まる値 (読み手が引用されたフィールドと取り違え得る唯一の残り。
|
|
30
|
+
* 同じ記号 `"` が 2 つ並ぶと 1 つのセルに読み戻される) も引用符で囲み、中の `"` を二重化する。
|
|
31
|
+
* 読み手が引用の読みを採るのは、引用が正しく閉じ (閉じの引用符の直後がタブ・改行・終端)、かつ
|
|
32
|
+
* 剥がした中身がタブ・CR・LF・`"` のどれかを含むとき — 書き手が囲む値とちょうど同じ — だけ。
|
|
33
|
+
* それ以外は文字どおりの値なので、Excel の素の出力は従来どおりに読める (Excel が囲まずに出した
|
|
34
|
+
* `"hi"` は中身に 4 つのどれも無いため `"hi"` のまま)。読みが変わった素の形は、二重化した引用符を
|
|
35
|
+
* 持つ引用のフィールドとそっくりな値 (文字どおりの `"a""b"` は `a"b` と読む) だけで、Excel が囲まずに
|
|
36
|
+
* 出すセルには無い。全ての読み手は唯一の文法走査 (`scanTsv`) を共有: `parseTsv` は行列を実体化し、
|
|
37
|
+
* `measureTsv` は行 × 列だけを数える — ADR-12 の paste ゲートが上限超のクリップボードを行配列の
|
|
38
|
+
* 実体化**前**に拒否できる (改行だけのクリップボードは実体化で入力 1 byte あたり数百 byte の行列費用になる)。
|
|
39
|
+
* `iterateTsvPrefixRows` は TSV の先頭だけかもしれない文字を読み、続きが行を変えうる所で止まる (改行の無い行と、
|
|
40
|
+
* 文字が閉じない引用。文字全体ではこれを literal と読む)。
|
|
18
41
|
*/
|
|
19
42
|
|
|
20
43
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
44
|
+
* Joins escaped fields with tabs, rows with CRLF, and ends with a CRLF.
|
|
45
|
+
* 囲みを済ませた値をタブで、行を CRLF でつなぎ、CRLF で終える処理。
|
|
46
|
+
*
|
|
47
|
+
* @param matrix - The rows of cell strings. セルの文字列の行。
|
|
48
|
+
* @param escapeField - The field writer. 値の書き手。
|
|
49
|
+
* @returns The TSV text. TSV の文字列。
|
|
23
50
|
*/
|
|
24
|
-
|
|
51
|
+
const joinTsv = (matrix: readonly (readonly string[])[], escapeField: (field: string) => string): string =>
|
|
25
52
|
// 終端 CRLF は必須 (Excel 実挙動)。parseTsv の「終端の改行 1 つを剥がす」規約と対 —
|
|
26
53
|
// 終端なしだと [["a"],[""]] が [["a"]] に潰れ (cut-move で clear されるべき先に旧値が残る)、
|
|
27
54
|
// [[""]] は "" になって §6.5.4-5 の空ガードに飲まれ「空セル貼り付けで clear」が不発になる
|
|
28
|
-
`${matrix.map((row) => row.map(
|
|
55
|
+
`${matrix.map((row) => row.map(escapeField).join("\t")).join("\r\n")}\r\n`
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serializes a matrix to TSV with a trailing CRLF; `parseTsv` reads the result back to the same
|
|
59
|
+
* matrix for every rectangular matrix of strings of at least 1 × 1 (the strict writer the package
|
|
60
|
+
* publishes; a host stores tables with it).
|
|
61
|
+
* 行列を終端 CRLF つきの TSV へ直列化する処理。文字列の少なくとも 1 × 1 のどの矩形行列でも `parseTsv` が同じ行列へ
|
|
62
|
+
* 読み戻す (パッケージが公開する厳密な書き手。ホストは表をこれで保存する)。
|
|
63
|
+
*
|
|
64
|
+
* @param matrix - The rows of cell strings. セルの文字列の行。
|
|
65
|
+
* @returns The TSV text. TSV の文字列。
|
|
66
|
+
*/
|
|
67
|
+
export const serializeTsv = (matrix: readonly (readonly string[])[]): string => joinTsv(matrix, escapeTsvField)
|
|
29
68
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Serializes a matrix the way Excel writes its clipboard text, with a trailing CRLF: only a field
|
|
71
|
+
* holding a tab, CR or LF is quoted (inner quotes doubled), so a field that merely starts with `"`
|
|
72
|
+
* reaches a plain-text target as typed. Its round trip is not exact for such fields (a raw
|
|
73
|
+
* `"\t"\tBlue` reads back as two cells); the grid's copy writes text/plain with it (and so should a
|
|
74
|
+
* host's own copy control, to match), and the grid's own paste reads the JSON payload first. Files and
|
|
75
|
+
* stored bodies use `serializeTsv`.
|
|
76
|
+
* Excel がクリップボードの文字を書く形で行列を終端 CRLF つきに直列化する処理。囲むのはタブ・CR・LF を含む値だけ (中の
|
|
77
|
+
* `"` は二重化) なので、`"` で始まるだけの値は平文の貼り先へそのまま届く。そうした値の往復は厳密でない (素の
|
|
78
|
+
* `"\t"\tBlue` は 2 つのセルに読み戻る)。グリッドのコピーが text/plain をこれで書き (ホスト自身のコピーの部品も揃えて
|
|
79
|
+
* これで書く)、グリッド自身の貼り付けは JSON の payload を先に読む。ファイルと保存する本文は `serializeTsv` を使う。
|
|
80
|
+
*
|
|
81
|
+
* @param matrix - The rows of cell strings. セルの文字列の行。
|
|
82
|
+
* @returns The TSV text in Excel's form. Excel の形の TSV の文字列。
|
|
83
|
+
*/
|
|
84
|
+
export const serializeClipboardTsv = (matrix: readonly (readonly string[])[]): string => joinTsv(matrix, escapeClipboardTsvField)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Quotes a field that holds a tab, CR or LF or starts with `"` (inner quotes doubled); any other
|
|
88
|
+
* field is written verbatim.
|
|
89
|
+
* タブ・CR・LF を含むか `"` で始まる値を引用符で囲む処理 (中の `"` は二重化)。ほかの値はそのまま書く。
|
|
90
|
+
*
|
|
91
|
+
* @param field - The cell string. セルの文字列。
|
|
92
|
+
* @returns The field as written to TSV. TSV へ書く形の値。
|
|
93
|
+
*/
|
|
94
|
+
const escapeTsvField = (field: string): string => (/[\t\r\n]/.test(field) || field.startsWith('"') ? `"${field.replaceAll('"', '""')}"` : field)
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Quotes a field only when it holds a tab, CR or LF (inner quotes doubled) — the cells Excel quotes.
|
|
98
|
+
* タブ・CR・LF を含む値だけを引用符で囲む処理 (中の `"` は二重化) — Excel が囲むセル。
|
|
99
|
+
*
|
|
100
|
+
* @param field - The cell string. セルの文字列。
|
|
101
|
+
* @returns The field as Excel writes it. Excel が書く形の値。
|
|
102
|
+
*/
|
|
103
|
+
const escapeClipboardTsvField = (field: string): string => (/[\t\r\n]/.test(field) ? `"${field.replaceAll('"', '""')}"` : field)
|
|
32
104
|
|
|
33
105
|
/**
|
|
34
106
|
* Strips the single trailing line terminator (CRLF / LF / lone CR) — the last row's terminator,
|
|
@@ -37,38 +109,50 @@ const escapeTsvField = (field: string): string => (/[\t\r\n]/.test(field) ? `"${
|
|
|
37
109
|
*/
|
|
38
110
|
const stripTrailingTerminator = (text: string): string => (text.endsWith("\r\n") ? text.slice(0, -2) : text.endsWith("\n") || text.endsWith("\r") ? text.slice(0, -1) : text)
|
|
39
111
|
|
|
112
|
+
/** The token scanTsv yields after the last field of every row. / scanTsv が各行の最後の値の後に出す印。 */
|
|
113
|
+
const ROW_END: unique symbol = Symbol("row end")
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What a walk reads: `whole`, a whole TSV text, terminator-stripped and non-empty; `prefix`, a text that may be only
|
|
117
|
+
* the beginning of one (a file read partway), whose readings end at the first one the rest could change.
|
|
118
|
+
* 走査が読むもの。`whole` は TSV の文字全体 (終端を剥がした非空の文字)、`prefix` は TSV の先頭だけかもしれない文字 (途中まで読んだ
|
|
119
|
+
* ファイル) で、続きが変えうる最初の読みで終わる。
|
|
120
|
+
*/
|
|
121
|
+
type TsvExtent = "whole" | "prefix"
|
|
122
|
+
|
|
40
123
|
/**
|
|
41
|
-
* The ONE grammar walk
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
124
|
+
* The ONE grammar walk every reader shares (parseTsv through iterateTsvRows, measureTsv, and iterateTsvPrefixRows): yields
|
|
125
|
+
* each field of `body` in order and ROW_END after the last field of every row, adopting the quoted reading only under the
|
|
126
|
+
* two-part condition of readQuotedField. Of a `whole` text the last row ends with the text; of a `prefix` the walk stops
|
|
127
|
+
* at the first field whose quote the text does not close (the rest decides whether it is one quoted field or a literal
|
|
128
|
+
* one), and a row the text does not end with a line terminator gets no ROW_END, as the rest may continue it. Lazy:
|
|
129
|
+
* nothing past the token a consumer takes is read.
|
|
130
|
+
* 全ての読み手 (iterateTsvRows を通る parseTsv・measureTsv・iterateTsvPrefixRows) が共有する唯一の文法走査: `body` の値を順に
|
|
131
|
+
* 出し、各行の最後の値の後に ROW_END を出す。quoted 解釈の採否は readQuotedField の 2 条件だけ。`whole` の文字では最後の行が
|
|
132
|
+
* 文字とともに終わり、`prefix` では文字が閉じない引用の値で走査を止め (それが 1 つの引用の値か文字どおりの値かは続きが決める)、
|
|
133
|
+
* 文字が改行で終えない行には ROW_END を出さない (続きがその行を延ばしうる)。遅延で、受け手が取った印より先は読まない。
|
|
134
|
+
*
|
|
135
|
+
* @param body - The TSV text: terminator-stripped and non-empty for `whole`, as read for `prefix`. TSV の文字列 (`whole` は終端を剥がした非空の文字、`prefix` は読んだまま)。
|
|
136
|
+
* @param extent - Whether `body` is the whole text or only its beginning. `body` が文字全体か、その先頭だけか。
|
|
137
|
+
* @returns The field / row-end tokens. 値と行末の印。
|
|
47
138
|
*/
|
|
48
|
-
|
|
139
|
+
function* scanTsv(body: string, extent: TsvExtent): Generator<string | typeof ROW_END> {
|
|
49
140
|
let i = 0
|
|
50
141
|
while (true) {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
i = quoted.nextIndex
|
|
59
|
-
} else {
|
|
60
|
-
const end = nextDelimiter(body, i)
|
|
61
|
-
field = body.slice(i, end)
|
|
62
|
-
i = end
|
|
63
|
-
}
|
|
142
|
+
// quoted 解釈の採否は readQuotedField の 2 条件だけが決める (不成立は literal — Excel が囲まずに出した `"hi"` を壊さない)
|
|
143
|
+
const quoted = body[i] === '"' ? readQuotedField(body, i) : LITERAL_FIELD
|
|
144
|
+
// 閉じない引用は、続きが閉じれば 1 つの値、閉じなければ改行で割れる literal。先頭だけの文字ではまだ決まらない
|
|
145
|
+
if (quoted === OPEN_QUOTE && extent === "prefix") return
|
|
146
|
+
if (typeof quoted === "object") {
|
|
147
|
+
yield quoted.value
|
|
148
|
+
i = quoted.nextIndex
|
|
64
149
|
} else {
|
|
65
150
|
const end = nextDelimiter(body, i)
|
|
66
|
-
|
|
151
|
+
yield body.slice(i, end)
|
|
67
152
|
i = end
|
|
68
153
|
}
|
|
69
|
-
onField(field)
|
|
70
154
|
if (i >= body.length) {
|
|
71
|
-
|
|
155
|
+
if (extent === "whole") yield ROW_END
|
|
72
156
|
return
|
|
73
157
|
}
|
|
74
158
|
if (body[i] === "\t") {
|
|
@@ -76,32 +160,80 @@ const scanTsv = (body: string, onField: (field: string) => void, onRowEnd: () =>
|
|
|
76
160
|
} else {
|
|
77
161
|
// 改行 (CRLF / LF / CR)
|
|
78
162
|
i += body[i] === "\r" && body[i + 1] === "\n" ? 2 : 1
|
|
79
|
-
|
|
163
|
+
yield ROW_END
|
|
80
164
|
}
|
|
81
165
|
}
|
|
82
166
|
}
|
|
83
167
|
|
|
84
168
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
169
|
+
* Yields the rows of a TSV text one at a time, in exactly parseTsv's grammar (the same quoting rule, line terminators
|
|
170
|
+
* and final-terminator handling; empty text is one row with one empty cell), WITHOUT padding them to a common width. A
|
|
171
|
+
* consumer that stops early materialises nothing past the last row it took, so a large text can be previewed under a
|
|
172
|
+
* cell budget.
|
|
173
|
+
* TSV の文字列の行を 1 つずつ出す処理。parseTsv とちょうど同じ文法 (同じ引用の規則・改行・末尾の改行の扱い。空の文字は空のセル
|
|
174
|
+
* 1 つの 1 行) で、幅を揃える右の埋めはしない。途中で止めた受け手は、最後に取った行より先を何も実体化しないので、大きな文字を
|
|
175
|
+
* セルの予算の下で見られる。
|
|
176
|
+
*
|
|
177
|
+
* @param text - The TSV text. TSV の文字列。
|
|
178
|
+
* @returns The rows, unpadded. 埋めない行。
|
|
87
179
|
*/
|
|
88
|
-
export
|
|
180
|
+
export function* iterateTsvRows(text: string): Generator<string[]> {
|
|
89
181
|
const body = stripTrailingTerminator(text)
|
|
90
182
|
if (body === "") {
|
|
91
|
-
|
|
183
|
+
yield [""]
|
|
184
|
+
return
|
|
92
185
|
}
|
|
93
|
-
|
|
186
|
+
yield* rowsOf(scanTsv(body, "whole"))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Yields the rows of a text that may be only the beginning of a TSV (a file read partway, a stream not yet ended), in
|
|
191
|
+
* exactly iterateTsvRows's grammar, but only those the rest of the text cannot change: every row the text ends with a
|
|
192
|
+
* line terminator, up to the first field that opens a quote the text does not close (whether it is one quoted field or a
|
|
193
|
+
* literal one split at its line breaks is the rest's to decide). So the rows of any beginning of a TSV are the first
|
|
194
|
+
* rows of the whole: a reader of a stream can show them as final. Empty text yields no row. Unpadded.
|
|
195
|
+
* TSV の先頭だけかもしれない文字 (途中まで読んだファイル、まだ終わらないストリーム) の行を、iterateTsvRows とちょうど同じ文法で、
|
|
196
|
+
* ただし文字の続きが変えられない行だけ出す処理。文字が改行で終える行すべてを、文字が閉じない引用を開く最初の値まで出す (それが 1 つの
|
|
197
|
+
* 引用の値か、改行で割れる文字どおりの値かは続きが決める)。そのため TSV のどの先頭の行も全体の最初の行で、ストリームの読み手は
|
|
198
|
+
* それを確定したものとして見せられる。空の文字は行を出さない。埋めない。
|
|
199
|
+
*
|
|
200
|
+
* @param text - The beginning of a TSV text, as read. 読んだままの TSV の文字の先頭。
|
|
201
|
+
* @returns The rows the rest of the text cannot change, unpadded. 文字の続きが変えられない行 (埋めない)。
|
|
202
|
+
*/
|
|
203
|
+
export function* iterateTsvPrefixRows(text: string): Generator<string[]> {
|
|
204
|
+
yield* rowsOf(scanTsv(text, "prefix"))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Groups a walk's tokens into rows; fields after the last ROW_END (a row the walk did not end) are dropped.
|
|
209
|
+
* 走査の印を行にまとめる処理 (最後の ROW_END の後の値 — 走査が終えなかった行 — は捨てる)。
|
|
210
|
+
*
|
|
211
|
+
* @param tokens - The walk's tokens. 走査の印。
|
|
212
|
+
* @returns The rows. 行。
|
|
213
|
+
*/
|
|
214
|
+
function* rowsOf(tokens: Iterable<string | typeof ROW_END>): Generator<string[]> {
|
|
94
215
|
let row: string[] = []
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
row.push(field)
|
|
99
|
-
},
|
|
100
|
-
() => {
|
|
101
|
-
rows.push(row)
|
|
216
|
+
for (const token of tokens) {
|
|
217
|
+
if (token === ROW_END) {
|
|
218
|
+
yield row
|
|
102
219
|
row = []
|
|
103
|
-
}
|
|
104
|
-
|
|
220
|
+
} else {
|
|
221
|
+
row.push(token)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Parses TSV into a rectangular matrix (ragged rows are right-padded with ""; one trailing line
|
|
228
|
+
* terminator is dropped; empty text is one empty cell): the rows of iterateTsvRows, padded.
|
|
229
|
+
* TSV を矩形行列へ解析する処理 (不揃いの行は "" で右を埋め、末尾の改行 1 つを除き、空の文字は空のセル 1 つ)。iterateTsvRows の
|
|
230
|
+
* 行を埋めたもの。
|
|
231
|
+
*
|
|
232
|
+
* @param text - The TSV text. TSV の文字列。
|
|
233
|
+
* @returns The rectangular matrix, at least 1 × 1. 少なくとも 1 × 1 の矩形行列。
|
|
234
|
+
*/
|
|
235
|
+
export const parseTsv = (text: string): string[][] => {
|
|
236
|
+
const rows = Array.from(iterateTsvRows(text))
|
|
105
237
|
// 矩形化: Excel 由来は常に矩形だが、他アプリ由来のギザギザ行も右パディングで受ける (仕様)。
|
|
106
238
|
// ❗ Math.max(...rows) は行数ぶん引数を積み、巨大貼り付け (V8 実測 ~15 万行、JSC はさらに低い)
|
|
107
239
|
// で ADR-12 見積もりに到達する前に RangeError で落ちる — ループで求める
|
|
@@ -122,6 +254,9 @@ export const parseTsv = (text: string): string[][] => {
|
|
|
122
254
|
* `parseTsv(text)` が実体化する寸法 — 行数と矩形化後 (最大) の列数 — を**同じ**文法走査から、
|
|
123
255
|
* 行配列を一切作らずに返す: `tsv` 貼り付けソースの ADR-12 実体化前見積もり (素の改行計数は
|
|
124
256
|
* quoted 内の埋め込み改行を行と数え、正当な貼り付けを誤って上限拒否する)。
|
|
257
|
+
*
|
|
258
|
+
* @param text - The TSV text. TSV の文字列。
|
|
259
|
+
* @returns The row count and the maximum column count. 行数と最大の列数。
|
|
125
260
|
*/
|
|
126
261
|
export const measureTsv = (text: string): { rows: number; cols: number } => {
|
|
127
262
|
const body = stripTrailingTerminator(text)
|
|
@@ -131,48 +266,60 @@ export const measureTsv = (text: string): { rows: number; cols: number } => {
|
|
|
131
266
|
let rows = 0
|
|
132
267
|
let cols = 0
|
|
133
268
|
let current = 0
|
|
134
|
-
scanTsv(
|
|
135
|
-
|
|
136
|
-
() => {
|
|
137
|
-
current += 1
|
|
138
|
-
},
|
|
139
|
-
() => {
|
|
269
|
+
for (const token of scanTsv(body, "whole")) {
|
|
270
|
+
if (token === ROW_END) {
|
|
140
271
|
rows += 1
|
|
141
272
|
if (current > cols) {
|
|
142
273
|
cols = current
|
|
143
274
|
}
|
|
144
275
|
current = 0
|
|
145
|
-
}
|
|
146
|
-
|
|
276
|
+
} else {
|
|
277
|
+
current += 1
|
|
278
|
+
}
|
|
279
|
+
}
|
|
147
280
|
return { rows, cols }
|
|
148
281
|
}
|
|
149
282
|
|
|
150
|
-
/**
|
|
151
|
-
const
|
|
283
|
+
/** readQuotedField's answer for a field read as a literal. readQuotedField が文字どおりに読む値に返す答え。 */
|
|
284
|
+
const LITERAL_FIELD: unique symbol = Symbol("literal field")
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* readQuotedField's answer for a quote the text does not close: a literal of a whole text, undecided in a prefix.
|
|
288
|
+
* readQuotedField が、文字の閉じない引用に返す答え (文字全体では literal、先頭だけの文字ではまだ決まらない)。
|
|
289
|
+
*/
|
|
290
|
+
const OPEN_QUOTE: unique symbol = Symbol("open quote")
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Reads the quoted field that starts at `start`, adopted ONLY when it closes properly and its content holds a tab, CR,
|
|
294
|
+
* LF or `"` (exactly the fields `serializeTsv` quotes); a quote that closes otherwise is a literal (LITERAL_FIELD), and a
|
|
295
|
+
* quote the text never closes is OPEN_QUOTE, which a whole text reads as a literal too. The content is gathered a run
|
|
296
|
+
* between quotes at a time.
|
|
297
|
+
* 位置 `start` から始まる引用のフィールドを読む処理。正しく閉じ、中身がタブ・CR・LF・`"` のどれかを含むとき (`serializeTsv` が
|
|
298
|
+
* 囲む値とちょうど同じ) だけ採用し、ほかの閉じ方は文字どおり (LITERAL_FIELD)、文字が閉じない引用は OPEN_QUOTE (文字全体では
|
|
299
|
+
* これも文字どおりに読む)。中身は引用符の間の区間ごとにまとめて集める。
|
|
300
|
+
*
|
|
301
|
+
* @param body - The TSV text. TSV の文字列。
|
|
302
|
+
* @param start - The index of the opening quote. 開きの引用符の位置。
|
|
303
|
+
* @returns The unescaped value and the index after the closing quote, LITERAL_FIELD, or OPEN_QUOTE. 剥がした値と閉じの直後の位置、LITERAL_FIELD、または OPEN_QUOTE。
|
|
304
|
+
*/
|
|
305
|
+
const readQuotedField = (body: string, start: number): { value: string; nextIndex: number } | typeof LITERAL_FIELD | typeof OPEN_QUOTE => {
|
|
152
306
|
let value = ""
|
|
153
307
|
let i = start + 1
|
|
154
|
-
while (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// (b) 中身に改行かタブを含む (Excel が quote する唯一の理由)。
|
|
163
|
-
// (b) が無いと、全体がちょうど引用で包まれた literal `"hi"` を剥がしてしまう
|
|
164
|
-
const isBoundary = next >= body.length || body[next] === "\t" || body[next] === "\n" || body[next] === "\r"
|
|
165
|
-
if (isBoundary && /[\t\r\n]/.test(value)) {
|
|
166
|
-
return { value, nextIndex: next }
|
|
167
|
-
}
|
|
168
|
-
return null
|
|
169
|
-
}
|
|
170
|
-
} else {
|
|
171
|
-
value += body[i]
|
|
172
|
-
i += 1
|
|
308
|
+
while (true) {
|
|
309
|
+
const quote = body.indexOf('"', i)
|
|
310
|
+
if (quote < 0) return OPEN_QUOTE
|
|
311
|
+
value += body.slice(i, quote)
|
|
312
|
+
if (body[quote + 1] === '"') {
|
|
313
|
+
value += '"'
|
|
314
|
+
i = quote + 2
|
|
315
|
+
continue
|
|
173
316
|
}
|
|
317
|
+
const next = quote + 1
|
|
318
|
+
// 成立条件は 2 つとも必要: (a) 閉じ引用の直後が区切りか終端、(b) 中身がタブ・改行・`"` を含む
|
|
319
|
+
// (書き手が囲む値の集合と同じ)。(b) が無いと、Excel が囲まずに出した literal `"hi"` を剥がしてしまう
|
|
320
|
+
const isBoundary = next >= body.length || body[next] === "\t" || body[next] === "\n" || body[next] === "\r"
|
|
321
|
+
return isBoundary && /[\t\r\n"]/.test(value) ? { value, nextIndex: next } : LITERAL_FIELD
|
|
174
322
|
}
|
|
175
|
-
return null // 閉じ引用が無い → literal 扱い
|
|
176
323
|
}
|
|
177
324
|
|
|
178
325
|
/** Next tab/newline position or end. / 次の区切り位置 (無ければ終端)。 */
|
|
@@ -88,7 +88,7 @@ import {
|
|
|
88
88
|
import { parseHtmlTable, serializeHtmlTable } from "./htmlInterop.ts"
|
|
89
89
|
import { DEFAULT_PASTE_OPTIONS, MODE_ASPECTS, type PasteAspects, type PasteOptions, patchFromClipCell, stripUndeclaredAspects, translateConditionalFormats, transposePayload } from "./pasteSpecial.ts"
|
|
90
90
|
import { computePasteTargetRect, expandRichPaste } from "./tiling.ts"
|
|
91
|
-
import { measureTsv, parseTsv,
|
|
91
|
+
import { measureTsv, parseTsv, serializeClipboardTsv } from "./tsv.ts"
|
|
92
92
|
|
|
93
93
|
/** 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 を保持)。 */
|
|
94
94
|
export type ClipInfo = { kind: "copy" | "cut"; range: CellRange; foreign: boolean }
|
|
@@ -709,7 +709,8 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
|
|
|
709
709
|
if (data !== null) {
|
|
710
710
|
// TSV は素の文字列 (数式セルも値文字列)、HTML は先頭属性に同じ JSON、カスタム MIME は
|
|
711
711
|
// paste イベント経路の高速マーカー (read() 経路には届かない)
|
|
712
|
-
|
|
712
|
+
// 平文の貼り先には Excel の形 (囲むのはタブ・改行を含む値だけ)。グリッド自身の貼り付けは JSON の payload を先に読む
|
|
713
|
+
data.setData("text/plain", serializeClipboardTsv(payload.cells.map((line) => line.map((cell) => cell.t))))
|
|
713
714
|
data.setData("text/html", serializeHtmlTable(payload, json))
|
|
714
715
|
data.setData(CLIP_MIME, json)
|
|
715
716
|
}
|
|
@@ -166,6 +166,14 @@ export type UseCommandsParams = {
|
|
|
166
166
|
announce: (text: string) => void
|
|
167
167
|
formatCommandAnnouncement?: (ctx: { command: SpreadsheetCommand; state: boolean | null }) => string
|
|
168
168
|
/** The active cell's keyboard rect (onPasteSpecialRequest origin). / active セルのキーボード矩形 (onPasteSpecialRequest の origin)。 */
|
|
169
|
+
/**
|
|
170
|
+
* The keyboard resize capability (the header resize machine's keyboard twins); absent ⇒ the resize commands are
|
|
171
|
+
* unavailable (`enableResizeUI` false or an empty grid).
|
|
172
|
+
* キーボードの寸法の能力 (帯のリサイズ機械のキーボードの双子)。無ければ寸法のコマンドは使えない (`enableResizeUI` が false か空のグリッド)。
|
|
173
|
+
*/
|
|
174
|
+
trackResize?: { readonly stepTrack: (axis: "row" | "col", index: number, direction: 1 | -1) => boolean; readonly autofitColumn: (index: number) => boolean }
|
|
175
|
+
/** Live-region text after a keyboard resize (the active track's new size); absent ⇒ silent. / キーボードの寸法の変更の後の読み上げ (アクティブなトラックの新しい寸法)。無ければ無音。 */
|
|
176
|
+
formatResizeAnnouncement?: (ctx: { command: SpreadsheetCommand; axis: "row" | "column"; index: number; size: number }) => string
|
|
169
177
|
keyboardCellRect: () => KeyboardCellRect
|
|
170
178
|
}
|
|
171
179
|
|
|
@@ -218,6 +226,7 @@ type CommandSpec =
|
|
|
218
226
|
| { kind: "paste"; options: PasteOptions }
|
|
219
227
|
| { kind: "insertNow"; unit: "date" | "time" }
|
|
220
228
|
| { kind: "hide"; axis: "row" | "col"; hide: boolean }
|
|
229
|
+
| { kind: "resize"; axis: "row" | "col"; step: 1 | -1 | "fit" }
|
|
221
230
|
| { kind: "highlight"; target: "row" | "column" | "both" | "intersection" }
|
|
222
231
|
|
|
223
232
|
/** Fill presets of the four named fill-colour toggles — pastels (design v3 §7 / the reference extension: legible behind the default text colour). / 名前つき塗りトグル 4 種のプリセット — 淡色 (設計 v3 §7 / 参照拡張機能と同値: 既定文字色の背後で可読)。 */
|
|
@@ -294,6 +303,11 @@ const COMMAND_SPECS: { readonly [K in SpreadsheetCommand]: CommandSpec } = {
|
|
|
294
303
|
fontColorCustom3: { kind: "color", key: "color", preset: null, custom: 2 },
|
|
295
304
|
fontColorCustom4: { kind: "color", key: "color", preset: null, custom: 3 },
|
|
296
305
|
fontColorCustom5: { kind: "color", key: "color", preset: null, custom: 4 },
|
|
306
|
+
increaseColumnWidth: { kind: "resize", axis: "col", step: 1 },
|
|
307
|
+
decreaseColumnWidth: { kind: "resize", axis: "col", step: -1 },
|
|
308
|
+
increaseRowHeight: { kind: "resize", axis: "row", step: 1 },
|
|
309
|
+
decreaseRowHeight: { kind: "resize", axis: "row", step: -1 },
|
|
310
|
+
autoFitColumnWidth: { kind: "resize", axis: "col", step: "fit" },
|
|
297
311
|
toggleRowHighlight: { kind: "highlight", target: "row" },
|
|
298
312
|
toggleColumnHighlight: { kind: "highlight", target: "column" },
|
|
299
313
|
toggleBothHighlight: { kind: "highlight", target: "both" },
|
|
@@ -748,6 +762,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
748
762
|
const formatDeclared = adapter.getCellFormat !== undefined
|
|
749
763
|
const popoverEnabled = (kind: PopoverKind): boolean => (p.popovers === undefined ? true : p.popovers[kind] !== false)
|
|
750
764
|
switch (spec.kind) {
|
|
765
|
+
case "resize":
|
|
766
|
+
return p.trackResize !== undefined
|
|
751
767
|
case "toggle":
|
|
752
768
|
case "patch":
|
|
753
769
|
case "clear":
|
|
@@ -842,6 +858,30 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
842
858
|
[activeHyperlink],
|
|
843
859
|
)
|
|
844
860
|
|
|
861
|
+
/**
|
|
862
|
+
* Runs one keyboard resize command on the active track (the header resize machine's keyboard twin) and announces the
|
|
863
|
+
* track's size afterwards; returns false when the capability is absent or the machine refused.
|
|
864
|
+
* キーボードの寸法のコマンド 1 つをアクティブなトラックへ当て (帯のリサイズ機械のキーボードの双子)、後でトラックの寸法を
|
|
865
|
+
* 読み上げる処理。能力が無いときと機械が拒んだときは false。
|
|
866
|
+
*/
|
|
867
|
+
const runResize = useCallback((command: SpreadsheetCommand, axis: "row" | "col", step: 1 | -1 | "fit"): boolean => {
|
|
868
|
+
const p = paramsRef.current
|
|
869
|
+
const resize = p.trackResize
|
|
870
|
+
// 可否は isCommandAvailable が先に確かめている — 無いのは到達しない枝で、能力の代わりの値は作らない
|
|
871
|
+
if (resize === undefined) {
|
|
872
|
+
return false
|
|
873
|
+
}
|
|
874
|
+
const active = p.getActive()
|
|
875
|
+
const index = axis === "col" ? active.col : active.row
|
|
876
|
+
const ran = step === "fit" ? resize.autofitColumn(index) : resize.stepTrack(axis, index, step)
|
|
877
|
+
const size = (axis === "col" ? p.colAxis : p.rowAxis).sizeOf(index)
|
|
878
|
+
// 隠れたトラック (0 px) は何も変えずに消費する — 寸法の読み上げも出さない
|
|
879
|
+
if (ran && size > 0 && p.formatResizeAnnouncement !== undefined) {
|
|
880
|
+
p.announce(p.formatResizeAnnouncement({ command, axis: axis === "col" ? "column" : "row", index, size }))
|
|
881
|
+
}
|
|
882
|
+
return ran
|
|
883
|
+
}, [])
|
|
884
|
+
|
|
845
885
|
const executeCommand = useCallback(
|
|
846
886
|
(command: SpreadsheetCommand): boolean => {
|
|
847
887
|
const p = paramsRef.current
|
|
@@ -980,6 +1020,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
980
1020
|
}
|
|
981
1021
|
case "hide":
|
|
982
1022
|
return runHide(spec.axis, spec.hide)
|
|
1023
|
+
case "resize":
|
|
1024
|
+
return runResize(command, spec.axis, spec.step)
|
|
983
1025
|
default: {
|
|
984
1026
|
const h = p.getHighlight()
|
|
985
1027
|
switch (spec.target) {
|
|
@@ -1002,7 +1044,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
|
|
|
1002
1044
|
}
|
|
1003
1045
|
}
|
|
1004
1046
|
},
|
|
1005
|
-
[isCommandAvailable, gateReadOnlyChord, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runFillSelectionEditing, runHide],
|
|
1047
|
+
[isCommandAvailable, gateReadOnlyChord, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runFillSelectionEditing, runHide, runResize],
|
|
1006
1048
|
)
|
|
1007
1049
|
|
|
1008
1050
|
/** Commit gate shared by the annotation setters. / 注釈 setter 共通の commit ゲート。 */
|