The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
/**
|
|
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
|
|
*
|
|
* All coordinates the engine uses internally are 0-based. The A1 notation
|
|
* exposed to the user is 1-based for rows and letter-based for columns
|
|
* (A=0, B=1, ..., Z=25, AA=26, ...).
|
|
*/
|
|
|
|
import { tokenize, type Token } from './lexer';
|
|
|
|
const CELL_RE = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})$/;
|
|
|
|
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
|
|
export function colToNumber(letters: string): number {
|
|
let n = 0;
|
|
const up = letters.toUpperCase();
|
|
for (let i = 0; i < up.length; i++) {
|
|
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
|
|
}
|
|
return n - 1;
|
|
}
|
|
|
|
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
|
|
export function numberToCol(n: number): string {
|
|
let s = '';
|
|
let x = n + 1;
|
|
while (x > 0) {
|
|
const rem = (x - 1) % 26;
|
|
s = String.fromCharCode(65 + rem) + s;
|
|
x = Math.floor((x - 1) / 26);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
|
|
export function looksLikeCell(text: string): boolean {
|
|
return CELL_RE.test(text);
|
|
}
|
|
|
|
/**
|
|
* Parse "A1" / "$A1" / "A$1" / "$A$1" -> 0-based coords plus which axes are
|
|
* `$`-locked (absolute). A locked axis is exempt from the reference shift a
|
|
* copy/fill applies (see `shiftFormula` in the store); an unlocked one is
|
|
* "relative" and always shifts with it. Throws for malformed input.
|
|
*/
|
|
export function parseA1(
|
|
text: string,
|
|
): { col: number; row: number; colAbs: boolean; rowAbs: boolean } {
|
|
const m = CELL_RE.exec(text);
|
|
if (!m) throw new Error(`Bad cell reference: ${text}`);
|
|
return { col: colToNumber(m[2]), row: Number(m[4]) - 1, colAbs: m[1] === '$', rowAbs: m[3] === '$' };
|
|
}
|
|
|
|
/** Build "A1" from 0-based coordinates; `colAbs`/`rowAbs` add the `$` lock markers. */
|
|
export function toA1(col: number, row: number, colAbs = false, rowAbs = false): string {
|
|
return `${colAbs ? '$' : ''}${numberToCol(col)}${rowAbs ? '$' : ''}${row + 1}`;
|
|
}
|
|
|
|
/**
|
|
* Excel/Numbers-style F4: cycle every cell reference through
|
|
* relative -> absolute -> row-locked -> column-locked -> relative, each
|
|
* from its own current state.
|
|
*
|
|
* With a collapsed selection (`selStart === selEnd`, the common case -- just
|
|
* a caret), this toggles the single reference at/adjacent to it. With a real
|
|
* text selection, it toggles *every* reference the selection overlaps at
|
|
* once -- select a stretch of formula text spanning several references and
|
|
* F4 adjusts them all in one press, same as Excel.
|
|
*
|
|
* `selStart`/`selEnd` are 0-based offsets into `formula` (which must start
|
|
* with "="), i.e. straight from an `<input>`/`<textarea>`'s
|
|
* `selectionStart`/`selectionEnd`. Returns the rewritten formula and a
|
|
* selection re-spanning the (possibly resized) references, so a repeated F4
|
|
* keeps cycling the same ones; `null` when none were found.
|
|
*/
|
|
export function cycleReferenceLocks(
|
|
formula: string,
|
|
selStart: number,
|
|
selEnd: number,
|
|
): { text: string; start: number; end: number } | null {
|
|
if (!formula.startsWith('=')) return null;
|
|
const body = formula.slice(1);
|
|
const lo = Math.min(selStart, selEnd) - 1;
|
|
const hi = Math.max(selStart, selEnd) - 1;
|
|
const collapsed = lo === hi;
|
|
let toks: Token[];
|
|
try {
|
|
toks = tokenize(body);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const targets: Token[] = [];
|
|
for (let i = 0; i < toks.length; i++) {
|
|
const t = toks[i];
|
|
if (t.type !== 'IDENT' || !looksLikeCell(t.value)) continue;
|
|
const next = toks[i + 1];
|
|
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
|
|
const tEnd = t.pos + t.value.length;
|
|
const hit = collapsed ? lo >= t.pos && lo <= tEnd : t.pos < hi && tEnd > lo;
|
|
if (hit) targets.push(t);
|
|
}
|
|
if (!targets.length) return null;
|
|
|
|
let out = '';
|
|
let last = 0;
|
|
let start = -1;
|
|
let end = -1;
|
|
for (const tok of targets) {
|
|
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
|
|
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
|
|
const rewritten = toA1(col, row, nColAbs, nRowAbs);
|
|
out += body.slice(last, tok.pos) + rewritten;
|
|
if (start === -1) start = out.length - rewritten.length;
|
|
end = out.length;
|
|
last = tok.pos + tok.value.length;
|
|
}
|
|
out += body.slice(last);
|
|
// a collapsed caret in -> a collapsed caret out (after the one toggled
|
|
// reference), same as before; a real selection in keeps spanning every
|
|
// reference it touched, so a repeated F4 keeps cycling all of them.
|
|
return collapsed
|
|
? { text: '=' + out, start: 1 + end, end: 1 + end }
|
|
: { text: '=' + out, start: 1 + start, end: 1 + end };
|
|
}
|
|
|
|
/** A1 -> $A$1 -> A$1 -> $A1 -> A1 -> ... (the classic F4 cycle). */
|
|
function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
|
|
if (!colAbs && !rowAbs) return [true, true];
|
|
if (colAbs && rowAbs) return [false, true];
|
|
if (!colAbs && rowAbs) return [true, false];
|
|
return [false, false]; // colAbs && !rowAbs
|
|
}
|
|
|
|
/**
|
|
* If the caret sits inside (anywhere within, or right at the end of) a bare
|
|
* identifier being typed in a formula -- not yet a resolved cell reference,
|
|
* not the start of an already-open function call -- return the identifier's
|
|
* text *up to the caret* and its start offset in `formula` (which still
|
|
* includes the leading "="). Drives function-name autocomplete; `null`
|
|
* means "nothing to suggest here" (not a formula at all, a cell reference
|
|
* like "A1", the caret sitting in whitespace or right after a completed
|
|
* token rather than still touching it, ...).
|
|
*
|
|
* Same shape as {@link cycleReferenceLocks} above: strip the leading "=",
|
|
* tokenize, and inspect the token(s) around the caret -- except here only
|
|
* the prefix up to the caret is tokenized (nothing after it is ever looked
|
|
* at, same as how autocomplete works in most editors), so the "identifier"
|
|
* found is always exactly what's to the *left* of the caret, regardless of
|
|
* whether more of the same word continues to its right.
|
|
*/
|
|
export function currentIdentifierAtCaret(
|
|
formula: string,
|
|
caret: number,
|
|
): { text: string; start: number } | null {
|
|
if (!formula.startsWith('=')) return null;
|
|
const pos = caret - 1; // caret position relative to body = formula.slice(1)
|
|
if (pos < 0) return null;
|
|
let toks: Token[];
|
|
try {
|
|
toks = tokenize(formula.slice(1, caret));
|
|
} catch {
|
|
return null;
|
|
}
|
|
// tokenize() appends a trailing EOF marker -- skip it to get the last
|
|
// *real* token.
|
|
const real = toks.filter((t) => t.type !== 'EOF');
|
|
const last = real[real.length - 1];
|
|
if (!last || last.type !== 'IDENT') return null;
|
|
// the token must reach all the way to the caret -- if it doesn't, the
|
|
// caret has moved past it (trailing whitespace, e.g. "=SUM |") and
|
|
// there's no word actively being typed right at the caret any more.
|
|
if (last.pos + last.value.length !== pos) return null;
|
|
if (looksLikeCell(last.value)) return null; // looks like a cell reference, not a function name
|
|
return { text: last.value, start: last.pos + 1 };
|
|
}
|
|
|
|
// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ----
|
|
|
|
/** Stand-in for "the last row/column" in a whole-column/whole-row range's
|
|
* open end. Every consumer clamps it to the actual table size (see
|
|
* {@link rangeCells}); a consumer that merely clamps to its table (the
|
|
* Name Box selection) gets the right answer for free. */
|
|
export const WHOLE_LINE_END = 9_999_998;
|
|
|
|
export type LineAxis = 'col' | 'row';
|
|
|
|
const COL_PART_RE = /^(\$?)([A-Za-z]{1,3})$/;
|
|
const ROW_PART_RE = /^(\$?)([0-9]{1,7})$/;
|
|
|
|
/** One side of a whole-line reference: `"B"`/`"$B"` (a column) or
|
|
* `"3"`/`"$3"` (a 1-based row) -> its 0-based index, or `null`. */
|
|
export function parseLinePart(text: string): { axis: LineAxis; index: number; abs: boolean } | null {
|
|
let m = COL_PART_RE.exec(text);
|
|
if (m) return { axis: 'col', index: colToNumber(m[2]), abs: m[1] === '$' };
|
|
m = ROW_PART_RE.exec(text);
|
|
if (m && Number(m[2]) >= 1) return { axis: 'row', index: Number(m[2]) - 1, abs: m[1] === '$' };
|
|
return null;
|
|
}
|
|
|
|
export function formatLinePart(axis: LineAxis, index: number, abs: boolean): string {
|
|
return (abs ? '$' : '') + (axis === 'col' ? numberToCol(index) : String(index + 1));
|
|
}
|
|
|
|
/** If `toks[i]`, `toks[i+1]`, `toks[i+2]` spell a whole-line range (`B:B`,
|
|
* `A:C`, `1:1`, `$2:$5` -- both sides the same axis), return its parts.
|
|
* Shared by the parser and every token-level formula rewriter (copy,
|
|
* row/column insert/delete, table rename), so they all agree on the shape. */
|
|
export function wholeLineAt(
|
|
toks: Token[],
|
|
i: number,
|
|
): { axis: LineAxis; a: Token; b: Token; start: number; end: number; startAbs: boolean; endAbs: boolean } | null {
|
|
const a = toks[i];
|
|
const colon = toks[i + 1];
|
|
const b = toks[i + 2];
|
|
if (!a || !colon || !b || colon.type !== 'COLON') return null;
|
|
if ((a.type !== 'IDENT' && a.type !== 'NUMBER') || (b.type !== 'IDENT' && b.type !== 'NUMBER')) return null;
|
|
const pa = parseLinePart(a.value);
|
|
const pb = parseLinePart(b.value);
|
|
if (!pa || !pb || pa.axis !== pb.axis) return null;
|
|
return { axis: pa.axis, a, b, start: pa.index, end: pb.index, startAbs: pa.abs, endAbs: pb.abs };
|
|
}
|
|
|
|
/** The cells a range covers, with a whole-column/whole-row range's open
|
|
* end clamped to its table's current size (`size` null = unknown table ->
|
|
* no cells). An ordinary range is just {@link expandRange}. */
|
|
export function rangeCells(
|
|
r: { start: { col: number; row: number }; end: { col: number; row: number }; whole?: LineAxis },
|
|
size: { cols: number; rows: number } | null,
|
|
): Array<{ col: number; row: number }> {
|
|
if (!r.whole) return expandRange(r.start, r.end);
|
|
if (!size) return [];
|
|
const clamp = (v: number, n: number) => Math.min(v, n - 1);
|
|
const start = { col: Math.min(r.start.col, r.end.col), row: Math.min(r.start.row, r.end.row) };
|
|
const end = { col: Math.max(r.start.col, r.end.col), row: Math.max(r.start.row, r.end.row) };
|
|
// a whole line entirely beyond the table covers nothing
|
|
if (r.whole === 'col' && start.col >= size.cols) return [];
|
|
if (r.whole === 'row' && start.row >= size.rows) return [];
|
|
return expandRange(start, { col: clamp(end.col, size.cols), row: clamp(end.row, size.rows) });
|
|
}
|
|
|
|
/**
|
|
* Expand a rectangular range (given by two corners, any order) into the list of
|
|
* cell coordinates it covers, row-major.
|
|
*/
|
|
export function expandRange(
|
|
a: { col: number; row: number },
|
|
b: { col: number; row: number },
|
|
): Array<{ col: number; row: number }> {
|
|
const c0 = Math.min(a.col, b.col);
|
|
const c1 = Math.max(a.col, b.col);
|
|
const r0 = Math.min(a.row, b.row);
|
|
const r1 = Math.max(a.row, b.row);
|
|
const out: Array<{ col: number; row: number }> = [];
|
|
for (let r = r0; r <= r1; r++) {
|
|
for (let c = c0; c <= c1; c++) {
|
|
out.push({ col: c, row: r });
|
|
}
|
|
}
|
|
return out;
|
|
}
|