Add Rails backend: accounts via magic link, server documents, sharing
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>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user