sfora-cli 0.10.0 → 0.12.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/README.md +174 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +344 -4
- package/dist/api-client.js +289 -21
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/chat.d.ts +89 -0
- package/dist/chat.js +189 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.js +88 -0
- package/dist/cli.js +530 -88
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +162 -0
- package/dist/render.js +280 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
/**
|
|
4
|
+
* Where every sheet cell lives in the authored bytes.
|
|
5
|
+
*
|
|
6
|
+
* `parseSheet` hands the reader a grid of DECODED strings — trimmed, escapes
|
|
7
|
+
* resolved, short rows padded. That is the right shape to draw and the wrong
|
|
8
|
+
* shape to write back through: re-serializing the whole grid from it would
|
|
9
|
+
* rewrite the author's column padding, their outer pipes, their delimiter row,
|
|
10
|
+
* every byte they lined up by hand.
|
|
11
|
+
*
|
|
12
|
+
* So an edit is a splice, not a re-serialization. This module answers "which
|
|
13
|
+
* byte range is that cell?" and writes a single cell in place. Everything the
|
|
14
|
+
* author wrote outside the edited cell survives untouched, which is what makes
|
|
15
|
+
* the no-op case byte-identical by construction rather than by luck:
|
|
16
|
+
* `writeSheetCell(source, r, c, currentText)` returns the same string back.
|
|
17
|
+
*
|
|
18
|
+
* The escape rules here are a second reading of the same grammar `sheetCells`
|
|
19
|
+
* in `blocks/parsers.ts` reads, and a second reading is only trustworthy if
|
|
20
|
+
* something checks it against the first. `sheetCellSpans.test.ts` does: every
|
|
21
|
+
* span's slice is fed back through `parseSheet` and must equal the cell the
|
|
22
|
+
* parser reports. A divergence is a test failure, never a silent misalignment.
|
|
23
|
+
*
|
|
24
|
+
* The CODE-SPAN half is no longer a second reading at all. While this module
|
|
25
|
+
* lived in `src/components/editor/` it could not reach the engine's private
|
|
26
|
+
* helpers, so it carried a hand-rolled `codeSpanMask` that closed a backtick
|
|
27
|
+
* run with `indexOf` — which lets a longer run close a shorter one, where
|
|
28
|
+
* CommonMark (and `lineGeometry.codeSpanMask`, which `sheetCells` uses) wants
|
|
29
|
+
* the closing run to be exactly as long as the opening one. Moving the file
|
|
30
|
+
* into the engine (board card #278) retires the copy: both readings of "where
|
|
31
|
+
* are the code spans" are now the one function, so the two cannot drift.
|
|
32
|
+
*/
|
|
33
|
+
import { isSheetRow, parseSheet } from "./blocks/parsers.js";
|
|
34
|
+
import { codeSpanMask } from "./lineGeometry.js";
|
|
35
|
+
const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
|
|
36
|
+
const SHEET_SEPARATOR_CELL = /^:?-+:?$/;
|
|
37
|
+
function isEscapedAt(text, index) {
|
|
38
|
+
let backslashes = 0;
|
|
39
|
+
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--)
|
|
40
|
+
backslashes++;
|
|
41
|
+
return backslashes % 2 === 1;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Split one already-trimmed row into the byte ranges between its delimiters.
|
|
45
|
+
* Offsets are relative to `line`; the caller shifts them into the source.
|
|
46
|
+
*/
|
|
47
|
+
function rowCells(line) {
|
|
48
|
+
let from = 0;
|
|
49
|
+
let to = line.length;
|
|
50
|
+
if (line.startsWith("|"))
|
|
51
|
+
from = 1;
|
|
52
|
+
const trailingPipe = line.endsWith("|") && !isEscapedAt(line, line.length - 1);
|
|
53
|
+
if (trailingPipe)
|
|
54
|
+
to = line.length - 1;
|
|
55
|
+
const content = line.slice(from, to);
|
|
56
|
+
const inCode = codeSpanMask(content);
|
|
57
|
+
const cells = [];
|
|
58
|
+
let cellStart = 0;
|
|
59
|
+
for (let i = 0; i < content.length; i++) {
|
|
60
|
+
const character = content[i];
|
|
61
|
+
if (character === "\\") {
|
|
62
|
+
const next = content[i + 1];
|
|
63
|
+
// Same rule as the engine: `\|` is a literal pipe even inside a code
|
|
64
|
+
// span; every other escape belongs to inline parsing and stops there.
|
|
65
|
+
if (next === "|" || (next !== undefined && !inCode[i] && ASCII_PUNCTUATION.test(next))) {
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (character === "|" && !inCode[i]) {
|
|
71
|
+
cells.push({ start: cellStart, end: i });
|
|
72
|
+
cellStart = i + 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
cells.push({ start: cellStart, end: content.length });
|
|
77
|
+
return {
|
|
78
|
+
cells: cells.map((cell) => ({ start: cell.start + from, end: cell.end + from })),
|
|
79
|
+
trailingPipe,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function sourceLines(source) {
|
|
83
|
+
const out = [];
|
|
84
|
+
let start = 0;
|
|
85
|
+
for (let i = 0; i <= source.length; i++) {
|
|
86
|
+
if (i === source.length || source[i] === "\n") {
|
|
87
|
+
let end = i;
|
|
88
|
+
if (end > start && source[end - 1] === "\r")
|
|
89
|
+
end -= 1;
|
|
90
|
+
out.push({ start, end, text: source.slice(start, end) });
|
|
91
|
+
start = i + 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Trim a raw range in place, so the author's padding stays outside the span. */
|
|
97
|
+
function trimRange(source, start, end) {
|
|
98
|
+
let from = start;
|
|
99
|
+
let to = end;
|
|
100
|
+
while (from < to && /\s/.test(source[from]))
|
|
101
|
+
from++;
|
|
102
|
+
while (to > from && /\s/.test(source[to - 1]))
|
|
103
|
+
to--;
|
|
104
|
+
return { start: from, end: to };
|
|
105
|
+
}
|
|
106
|
+
function rowSpan(source, line, texts) {
|
|
107
|
+
const trimmed = line.text.trim();
|
|
108
|
+
const lead = line.text.length - line.text.trimStart().length;
|
|
109
|
+
const base = line.start + lead;
|
|
110
|
+
const { cells, trailingPipe } = rowCells(trimmed);
|
|
111
|
+
return {
|
|
112
|
+
cells: cells.map((cell, index) => {
|
|
113
|
+
const range = trimRange(source, base + cell.start, base + cell.end);
|
|
114
|
+
return { start: range.start, end: range.end, text: texts[index] ?? source.slice(range.start, range.end) };
|
|
115
|
+
}),
|
|
116
|
+
appendAt: base + trimmed.length,
|
|
117
|
+
trailingPipe,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Build the source-anchored grid, or null when the source is not a sheet the
|
|
122
|
+
* reader would draw — the same null the renderer already falls back on.
|
|
123
|
+
*
|
|
124
|
+
* The cell TEXTS come from `parseSheet`, never from this file's own decoding:
|
|
125
|
+
* one reader of the grammar owns what a cell says, and this module only owns
|
|
126
|
+
* where it sits. When the two disagree about how many cells the header has,
|
|
127
|
+
* the grid degrades to null instead of handing back a misaligned map.
|
|
128
|
+
*/
|
|
129
|
+
export function sheetCellGrid(source) {
|
|
130
|
+
const data = parseSheet(source);
|
|
131
|
+
if (!data)
|
|
132
|
+
return null;
|
|
133
|
+
const lines = sourceLines(source).filter((line) => {
|
|
134
|
+
const text = line.text.trim();
|
|
135
|
+
return text.length > 0 && isSheetRow(text);
|
|
136
|
+
});
|
|
137
|
+
if (lines.length < 2)
|
|
138
|
+
return null;
|
|
139
|
+
const headerSpan = rowSpan(source, lines[0], data.columns.map((column) => column.label));
|
|
140
|
+
if (headerSpan.cells.length !== data.columns.length)
|
|
141
|
+
return null;
|
|
142
|
+
const separatorCells = rowCells(lines[1].text.trim()).cells;
|
|
143
|
+
const hasSeparator = separatorCells.length === data.columns.length &&
|
|
144
|
+
separatorCells.every((cell) => {
|
|
145
|
+
const trimmed = lines[1].text.trim().slice(cell.start, cell.end).trim();
|
|
146
|
+
return SHEET_SEPARATOR_CELL.test(trimmed);
|
|
147
|
+
});
|
|
148
|
+
const bodyLines = lines.slice(hasSeparator ? 2 : 1);
|
|
149
|
+
if (bodyLines.length !== data.rows.length)
|
|
150
|
+
return null;
|
|
151
|
+
return {
|
|
152
|
+
columns: data.columns.length,
|
|
153
|
+
header: headerSpan,
|
|
154
|
+
rows: bodyLines.map((line, index) => rowSpan(source, line, data.rows[index])),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Encode a value for a table cell.
|
|
159
|
+
*
|
|
160
|
+
* The inverse of the engine's decoding over the alphabet that decoding
|
|
161
|
+
* touches: `\` and `|`. Everything else is left as the author typed it —
|
|
162
|
+
* the sheet renders cell text literally, so nothing else needs a spelling.
|
|
163
|
+
* A row cannot hold a line break, so whitespace runs collapse to one space.
|
|
164
|
+
*/
|
|
165
|
+
export function encodeSheetCell(value) {
|
|
166
|
+
return value.replace(/\s+/g, " ").trim().replace(/[\\|]/g, (match) => `\\${match}`);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Write one cell and return the whole block source back.
|
|
170
|
+
*
|
|
171
|
+
* Total by construction: every refusal names itself, nothing returns
|
|
172
|
+
* `undefined`, and the unchanged source always comes back on the result so a
|
|
173
|
+
* caller cannot accidentally drop the document by not checking `ok`.
|
|
174
|
+
*/
|
|
175
|
+
export function writeSheetCell(source, rowIndex, columnIndex, value) {
|
|
176
|
+
const grid = sheetCellGrid(source);
|
|
177
|
+
if (!grid)
|
|
178
|
+
return { ok: false, reason: "no-grid", source };
|
|
179
|
+
if (rowIndex < 0 || rowIndex >= grid.rows.length) {
|
|
180
|
+
return { ok: false, reason: "row-out-of-range", source };
|
|
181
|
+
}
|
|
182
|
+
if (columnIndex < 0 || columnIndex >= grid.columns) {
|
|
183
|
+
return { ok: false, reason: "column-out-of-range", source };
|
|
184
|
+
}
|
|
185
|
+
const encoded = encodeSheetCell(value);
|
|
186
|
+
const shown = decodeForDisplay(encoded);
|
|
187
|
+
const normalized = shown !== value;
|
|
188
|
+
const row = grid.rows[rowIndex];
|
|
189
|
+
const current = row.cells[columnIndex];
|
|
190
|
+
// The no-op door. Nothing is spliced, so the bytes cannot drift — this is
|
|
191
|
+
// why the round trip is identity and not merely "usually identity". An
|
|
192
|
+
// absent cell in a short row counts: the reader already draws it as empty,
|
|
193
|
+
// so writing empty into it is asking for what is already true.
|
|
194
|
+
const shown0 = current ? current.text : "";
|
|
195
|
+
if (shown0 === value) {
|
|
196
|
+
return { ok: true, source, value, changed: false, normalized: false };
|
|
197
|
+
}
|
|
198
|
+
if (current) {
|
|
199
|
+
const next = source.slice(0, current.start) + encoded + source.slice(current.end);
|
|
200
|
+
return { ok: true, source: next, value: shown, changed: next !== source, normalized };
|
|
201
|
+
}
|
|
202
|
+
// A short row: grow it to reach the column, rather than refusing an edit the
|
|
203
|
+
// reader already drew as an empty cell.
|
|
204
|
+
const additions = [];
|
|
205
|
+
for (let index = row.cells.length; index <= columnIndex; index++) {
|
|
206
|
+
additions.push(index === columnIndex ? encoded : "");
|
|
207
|
+
}
|
|
208
|
+
const inserted = additions
|
|
209
|
+
.map((cell) => row.trailingPipe
|
|
210
|
+
? cell
|
|
211
|
+
? ` ${cell} |`
|
|
212
|
+
: " |"
|
|
213
|
+
: cell
|
|
214
|
+
? ` | ${cell}`
|
|
215
|
+
: " |")
|
|
216
|
+
.join("");
|
|
217
|
+
const next = source.slice(0, row.appendAt) + inserted + source.slice(row.appendAt);
|
|
218
|
+
return { ok: true, source: next, value: shown, changed: true, normalized };
|
|
219
|
+
}
|
|
220
|
+
/** What the reader will show for bytes this module just wrote. */
|
|
221
|
+
function decodeForDisplay(encoded) {
|
|
222
|
+
return encoded.replace(/\\([\\|])/g, "$1");
|
|
223
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard-first selection for the sheet block.
|
|
3
|
+
*
|
|
4
|
+
* The shape is BlockSuite's data-view model, kept because it earns its keep: a
|
|
5
|
+
* selection is a plain serializable VALUE — a focus coordinate plus a rung —
|
|
6
|
+
* not a DOM range, not a set of element references. That is what lets the same
|
|
7
|
+
* selection survive a re-render, a re-parse of the block source, a round trip
|
|
8
|
+
* through JSON, and (later) a collaborative cursor. Reference:
|
|
9
|
+
* context/AFFiNE/blocksuite/affine/data-view/src/view-presets/table/selection.ts
|
|
10
|
+
* and .../table/pc/controller/hotkeys.ts.
|
|
11
|
+
*
|
|
12
|
+
* Three of their behaviours are deliberately NOT copied.
|
|
13
|
+
*
|
|
14
|
+
* 1. The inescapable Escape loop. In their hotkeys, Escape on a cell promotes
|
|
15
|
+
* to row selection and Escape on a row selection converts the rows straight
|
|
16
|
+
* back into an area selection — the two rungs trade places forever and the
|
|
17
|
+
* table never lets go. Here Escape walks a strictly decreasing ladder
|
|
18
|
+
* (editing → cell → row → none) and then reports `ignored` so the key
|
|
19
|
+
* belongs to whatever encloses the block. `selectionRung` is the proof
|
|
20
|
+
* obligation: every Escape lowers it, so the walk terminates.
|
|
21
|
+
*
|
|
22
|
+
* 2. Tab dead-ending. Theirs wraps to the next row and stops moving at the last
|
|
23
|
+
* cell while still swallowing the key, so a keyboard user is trapped in the
|
|
24
|
+
* grid with no way forward. Here Tab past the last cell RELEASES — the
|
|
25
|
+
* caller lets the browser move focus on — and the selection value is left
|
|
26
|
+
* intact so tabbing back in lands where you left.
|
|
27
|
+
*
|
|
28
|
+
* 3. Silent undefined. Their cell lookups go through a DOM query that returns
|
|
29
|
+
* `undefined` and the handler simply does nothing, so a coordinate that has
|
|
30
|
+
* drifted out of range is indistinguishable from a key that did nothing.
|
|
31
|
+
* This reducer is total: coordinates are clamped against the shape on every
|
|
32
|
+
* transition, and every call returns a named outcome.
|
|
33
|
+
*/
|
|
34
|
+
/** How many body rows and columns the block is currently drawing. */
|
|
35
|
+
export type SheetShape = {
|
|
36
|
+
rows: number;
|
|
37
|
+
columns: number;
|
|
38
|
+
};
|
|
39
|
+
export type SheetSelection = {
|
|
40
|
+
kind: "none";
|
|
41
|
+
} | {
|
|
42
|
+
kind: "row";
|
|
43
|
+
rowIndex: number;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "cell";
|
|
46
|
+
rowIndex: number;
|
|
47
|
+
columnIndex: number;
|
|
48
|
+
isEditing: boolean;
|
|
49
|
+
};
|
|
50
|
+
export declare const SHEET_SELECTION_NONE: SheetSelection;
|
|
51
|
+
/**
|
|
52
|
+
* The ladder rung. Escape lowers it by exactly one, which is the whole reason
|
|
53
|
+
* the Escape walk terminates rather than ping-ponging.
|
|
54
|
+
*/
|
|
55
|
+
export declare function selectionRung(selection: SheetSelection): 0 | 1 | 2 | 3;
|
|
56
|
+
export type SheetAction = {
|
|
57
|
+
type: "focusCell";
|
|
58
|
+
rowIndex: number;
|
|
59
|
+
columnIndex: number;
|
|
60
|
+
edit?: boolean;
|
|
61
|
+
} | {
|
|
62
|
+
type: "move";
|
|
63
|
+
direction: "up" | "down" | "left" | "right";
|
|
64
|
+
} | {
|
|
65
|
+
type: "step";
|
|
66
|
+
direction: "forward" | "back";
|
|
67
|
+
} | {
|
|
68
|
+
type: "enter";
|
|
69
|
+
} | {
|
|
70
|
+
type: "escape";
|
|
71
|
+
} | {
|
|
72
|
+
type: "typeChar";
|
|
73
|
+
char: string;
|
|
74
|
+
} | {
|
|
75
|
+
type: "clear";
|
|
76
|
+
} | {
|
|
77
|
+
type: "commit";
|
|
78
|
+
value: string;
|
|
79
|
+
then: "stay" | "down" | "forward" | "back";
|
|
80
|
+
} | {
|
|
81
|
+
type: "release";
|
|
82
|
+
};
|
|
83
|
+
export type SheetOutcome =
|
|
84
|
+
/** The block consumed the key. */
|
|
85
|
+
"handled"
|
|
86
|
+
/** The block is done: focus should move to the next thing after it. */
|
|
87
|
+
| "released-forward"
|
|
88
|
+
/** The block is done: focus should move to the thing before it. */
|
|
89
|
+
| "released-back"
|
|
90
|
+
/** Not ours — let the key bubble to whatever encloses the block. */
|
|
91
|
+
| "ignored";
|
|
92
|
+
/** A write the caller must apply to the block source. */
|
|
93
|
+
export type SheetWrite = {
|
|
94
|
+
rowIndex: number;
|
|
95
|
+
columnIndex: number;
|
|
96
|
+
value: string;
|
|
97
|
+
};
|
|
98
|
+
export type SheetTransition = {
|
|
99
|
+
selection: SheetSelection;
|
|
100
|
+
outcome: SheetOutcome;
|
|
101
|
+
/** Null unless the transition changed a cell's value. */
|
|
102
|
+
write: SheetWrite | null;
|
|
103
|
+
/** The text an opening editor starts with, or null when none opens. */
|
|
104
|
+
draft: string | null;
|
|
105
|
+
};
|
|
106
|
+
export type SheetContext = {
|
|
107
|
+
shape: SheetShape;
|
|
108
|
+
/** The current text of a cell. Total: out-of-range reads answer "". */
|
|
109
|
+
read: (rowIndex: number, columnIndex: number) => string;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Re-seat a selection against a new shape. The block source can change under
|
|
113
|
+
* the selection — an agent writes a row, a paste shortens one — and the value
|
|
114
|
+
* has to stay meaningful without being thrown away.
|
|
115
|
+
*/
|
|
116
|
+
export declare function reseatSelection(selection: SheetSelection, shape: SheetShape): SheetSelection;
|
|
117
|
+
export declare function sheetSelectionReducer(selection: SheetSelection, action: SheetAction, context: SheetContext): SheetTransition;
|
|
118
|
+
/**
|
|
119
|
+
* A keyboard event, read as an action. Split from the reducer so the key map
|
|
120
|
+
* is a table a test can walk without a DOM.
|
|
121
|
+
*
|
|
122
|
+
* Returns null for keys the block has no opinion about, which is the same
|
|
123
|
+
* statement as `ignored` — nothing here silently swallows a key.
|
|
124
|
+
*/
|
|
125
|
+
export declare function sheetKeyAction(event: {
|
|
126
|
+
key: string;
|
|
127
|
+
shiftKey?: boolean;
|
|
128
|
+
metaKey?: boolean;
|
|
129
|
+
ctrlKey?: boolean;
|
|
130
|
+
altKey?: boolean;
|
|
131
|
+
}): SheetAction | null;
|
|
132
|
+
/**
|
|
133
|
+
* Where Enter should land after committing an edit — down a row, matching
|
|
134
|
+
* every spreadsheet. Kept as a named function so the hook and its test agree.
|
|
135
|
+
*/
|
|
136
|
+
export declare const ENTER_COMMIT_LANDING: "down";
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
/**
|
|
4
|
+
* Keyboard-first selection for the sheet block.
|
|
5
|
+
*
|
|
6
|
+
* The shape is BlockSuite's data-view model, kept because it earns its keep: a
|
|
7
|
+
* selection is a plain serializable VALUE — a focus coordinate plus a rung —
|
|
8
|
+
* not a DOM range, not a set of element references. That is what lets the same
|
|
9
|
+
* selection survive a re-render, a re-parse of the block source, a round trip
|
|
10
|
+
* through JSON, and (later) a collaborative cursor. Reference:
|
|
11
|
+
* context/AFFiNE/blocksuite/affine/data-view/src/view-presets/table/selection.ts
|
|
12
|
+
* and .../table/pc/controller/hotkeys.ts.
|
|
13
|
+
*
|
|
14
|
+
* Three of their behaviours are deliberately NOT copied.
|
|
15
|
+
*
|
|
16
|
+
* 1. The inescapable Escape loop. In their hotkeys, Escape on a cell promotes
|
|
17
|
+
* to row selection and Escape on a row selection converts the rows straight
|
|
18
|
+
* back into an area selection — the two rungs trade places forever and the
|
|
19
|
+
* table never lets go. Here Escape walks a strictly decreasing ladder
|
|
20
|
+
* (editing → cell → row → none) and then reports `ignored` so the key
|
|
21
|
+
* belongs to whatever encloses the block. `selectionRung` is the proof
|
|
22
|
+
* obligation: every Escape lowers it, so the walk terminates.
|
|
23
|
+
*
|
|
24
|
+
* 2. Tab dead-ending. Theirs wraps to the next row and stops moving at the last
|
|
25
|
+
* cell while still swallowing the key, so a keyboard user is trapped in the
|
|
26
|
+
* grid with no way forward. Here Tab past the last cell RELEASES — the
|
|
27
|
+
* caller lets the browser move focus on — and the selection value is left
|
|
28
|
+
* intact so tabbing back in lands where you left.
|
|
29
|
+
*
|
|
30
|
+
* 3. Silent undefined. Their cell lookups go through a DOM query that returns
|
|
31
|
+
* `undefined` and the handler simply does nothing, so a coordinate that has
|
|
32
|
+
* drifted out of range is indistinguishable from a key that did nothing.
|
|
33
|
+
* This reducer is total: coordinates are clamped against the shape on every
|
|
34
|
+
* transition, and every call returns a named outcome.
|
|
35
|
+
*/
|
|
36
|
+
export const SHEET_SELECTION_NONE = { kind: "none" };
|
|
37
|
+
/**
|
|
38
|
+
* The ladder rung. Escape lowers it by exactly one, which is the whole reason
|
|
39
|
+
* the Escape walk terminates rather than ping-ponging.
|
|
40
|
+
*/
|
|
41
|
+
export function selectionRung(selection) {
|
|
42
|
+
if (selection.kind === "none")
|
|
43
|
+
return 0;
|
|
44
|
+
if (selection.kind === "row")
|
|
45
|
+
return 1;
|
|
46
|
+
return selection.isEditing ? 3 : 2;
|
|
47
|
+
}
|
|
48
|
+
function clamp(value, max) {
|
|
49
|
+
if (!Number.isFinite(value))
|
|
50
|
+
return 0;
|
|
51
|
+
if (value < 0)
|
|
52
|
+
return 0;
|
|
53
|
+
if (value > max)
|
|
54
|
+
return max;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function inShape(shape) {
|
|
58
|
+
return shape.rows > 0 && shape.columns > 0;
|
|
59
|
+
}
|
|
60
|
+
/** Every coordinate that leaves this module has been pulled back in range. */
|
|
61
|
+
function normalize(selection, shape) {
|
|
62
|
+
if (!inShape(shape))
|
|
63
|
+
return SHEET_SELECTION_NONE;
|
|
64
|
+
if (selection.kind === "row") {
|
|
65
|
+
return { kind: "row", rowIndex: clamp(selection.rowIndex, shape.rows - 1) };
|
|
66
|
+
}
|
|
67
|
+
if (selection.kind === "cell") {
|
|
68
|
+
return {
|
|
69
|
+
kind: "cell",
|
|
70
|
+
rowIndex: clamp(selection.rowIndex, shape.rows - 1),
|
|
71
|
+
columnIndex: clamp(selection.columnIndex, shape.columns - 1),
|
|
72
|
+
isEditing: selection.isEditing,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return selection;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Re-seat a selection against a new shape. The block source can change under
|
|
79
|
+
* the selection — an agent writes a row, a paste shortens one — and the value
|
|
80
|
+
* has to stay meaningful without being thrown away.
|
|
81
|
+
*/
|
|
82
|
+
export function reseatSelection(selection, shape) {
|
|
83
|
+
return normalize(selection, shape);
|
|
84
|
+
}
|
|
85
|
+
function stay(selection, outcome = "handled") {
|
|
86
|
+
return { selection, outcome, write: null, draft: null };
|
|
87
|
+
}
|
|
88
|
+
/** Linear cell index, so Tab is one arithmetic step and its edges are obvious. */
|
|
89
|
+
function linearIndex(rowIndex, columnIndex, shape) {
|
|
90
|
+
return rowIndex * shape.columns + columnIndex;
|
|
91
|
+
}
|
|
92
|
+
export function sheetSelectionReducer(selection, action, context) {
|
|
93
|
+
const shape = context.shape;
|
|
94
|
+
if (!inShape(shape))
|
|
95
|
+
return stay(SHEET_SELECTION_NONE, "ignored");
|
|
96
|
+
const current = normalize(selection, shape);
|
|
97
|
+
switch (action.type) {
|
|
98
|
+
case "focusCell": {
|
|
99
|
+
const next = normalize({
|
|
100
|
+
kind: "cell",
|
|
101
|
+
rowIndex: action.rowIndex,
|
|
102
|
+
columnIndex: action.columnIndex,
|
|
103
|
+
isEditing: action.edit === true,
|
|
104
|
+
}, shape);
|
|
105
|
+
const draft = next.kind === "cell" && next.isEditing ? context.read(next.rowIndex, next.columnIndex) : null;
|
|
106
|
+
return { selection: next, outcome: "handled", write: null, draft };
|
|
107
|
+
}
|
|
108
|
+
case "release":
|
|
109
|
+
return stay(SHEET_SELECTION_NONE);
|
|
110
|
+
case "move": {
|
|
111
|
+
if (current.kind === "none") {
|
|
112
|
+
return {
|
|
113
|
+
selection: { kind: "cell", rowIndex: 0, columnIndex: 0, isEditing: false },
|
|
114
|
+
outcome: "handled",
|
|
115
|
+
write: null,
|
|
116
|
+
draft: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (current.kind === "row") {
|
|
120
|
+
// A row is a whole-row rung: up and down walk rows, right steps back
|
|
121
|
+
// into the row's first cell. Left has nothing lower to go to.
|
|
122
|
+
if (action.direction === "up" || action.direction === "down") {
|
|
123
|
+
const delta = action.direction === "up" ? -1 : 1;
|
|
124
|
+
return stay(normalize({ kind: "row", rowIndex: current.rowIndex + delta }, shape));
|
|
125
|
+
}
|
|
126
|
+
if (action.direction === "right") {
|
|
127
|
+
return stay(normalize({ kind: "cell", rowIndex: current.rowIndex, columnIndex: 0, isEditing: false }, shape));
|
|
128
|
+
}
|
|
129
|
+
return stay(current);
|
|
130
|
+
}
|
|
131
|
+
// Arrows inside an open editor belong to the caret, not to the grid.
|
|
132
|
+
if (current.isEditing)
|
|
133
|
+
return stay(current, "ignored");
|
|
134
|
+
const rowDelta = action.direction === "up" ? -1 : action.direction === "down" ? 1 : 0;
|
|
135
|
+
const columnDelta = action.direction === "left" ? -1 : action.direction === "right" ? 1 : 0;
|
|
136
|
+
// Arrows clamp at the edges rather than wrapping: an arrow key names a
|
|
137
|
+
// direction on the grid, and wrapping would make it name a different one.
|
|
138
|
+
return stay(normalize({
|
|
139
|
+
kind: "cell",
|
|
140
|
+
rowIndex: current.rowIndex + rowDelta,
|
|
141
|
+
columnIndex: current.columnIndex + columnDelta,
|
|
142
|
+
isEditing: false,
|
|
143
|
+
}, shape));
|
|
144
|
+
}
|
|
145
|
+
case "step": {
|
|
146
|
+
if (current.kind === "none") {
|
|
147
|
+
const first = action.direction === "forward"
|
|
148
|
+
? { rowIndex: 0, columnIndex: 0 }
|
|
149
|
+
: { rowIndex: shape.rows - 1, columnIndex: shape.columns - 1 };
|
|
150
|
+
return stay({ kind: "cell", ...first, isEditing: false });
|
|
151
|
+
}
|
|
152
|
+
if (current.kind === "row") {
|
|
153
|
+
return stay(normalize({ kind: "cell", rowIndex: current.rowIndex, columnIndex: 0, isEditing: false }, shape));
|
|
154
|
+
}
|
|
155
|
+
const total = shape.rows * shape.columns;
|
|
156
|
+
const index = linearIndex(current.rowIndex, current.columnIndex, shape);
|
|
157
|
+
const nextIndex = action.direction === "forward" ? index + 1 : index - 1;
|
|
158
|
+
if (nextIndex < 0 || nextIndex >= total) {
|
|
159
|
+
// The grid is finished with the key. The coordinate is KEPT — tabbing
|
|
160
|
+
// back in returns to the cell you left, which is the whole reason not
|
|
161
|
+
// to dead-end here.
|
|
162
|
+
return stay({ kind: "cell", rowIndex: current.rowIndex, columnIndex: current.columnIndex, isEditing: false }, action.direction === "forward" ? "released-forward" : "released-back");
|
|
163
|
+
}
|
|
164
|
+
return stay({
|
|
165
|
+
kind: "cell",
|
|
166
|
+
rowIndex: Math.floor(nextIndex / shape.columns),
|
|
167
|
+
columnIndex: nextIndex % shape.columns,
|
|
168
|
+
isEditing: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
case "enter": {
|
|
172
|
+
if (current.kind === "none") {
|
|
173
|
+
return stay({ kind: "cell", rowIndex: 0, columnIndex: 0, isEditing: false });
|
|
174
|
+
}
|
|
175
|
+
if (current.kind === "row") {
|
|
176
|
+
return stay(normalize({ kind: "cell", rowIndex: current.rowIndex, columnIndex: 0, isEditing: false }, shape));
|
|
177
|
+
}
|
|
178
|
+
if (current.isEditing) {
|
|
179
|
+
// Committing is the caller's job — it holds the draft. Enter only says
|
|
180
|
+
// where to land afterwards.
|
|
181
|
+
return stay(current, "handled");
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
selection: { ...current, isEditing: true },
|
|
185
|
+
outcome: "handled",
|
|
186
|
+
write: null,
|
|
187
|
+
draft: context.read(current.rowIndex, current.columnIndex),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
case "escape": {
|
|
191
|
+
if (current.kind === "none")
|
|
192
|
+
return stay(current, "ignored");
|
|
193
|
+
if (current.kind === "row")
|
|
194
|
+
return stay(SHEET_SELECTION_NONE);
|
|
195
|
+
if (current.isEditing) {
|
|
196
|
+
// Escape discards. No write leaves this branch, which is what makes
|
|
197
|
+
// "Escape returns to cell selection" safe to press on a typo.
|
|
198
|
+
return stay({ ...current, isEditing: false });
|
|
199
|
+
}
|
|
200
|
+
return stay({ kind: "row", rowIndex: current.rowIndex });
|
|
201
|
+
}
|
|
202
|
+
case "typeChar": {
|
|
203
|
+
if (current.kind !== "cell" || current.isEditing)
|
|
204
|
+
return stay(current, "ignored");
|
|
205
|
+
if (action.char.length !== 1)
|
|
206
|
+
return stay(current, "ignored");
|
|
207
|
+
// Typing REPLACES: the draft starts as the character, not as the cell.
|
|
208
|
+
return {
|
|
209
|
+
selection: { ...current, isEditing: true },
|
|
210
|
+
outcome: "handled",
|
|
211
|
+
write: null,
|
|
212
|
+
draft: action.char,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
case "clear": {
|
|
216
|
+
if (current.kind !== "cell" || current.isEditing)
|
|
217
|
+
return stay(current, "ignored");
|
|
218
|
+
return {
|
|
219
|
+
selection: current,
|
|
220
|
+
outcome: "handled",
|
|
221
|
+
write: { rowIndex: current.rowIndex, columnIndex: current.columnIndex, value: "" },
|
|
222
|
+
draft: null,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
case "commit": {
|
|
226
|
+
if (current.kind !== "cell")
|
|
227
|
+
return stay(current, "ignored");
|
|
228
|
+
const write = {
|
|
229
|
+
rowIndex: current.rowIndex,
|
|
230
|
+
columnIndex: current.columnIndex,
|
|
231
|
+
value: action.value,
|
|
232
|
+
};
|
|
233
|
+
if (action.then === "stay") {
|
|
234
|
+
return { selection: { ...current, isEditing: false }, outcome: "handled", write, draft: null };
|
|
235
|
+
}
|
|
236
|
+
if (action.then === "down") {
|
|
237
|
+
const next = normalize({ kind: "cell", rowIndex: current.rowIndex + 1, columnIndex: current.columnIndex, isEditing: false }, shape);
|
|
238
|
+
return { selection: next, outcome: "handled", write, draft: null };
|
|
239
|
+
}
|
|
240
|
+
const stepped = sheetSelectionReducer({ ...current, isEditing: false }, { type: "step", direction: action.then === "forward" ? "forward" : "back" }, context);
|
|
241
|
+
return { selection: stepped.selection, outcome: stepped.outcome, write, draft: null };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A keyboard event, read as an action. Split from the reducer so the key map
|
|
247
|
+
* is a table a test can walk without a DOM.
|
|
248
|
+
*
|
|
249
|
+
* Returns null for keys the block has no opinion about, which is the same
|
|
250
|
+
* statement as `ignored` — nothing here silently swallows a key.
|
|
251
|
+
*/
|
|
252
|
+
export function sheetKeyAction(event) {
|
|
253
|
+
const modified = event.metaKey === true || event.ctrlKey === true || event.altKey === true;
|
|
254
|
+
switch (event.key) {
|
|
255
|
+
case "ArrowUp":
|
|
256
|
+
return modified ? null : { type: "move", direction: "up" };
|
|
257
|
+
case "ArrowDown":
|
|
258
|
+
return modified ? null : { type: "move", direction: "down" };
|
|
259
|
+
case "ArrowLeft":
|
|
260
|
+
return modified ? null : { type: "move", direction: "left" };
|
|
261
|
+
case "ArrowRight":
|
|
262
|
+
return modified ? null : { type: "move", direction: "right" };
|
|
263
|
+
case "Tab":
|
|
264
|
+
return modified ? null : { type: "step", direction: event.shiftKey ? "back" : "forward" };
|
|
265
|
+
case "Enter":
|
|
266
|
+
return modified ? null : { type: "enter" };
|
|
267
|
+
case "Escape":
|
|
268
|
+
return { type: "escape" };
|
|
269
|
+
case "Backspace":
|
|
270
|
+
case "Delete":
|
|
271
|
+
return modified ? null : { type: "clear" };
|
|
272
|
+
default:
|
|
273
|
+
if (!modified && event.key.length === 1)
|
|
274
|
+
return { type: "typeChar", char: event.key };
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Where Enter should land after committing an edit — down a row, matching
|
|
280
|
+
* every spreadsheet. Kept as a named function so the hook and its test agree.
|
|
281
|
+
*/
|
|
282
|
+
export const ENTER_COMMIT_LANDING = "down";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document statistics for editor footers.
|
|
3
|
+
*
|
|
4
|
+
* Word counting splits with `Intl.Segmenter` where it exists: whitespace
|
|
5
|
+
* splitting reports one word for an entire Chinese or Japanese sentence, which
|
|
6
|
+
* is the bug open-knowledge's `selection-stats.ts` fixes the same way. The
|
|
7
|
+
* fallback keeps that property by counting CJK ideographs individually.
|
|
8
|
+
*
|
|
9
|
+
* Token count is a chars/4 heuristic, not a tokenizer — always render it with a
|
|
10
|
+
* leading `~` so it never reads as exact.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Words in a chunk of Markdown. Punctuation-only segments (`#`, `*`, `|`) are
|
|
14
|
+
* not word-like, so Markdown syntax drops out without a stripping pass.
|
|
15
|
+
*/
|
|
16
|
+
export declare function countWords(text: string): number;
|
|
17
|
+
/** Rough token count: ~4 characters per token, the usual English-prose ratio. */
|
|
18
|
+
export declare function estimateTokens(text: string): number;
|
|
19
|
+
export type DocumentStats = {
|
|
20
|
+
words: number;
|
|
21
|
+
tokens: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function documentStats(text: string): DocumentStats;
|