···99 * only the additional rules that are NOT built-in, plus exports a complete
1010 * inventory of all active rules for documentation and testing.
1111 */
1212+import type { AutoformatRule, AutoformatMatch, ParsedLink } from './types.js';
12131314/**
1415 * Regex for markdown-style links: [text](url)
···2223/**
2324 * All active autoformat rules in the editor, including built-in ones.
2425 * Used for testing and documentation purposes.
2525- *
2626- * Each entry describes:
2727- * - id: unique identifier
2828- * - description: what the rule does
2929- * - trigger: what the user types
3030- * - regex: the pattern that fires the rule
3131- * - source: which TipTap extension provides it
3232- * - custom: true if we implement it (false = built-in)
3326 */
3434-export const AUTOFORMAT_RULES = [
2727+export const AUTOFORMAT_RULES: AutoformatRule[] = [
3528 // Block-level rules (built-in via StarterKit)
3629 {
3730 id: 'codeBlock',
···153146154147/**
155148 * Determine which autoformat rule (if any) matches the given input text.
156156- *
157157- * @param {string} text - The text content at the current cursor position
158158- * @returns {{ id: string, match: RegExpMatchArray } | null}
159149 */
160160-export function resolveAutoformat(text) {
150150+export function resolveAutoformat(text: string): AutoformatMatch | null {
161151 for (const rule of AUTOFORMAT_RULES) {
162152 const match = text.match(rule.regex);
163153 if (match) {
···169159170160/**
171161 * Parse a markdown link match into its components.
172172- *
173173- * @param {RegExpMatchArray} match - The regex match from linkInputRegex
174174- * @returns {{ text: string, href: string }}
175162 */
176176-export function parseLinkMatch(match) {
163163+export function parseLinkMatch(match: RegExpMatchArray): ParsedLink {
177164 return {
178165 text: match[1],
179166 href: match[2],
···1010 */
11111212import MarkdownIt from 'markdown-it';
1313+import type { Token, Renderer, MarkdownItOptions, MarkdownItPlugin } from 'markdown-it';
13141415// Initialize markdown-it with GFM-like defaults
1516const md = new MarkdownIt({
···2930 * Custom plugin: task list checkboxes (GFM style)
3031 * Converts `- [ ] text` and `- [x] text` into checkbox list items.
3132 */
3232-function taskListPlugin(md) {
3333+const taskListPlugin: MarkdownItPlugin = function taskListPlugin(md: MarkdownIt): void {
3334 const defaultRender = md.renderer.rules.list_item_open ||
3434- function (tokens, idx, options, env, self) {
3535+ function (tokens: Token[], idx: number, options: MarkdownItOptions, _env: unknown, self: Renderer): string {
3536 return self.renderToken(tokens, idx, options);
3637 };
37383838- md.renderer.rules.list_item_open = function (tokens, idx, options, env, self) {
3939+ md.renderer.rules.list_item_open = function (tokens: Token[], idx: number, options: MarkdownItOptions, env: unknown, self: Renderer): string {
3940 // Look at the inline content of this list item
4041 const contentToken = tokens[idx + 2]; // list_item_open -> paragraph_open -> inline
4142 if (contentToken && contentToken.type === 'inline' && contentToken.content) {
···5758 }
5859 return defaultRender(tokens, idx, options, env, self);
5960 };
6060-}
6161+};
61626263md.use(taskListPlugin);
63646465/**
6566 * Convert a markdown string to HTML.
6666- *
6767- * @param {string} mdString - Raw markdown content
6868- * @returns {string} HTML string suitable for TipTap editor
6967 */
7070-export function markdownToHtml(mdString) {
6868+export function markdownToHtml(mdString: string): string {
7169 if (!mdString) return '';
7270 return md.render(mdString);
7371}
-87
src/docs/markdown-toggle.js
···11-/**
22- * Markdown Toggle (Source View)
33- *
44- * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes.
55- * Pure state management — no DOM manipulation. The caller (main.js) handles
66- * the actual UI show/hide of editor vs textarea.
77- *
88- * Usage:
99- * const toggle = createMarkdownToggle({
1010- * getEditorHtml: () => editor.getHTML(),
1111- * setEditorHtml: (html) => editor.commands.setContent(html),
1212- * htmlToMarkdown: (html) => htmlToMarkdown(html),
1313- * markdownToHtml: (md) => markdownToHtml(md),
1414- * onModeChange: (mode) => { ... }, // optional callback
1515- * });
1616- *
1717- * toggle.toggle(); // Switch modes
1818- * toggle.getMode(); // Current mode
1919- */
2020-2121-export const TOGGLE_MODE = Object.freeze({
2222- WYSIWYG: 'wysiwyg',
2323- MARKDOWN: 'markdown',
2424-});
2525-2626-/**
2727- * Create a markdown toggle state manager.
2828- *
2929- * @param {Object} opts
3030- * @param {() => string} opts.getEditorHtml - Get current TipTap HTML content
3131- * @param {(html: string) => void} opts.setEditorHtml - Set TipTap HTML content
3232- * @param {(html: string) => string} opts.htmlToMarkdown - Convert HTML to markdown
3333- * @param {(md: string) => string} opts.markdownToHtml - Convert markdown to HTML
3434- * @param {(mode: string) => void} [opts.onModeChange] - Optional callback on mode change
3535- * @returns {Object} Toggle API
3636- */
3737-export function createMarkdownToggle(opts) {
3838- const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts;
3939-4040- let mode = TOGGLE_MODE.WYSIWYG;
4141- let markdownContent = '';
4242-4343- function toggle() {
4444- if (mode === TOGGLE_MODE.WYSIWYG) {
4545- // Switching TO markdown mode: convert current editor HTML to markdown
4646- markdownContent = htmlToMarkdown(getEditorHtml());
4747- mode = TOGGLE_MODE.MARKDOWN;
4848- } else {
4949- // Switching BACK to WYSIWYG: parse markdown to HTML and load into editor
5050- const html = markdownToHtml(markdownContent);
5151- setEditorHtml(html);
5252- markdownContent = '';
5353- mode = TOGGLE_MODE.WYSIWYG;
5454- }
5555-5656- if (onModeChange) {
5757- onModeChange(mode);
5858- }
5959- }
6060-6161- function getMode() {
6262- return mode;
6363- }
6464-6565- function isMarkdownMode() {
6666- return mode === TOGGLE_MODE.MARKDOWN;
6767- }
6868-6969- function getMarkdownContent() {
7070- return markdownContent;
7171- }
7272-7373- function setMarkdownContent(content) {
7474- if (mode === TOGGLE_MODE.MARKDOWN) {
7575- markdownContent = content;
7676- }
7777- // Ignored in WYSIWYG mode
7878- }
7979-8080- return {
8181- toggle,
8282- getMode,
8383- isMarkdownMode,
8484- getMarkdownContent,
8585- setMarkdownContent,
8686- };
8787-}
+70
src/docs/markdown-toggle.ts
···11+/**
22+ * Markdown Toggle (Source View)
33+ *
44+ * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes.
55+ * Pure state management — no DOM manipulation. The caller (main.js) handles
66+ * the actual UI show/hide of editor vs textarea.
77+ */
88+import type { MarkdownToggleOptions, MarkdownToggleApi } from './types.js';
99+1010+export const TOGGLE_MODE = Object.freeze({
1111+ WYSIWYG: 'wysiwyg' as const,
1212+ MARKDOWN: 'markdown' as const,
1313+});
1414+1515+export type ToggleMode = typeof TOGGLE_MODE[keyof typeof TOGGLE_MODE];
1616+1717+/**
1818+ * Create a markdown toggle state manager.
1919+ */
2020+export function createMarkdownToggle(opts: MarkdownToggleOptions): MarkdownToggleApi {
2121+ const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts;
2222+2323+ let mode: ToggleMode = TOGGLE_MODE.WYSIWYG;
2424+ let markdownContent = '';
2525+2626+ function toggle(): void {
2727+ if (mode === TOGGLE_MODE.WYSIWYG) {
2828+ // Switching TO markdown mode: convert current editor HTML to markdown
2929+ markdownContent = htmlToMarkdown(getEditorHtml());
3030+ mode = TOGGLE_MODE.MARKDOWN;
3131+ } else {
3232+ // Switching BACK to WYSIWYG: parse markdown to HTML and load into editor
3333+ const html = markdownToHtml(markdownContent);
3434+ setEditorHtml(html);
3535+ markdownContent = '';
3636+ mode = TOGGLE_MODE.WYSIWYG;
3737+ }
3838+3939+ if (onModeChange) {
4040+ onModeChange(mode);
4141+ }
4242+ }
4343+4444+ function getMode(): string {
4545+ return mode;
4646+ }
4747+4848+ function isMarkdownMode(): boolean {
4949+ return mode === TOGGLE_MODE.MARKDOWN;
5050+ }
5151+5252+ function getMarkdownContent(): string {
5353+ return markdownContent;
5454+ }
5555+5656+ function setMarkdownContent(content: string): void {
5757+ if (mode === TOGGLE_MODE.MARKDOWN) {
5858+ markdownContent = content;
5959+ }
6060+ // Ignored in WYSIWYG mode
6161+ }
6262+6363+ return {
6464+ toggle,
6565+ getMode,
6666+ isMarkdownMode,
6767+ getMarkdownContent,
6868+ setMarkdownContent,
6969+ };
7070+}
+34-15
src/docs/outline.js
src/docs/outline.ts
···44 * Extracts headings (H1, H2, H3) from editor content and builds
55 * a navigable tree for the outline sidebar panel.
66 */
77+import type { OutlineItem, OutlineTreeNode } from './types.js';
88+99+interface EditorJsonNode {
1010+ type: string;
1111+ attrs?: { level?: number };
1212+ content?: EditorJsonChild[];
1313+}
1414+1515+interface EditorJsonChild {
1616+ type: string;
1717+ text?: string;
1818+}
1919+2020+interface EditorJson {
2121+ content?: EditorJsonNode[];
2222+}
723824/**
925 * Generate a URL-safe anchor ID from heading text.
1026 * Appends index suffix for uniqueness when index > 0.
1127 */
1212-export function generateHeadingId(text, index) {
2828+export function generateHeadingId(text: string, index?: number): string {
1329 let id = text
1430 .toLowerCase()
1531 .replace(/[^a-z0-9\s-]/g, '')
···2642/**
2743 * Extract heading text from a heading node's content array.
2844 */
2929-function getHeadingText(node) {
4545+function getHeadingText(node: EditorJsonNode): string {
3046 if (!node.content || !Array.isArray(node.content)) return '';
3147 return node.content
3248 .filter((child) => child.type === 'text')
···3854 * Extract all H1, H2, H3 headings from editor JSON content.
3955 * Returns a flat array of { level, text, id } objects.
4056 */
4141-export function extractHeadings(json) {
5757+export function extractHeadings(json: EditorJson): OutlineItem[] {
4258 if (!json || !json.content) return [];
43594444- const headings = [];
4545- const idCounts = {};
6060+ const headings: OutlineItem[] = [];
6161+ const idCounts: Record<string, number> = {};
46624763 for (const node of json.content) {
4864 if (node.type !== 'heading') continue;
4965 const level = node.attrs?.level;
5050- if (level < 1 || level > 3) continue;
6666+ if (level === undefined || level < 1 || level > 3) continue;
51675268 const text = getHeadingText(node);
5369 const baseId = generateHeadingId(text);
···6682 * Build a nested tree from a flat list of headings.
6783 * H2 nests under preceding H1, H3 nests under preceding H2.
6884 */
6969-export function buildOutlineTree(headings) {
8585+export function buildOutlineTree(headings: OutlineItem[]): OutlineTreeNode[] {
7086 if (!headings || headings.length === 0) return [];
71877272- const root = [];
7373- const stack = []; // stack of { node, level }
8888+ const root: OutlineTreeNode[] = [];
8989+ const stack: OutlineTreeNode[] = []; // stack of tree nodes
74907591 for (const heading of headings) {
7676- const node = { ...heading, children: [] };
9292+ const node: OutlineTreeNode = { ...heading, children: [] };
77937894 // Pop stack until we find a parent with a lower level
7995 while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
···96112 * Manages outline sidebar state.
97113 */
98114export class OutlineState {
115115+ isOpen: boolean;
116116+ headings: OutlineItem[];
117117+99118 constructor() {
100119 this.isOpen = false;
101120 this.headings = [];
102121 }
103122104104- toggle() {
123123+ toggle(): void {
105124 this.isOpen = !this.isOpen;
106125 }
107126108108- open() {
127127+ open(): void {
109128 this.isOpen = true;
110129 }
111130112112- close() {
131131+ close(): void {
113132 this.isOpen = false;
114133 }
115134116116- updateHeadings(headings) {
135135+ updateHeadings(headings: OutlineItem[]): void {
117136 this.headings = headings;
118137 }
119138120120- getTree() {
139139+ getTree(): OutlineTreeNode[] {
121140 return buildOutlineTree(this.headings);
122141 }
123142}
+4-14
src/docs/pdf-export.js
src/docs/pdf-export.ts
···44 * Uses html2pdf.js to convert the editor content into a downloadable PDF.
55 * Always exports in light mode regardless of current theme.
66 */
77+import type { PdfExportOptions } from './types.js';
7889/**
910 * Build a self-contained HTML string suitable for PDF rendering.
1011 * Extracted as a pure function for testability.
1111- *
1212- * @param {string} editorHtml - The TipTap editor's HTML output
1313- * @param {string} title - Document title
1414- * @returns {string} Full HTML document string for pdf rendering
1512 */
1616-export function buildPdfHtml(editorHtml, title) {
1313+export function buildPdfHtml(editorHtml: string, title: string): string {
1714 return `<!DOCTYPE html>
1815<html lang="en">
1916<head>
···50475148/**
5249 * Derive a safe filename from a document title.
5353- *
5454- * @param {string} title - The document title
5555- * @returns {string} Sanitized filename (without extension)
5650 */
5757-export function pdfFilename(title) {
5151+export function pdfFilename(title: string): string {
5852 const clean = (title || '').trim() || 'Untitled Document';
5953 return clean.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_');
6054}
···6256/**
6357 * Generate and download a PDF from editor content.
6458 * This is the DOM-coupled entry point — not unit-testable.
6565- *
6666- * @param {object} opts
6767- * @param {string} opts.editorHtml - HTML content from editor.getHTML()
6868- * @param {string} opts.title - Document title
6959 */
7070-export async function exportPdf({ editorHtml, title }) {
6060+export async function exportPdf({ editorHtml, title }: PdfExportOptions): Promise<void> {
7161 const html2pdf = (await import('html2pdf.js')).default;
72627363 const container = document.createElement('div');
···88 * - Version retrieval and restore
99 */
10101111+export interface VersionMetadata {
1212+ author: string | null;
1313+ wordCount: number;
1414+}
1515+1616+export interface VersionEntry {
1717+ id: string;
1818+ snapshot: Uint8Array;
1919+ author: string | null;
2020+ wordCount: number;
2121+ timestamp: number;
2222+}
2323+2424+export interface VersionInfo {
2525+ id: string;
2626+ author: string | null;
2727+ wordCount: number;
2828+ timestamp: number;
2929+}
3030+3131+export interface VersionInfoWithDelta extends VersionInfo {
3232+ wordCountDelta: string;
3333+}
3434+3535+export interface VersionManagerOptions {
3636+ maxVersions?: number;
3737+ editThreshold?: number;
3838+ timeThresholdMs?: number;
3939+}
4040+1141/**
1242 * Count words in a text string.
1313- * @param {string} text
1414- * @returns {number}
1543 */
1616-export function computeWordCount(text) {
4444+export function computeWordCount(text: string): number {
1745 if (!text || !text.trim()) return 0;
1846 return text.trim().split(/\s+/).length;
1947}
20482149/**
2250 * Compute a display string for word count change between versions.
2323- * @param {number|null} previousCount - word count of previous version (null if first)
2424- * @param {number} currentCount - word count of this version
2525- * @returns {string} e.g. "+5", "-3", "0"
2651 */
2727-export function computeWordCountDelta(previousCount, currentCount) {
5252+export function computeWordCountDelta(previousCount: number | null | undefined, currentCount: number): string {
2853 if (previousCount === null || previousCount === undefined) {
2954 return `+${currentCount}`;
3055 }
···3863 * Manages version history for a document.
3964 */
4065export class VersionManager {
4141- /**
4242- * @param {object} opts
4343- * @param {number} opts.maxVersions - Maximum versions to keep (FIFO)
4444- * @param {number} opts.editThreshold - Number of edits before auto-capture
4545- * @param {number} opts.timeThresholdMs - Time in ms before auto-capture
4646- */
4747- constructor(opts = {}) {
6666+ _maxVersions: number;
6767+ _editThreshold: number;
6868+ _timeThresholdMs: number;
6969+ _versions: VersionEntry[];
7070+ _editCount: number;
7171+ _lastCaptureTime: number;
7272+7373+ constructor(opts: VersionManagerOptions = {}) {
4874 this._maxVersions = opts.maxVersions || 50;
4975 this._editThreshold = opts.editThreshold || 50;
5076 this._timeThresholdMs = opts.timeThresholdMs || 5 * 60 * 1000;
51775252- /** @type {Array<{id: string, snapshot: Uint8Array, author: string, wordCount: number, timestamp: number}>} */
5378 this._versions = [];
5454-5579 this._editCount = 0;
5680 this._lastCaptureTime = Date.now();
5781 }
···5983 /**
6084 * Record a single edit. Used by the editor to track edits toward threshold.
6185 */
6262- recordEdit() {
8686+ recordEdit(): void {
6387 this._editCount++;
6488 }
6589···6892 * Returns true if edit threshold OR time threshold is reached.
6993 * At least one edit must have occurred for time trigger.
7094 */
7171- shouldCapture() {
9595+ shouldCapture(): boolean {
7296 if (this._editCount >= this._editThreshold) return true;
7397 if (this._editCount > 0 && (Date.now() - this._lastCaptureTime) >= this._timeThresholdMs) return true;
7498 return false;
···77101 /**
78102 * Reset capture counters. Call after a version is captured.
79103 */
8080- resetCapture() {
104104+ resetCapture(): void {
81105 this._editCount = 0;
82106 this._lastCaptureTime = Date.now();
83107 }
8410885109 /**
86110 * Add a version snapshot.
8787- * @param {Uint8Array} snapshot - The document state
8888- * @param {object} metadata - { author, wordCount }
89111 */
9090- addVersion(snapshot, metadata) {
112112+ addVersion(snapshot: Uint8Array, metadata: { author?: string | null; wordCount?: number }): void {
91113 const id = typeof crypto !== 'undefined' && crypto.randomUUID
92114 ? crypto.randomUUID()
93115 : `v-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
···112134 * Get all versions (newest first) with computed word count deltas.
113135 * Snapshots are NOT included in the returned objects (use getSnapshot).
114136 */
115115- getVersions() {
137137+ getVersions(): VersionInfoWithDelta[] {
116138 // Create a reversed copy (newest first)
117139 const reversed = [...this._versions].reverse();
118140···133155134156 /**
135157 * Get a specific version by ID (without snapshot).
136136- * @param {string} id
137137- * @returns {object|null}
138158 */
139139- getVersion(id) {
159159+ getVersion(id: string): VersionInfo | null {
140160 const v = this._versions.find(v => v.id === id);
141161 if (!v) return null;
142162 return {
···149169150170 /**
151171 * Get the snapshot data for a specific version.
152152- * @param {string} id
153153- * @returns {Uint8Array|null}
154172 */
155155- getSnapshot(id) {
173173+ getSnapshot(id: string): Uint8Array | null {
156174 const v = this._versions.find(v => v.id === id);
157175 return v ? v.snapshot : null;
158176 }
-93
src/sheets/cell-notes.js
···11-/**
22- * Cell Notes — plain text annotations on individual cells.
33- *
44- * Notes are displayed as hover tooltips with a small triangle
55- * indicator in the top-right corner of the cell. In the real app,
66- * the notes object is backed by a Yjs Map for collaboration sync.
77- *
88- * These functions operate on a plain object { cellId: noteText }
99- * and return a new object (immutable pattern for testability).
1010- */
1111-1212-/**
1313- * Create or set a note on a cell. Trims whitespace.
1414- * If text is empty/null, the note is not created.
1515- *
1616- * @param {Object} notes - Current notes map { cellId: text }
1717- * @param {string} cellId - Cell identifier (e.g., 'A1')
1818- * @param {string|null} text - Note text
1919- * @returns {Object} Updated notes map
2020- */
2121-export function createNote(notes, cellId, text) {
2222- const result = { ...notes };
2323- if (text === null || text === undefined) return result;
2424- const trimmed = text.trim();
2525- if (trimmed === '') return result;
2626- result[cellId] = trimmed;
2727- return result;
2828-}
2929-3030-/**
3131- * Update an existing note (or create if missing).
3232- * If the updated text is empty, the note is deleted.
3333- *
3434- * @param {Object} notes - Current notes map
3535- * @param {string} cellId - Cell identifier
3636- * @param {string} text - New note text
3737- * @returns {Object} Updated notes map
3838- */
3939-export function updateNote(notes, cellId, text) {
4040- const result = { ...notes };
4141- const trimmed = (text || '').trim();
4242- if (trimmed === '') {
4343- delete result[cellId];
4444- return result;
4545- }
4646- result[cellId] = trimmed;
4747- return result;
4848-}
4949-5050-/**
5151- * Delete a note from a cell.
5252- *
5353- * @param {Object} notes - Current notes map
5454- * @param {string} cellId - Cell identifier
5555- * @returns {Object} Updated notes map
5656- */
5757-export function deleteNote(notes, cellId) {
5858- const result = { ...notes };
5959- delete result[cellId];
6060- return result;
6161-}
6262-6363-/**
6464- * Get the note text for a cell.
6565- *
6666- * @param {Object} notes - Notes map
6767- * @param {string} cellId - Cell identifier
6868- * @returns {string|null} Note text or null
6969- */
7070-export function getNote(notes, cellId) {
7171- return notes[cellId] ?? null;
7272-}
7373-7474-/**
7575- * Check if a cell has a note.
7676- *
7777- * @param {Object} notes - Notes map
7878- * @param {string} cellId - Cell identifier
7979- * @returns {boolean}
8080- */
8181-export function hasNote(notes, cellId) {
8282- return cellId in notes;
8383-}
8484-8585-/**
8686- * Get all cell IDs that have notes.
8787- *
8888- * @param {Object} notes - Notes map
8989- * @returns {string[]} Array of cell IDs
9090- */
9191-export function getAllNotes(notes) {
9292- return Object.keys(notes);
9393-}
+70
src/sheets/cell-notes.ts
···11+/**
22+ * Cell Notes — plain text annotations on individual cells.
33+ *
44+ * Notes are displayed as hover tooltips with a small triangle
55+ * indicator in the top-right corner of the cell. In the real app,
66+ * the notes object is backed by a Yjs Map for collaboration sync.
77+ *
88+ * These functions operate on a plain object { cellId: noteText }
99+ * and return a new object (immutable pattern for testability).
1010+ */
1111+1212+import type { NotesMap } from './types.js';
1313+1414+/**
1515+ * Create or set a note on a cell. Trims whitespace.
1616+ * If text is empty/null, the note is not created.
1717+ */
1818+export function createNote(notes: NotesMap, cellId: string, text: string | null): NotesMap {
1919+ const result = { ...notes };
2020+ if (text === null || text === undefined) return result;
2121+ const trimmed = text.trim();
2222+ if (trimmed === '') return result;
2323+ result[cellId] = trimmed;
2424+ return result;
2525+}
2626+2727+/**
2828+ * Update an existing note (or create if missing).
2929+ * If the updated text is empty, the note is deleted.
3030+ */
3131+export function updateNote(notes: NotesMap, cellId: string, text: string): NotesMap {
3232+ const result = { ...notes };
3333+ const trimmed = (text || '').trim();
3434+ if (trimmed === '') {
3535+ delete result[cellId];
3636+ return result;
3737+ }
3838+ result[cellId] = trimmed;
3939+ return result;
4040+}
4141+4242+/**
4343+ * Delete a note from a cell.
4444+ */
4545+export function deleteNote(notes: NotesMap, cellId: string): NotesMap {
4646+ const result = { ...notes };
4747+ delete result[cellId];
4848+ return result;
4949+}
5050+5151+/**
5252+ * Get the note text for a cell.
5353+ */
5454+export function getNote(notes: NotesMap, cellId: string): string | null {
5555+ return notes[cellId] ?? null;
5656+}
5757+5858+/**
5959+ * Check if a cell has a note.
6060+ */
6161+export function hasNote(notes: NotesMap, cellId: string): boolean {
6262+ return cellId in notes;
6363+}
6464+6565+/**
6666+ * Get all cell IDs that have notes.
6767+ */
6868+export function getAllNotes(notes: NotesMap): string[] {
6969+ return Object.keys(notes);
7070+}
···55 * filtering logic, and keyboard navigation helpers.
66 */
7788+import type { FormulaFunction } from './types.js';
99+810/**
911 * Complete list of formula functions supported by the formula engine.
1012 * Each entry has a name (uppercase) and a signature hint.
1113 */
1212-export const FORMULA_FUNCTIONS = [
1414+export const FORMULA_FUNCTIONS: ReadonlyArray<FormulaFunction> = [
1315 // Math & Stats
1416 { name: 'SUM', signature: 'SUM(range1, [range2], ...)' },
1517 { name: 'AVERAGE', signature: 'AVERAGE(range1, [range2], ...)' },
···9193 * Filter the function list based on a partial query string.
9294 * Case insensitive prefix matching. Results are sorted so that
9395 * exact matches and shorter names appear first.
9494- *
9595- * @param {string} query - The partial function name typed by the user
9696- * @returns {Array<{name: string, signature: string}>}
9796 */
9898-export function filterFunctions(query) {
9797+export function filterFunctions(query: string): FormulaFunction[] {
9998 if (!query) return [...FORMULA_FUNCTIONS];
10099101100 const upper = query.toUpperCase();
···116115/**
117116 * Navigate the autocomplete dropdown with arrow keys.
118117 * Wraps around at boundaries.
119119- *
120120- * @param {number} currentIndex - Current selected index (-1 = none)
121121- * @param {number} itemCount - Total number of items
122122- * @param {'up'|'down'} direction
123123- * @returns {number} New selected index
124118 */
125125-export function navigateAutocomplete(currentIndex, itemCount, direction) {
119119+export function navigateAutocomplete(currentIndex: number, itemCount: number, direction: 'up' | 'down'): number {
126120 if (itemCount === 0) return -1;
127121128122 if (direction === 'down') {
···136130137131/**
138132 * Get the selected function from the filtered list.
139139- *
140140- * @param {number} index - Selected index
141141- * @param {Array} items - Filtered function list
142142- * @returns {{ name: string, signature: string } | null}
143133 */
144144-export function getSelectedFunction(index, items) {
134134+export function getSelectedFunction(index: number, items: FormulaFunction[]): FormulaFunction | null {
145135 if (index < 0 || index >= items.length) return null;
146136 return items[index];
147137}
···66 * maps exactly back to the original text.
77 */
8899+import type { HighlightToken, HighlightTokenType } from './types.js';
1010+911// Known error values in spreadsheets
1012const ERROR_PATTERN = /^#(REF!|N\/A|VALUE!|ERROR!|NAME\?|NULL!|NUM!|DIV\/0!)/;
1113···2931 * Tokenize a formula string for syntax highlighting.
3032 * Returns tokens with original positions preserved so the highlighted
3133 * output reconstructs the exact formula text.
3232- *
3333- * @param {string} formula - The formula string (including leading '=')
3434- * @returns {Array<{text: string, type: string, start: number, end: number}>}
3534 */
3636-export function tokenizeForHighlighting(formula) {
3737- const tokens = [];
3535+export function tokenizeForHighlighting(formula: string): HighlightToken[] {
3636+ const tokens: HighlightToken[] = [];
3837 let i = 0;
3938 const s = formula;
4039···243242/**
244243 * Escape HTML special characters for safe insertion.
245244 */
246246-function escapeHtml(text) {
245245+function escapeHtml(text: string): string {
247246 return text
248247 .replace(/&/g, '&')
249248 .replace(/</g, '<')
···254253/**
255254 * Render highlighted formula tokens as an HTML string.
256255 * Each token is wrapped in a <span> with a class based on its type.
257257- *
258258- * @param {Array<{text: string, type: string, start: number, end: number}>} tokens
259259- * @returns {string} HTML string
260256 */
261261-export function renderHighlightedFormula(tokens) {
257257+export function renderHighlightedFormula(tokens: HighlightToken[]): string {
262258 return tokens.map(t => {
263259 const escaped = escapeHtml(t.text);
264260 return `<span class="formula-token-${t.type}">${escaped}</span>`;
···66 * cursor position (counting commas and parens).
77 */
8899+import type { FunctionMetadataEntry, DetectedFunction, FunctionParam } from './types.js';
1010+911/**
1012 * Complete function metadata for all 57 supported functions.
1113 * Each entry has a description and per-parameter info.
1214 */
1313-export const FUNCTION_METADATA = {
1515+export const FUNCTION_METADATA: Record<string, FunctionMetadataEntry> = {
1416 // --- Math & Stats ---
1517 SUM: {
1618 desc: 'Adds all numbers in a range',
···388390 * @param {number} cursorPosition - The cursor position in the string
389391 * @returns {{ functionName: string, paramIndex: number } | null}
390392 */
391391-export function detectCurrentFunction(formula, cursorPosition) {
393393+export function detectCurrentFunction(formula: string, cursorPosition: number): DetectedFunction | null {
392394 if (!formula || cursorPosition <= 0) return null;
393395394396 // Work with the portion up to the cursor
···472474 * @param {HTMLElement} anchorElement - The element to position the tooltip near
473475 * @returns {HTMLElement | null} The tooltip element, or null if function not found
474476 */
475475-export function renderTooltip(functionName, paramIndex, anchorElement) {
477477+export function renderTooltip(functionName: string, paramIndex: number, anchorElement: HTMLElement | null): HTMLElement | null {
476478 const meta = FUNCTION_METADATA[functionName];
477479 if (!meta) return null;
478480···535537/**
536538 * Remove the tooltip from the DOM.
537539 */
538538-export function hideTooltip() {
540540+export function hideTooltip(): void {
539541 const existing = document.getElementById('formula-tooltip');
540542 if (existing) existing.remove();
541543}
···10101111import { extractRefs } from './formulas.js';
12121313+interface CellDataMap {
1414+ [cellId: string]: { v: unknown; f: string };
1515+}
1616+1317/**
1418 * Trace the precedents (inputs) of a cell.
1519 * Returns the set of cell IDs that the cell's formula directly references.
1616- *
1717- * @param {string} cellId - The cell to trace (e.g. "C1")
1818- * @param {object} cellData - Map of cellId -> { v, f } where f is formula string
1919- * @returns {Set<string>} Set of precedent cell IDs
2020 */
2121-export function tracePrecedents(cellId, cellData) {
2121+export function tracePrecedents(cellId: string, cellData: CellDataMap): Set<string> {
2222 const cell = cellData[cellId];
2323 if (!cell || !cell.f) return new Set();
2424 return extractRefs(cell.f);
···2727/**
2828 * Trace the dependents (outputs) of a cell.
2929 * Returns the set of cell IDs whose formulas reference the given cell.
3030- *
3131- * @param {string} cellId - The cell to trace (e.g. "A1")
3232- * @param {object} cellData - Map of cellId -> { v, f }
3333- * @returns {Set<string>} Set of dependent cell IDs
3430 */
3535-export function traceDependents(cellId, cellData) {
3636- const dependents = new Set();
3131+export function traceDependents(cellId: string, cellData: CellDataMap): Set<string> {
3232+ const dependents = new Set<string>();
3733 for (const [id, cell] of Object.entries(cellData)) {
3834 if (!cell.f) continue;
3935 const refs = extractRefs(cell.f);
···4743/**
4844 * Build a full dependency graph for all cells.
4945 * Returns both precedent and dependent maps.
5050- *
5151- * @param {object} cellData - Map of cellId -> { v, f }
5252- * @returns {{ precedents: Map<string, Set<string>>, dependents: Map<string, Set<string>> }}
5346 */
5454-export function buildDependencyGraph(cellData) {
5555- const precedents = new Map(); // cellId -> Set of cells it depends on
5656- const dependents = new Map(); // cellId -> Set of cells that depend on it
4747+export function buildDependencyGraph(cellData: CellDataMap): { precedents: Map<string, Set<string>>; dependents: Map<string, Set<string>> } {
4848+ const precedents = new Map<string, Set<string>>(); // cellId -> Set of cells it depends on
4949+ const dependents = new Map<string, Set<string>>(); // cellId -> Set of cells that depend on it
57505851 for (const [id, cell] of Object.entries(cellData)) {
5952 if (!cell.f) continue;
···6659 if (!dependents.has(ref)) {
6760 dependents.set(ref, new Set());
6861 }
6969- dependents.get(ref).add(id);
6262+ dependents.get(ref)!.add(id);
7063 }
7164 }
7265
+67-50
src/sheets/formulas.js
src/sheets/formulas.ts
···55 * ranges (A1:B5), and a library of common functions.
66 */
7788+import type { CellRef, CellValue, CrossSheetResolver, NamedRangesMap, RangeArray, FormatType } from './types.js';
99+810// --- Tokenizer ---
1111+type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF';
1212+1313+interface CrossSheetTokenValue {
1414+ sheetName: string;
1515+ ref: string;
1616+}
1717+1818+interface Token {
1919+ type: TokenTypeValue;
2020+ value?: string | number | boolean | CrossSheetTokenValue;
2121+}
2222+923const TokenType = {
1024 NUMBER: 'NUMBER',
1125 STRING: 'STRING',
···2438 EOF: 'EOF',
2539};
26402727-function tokenize(formula) {
2828- const tokens = [];
4141+function tokenize(formula: string): Token[] {
4242+ const tokens: Token[] = [];
2943 let i = 0;
3044 const s = formula;
3145···171185172186// --- Parser (recursive descent) ---
173187class Parser {
174174- constructor(tokens, getCellValue, crossSheetResolver, namedRanges) {
188188+ tokens: Token[];
189189+ pos: number;
190190+ getCellValue: (ref: string) => CellValue | '';
191191+ crossSheetResolver: CrossSheetResolver | null;
192192+ namedRanges: NamedRangesMap;
193193+ _letScope: Record<string, unknown> | null;
194194+195195+ constructor(tokens: Token[], getCellValue: (ref: string) => CellValue | '', crossSheetResolver: CrossSheetResolver | null | undefined, namedRanges: NamedRangesMap | null | undefined) {
175196 this.tokens = tokens;
176197 this.pos = 0;
177198 this.getCellValue = getCellValue;
178178- this.crossSheetResolver = crossSheetResolver;
199199+ this.crossSheetResolver = crossSheetResolver || null;
179200 this.namedRanges = namedRanges || {};
201201+ this._letScope = null;
180202 }
181203182182- peek() { return this.tokens[this.pos]; }
183183- advance() { return this.tokens[this.pos++]; }
204204+ peek(): Token { return this.tokens[this.pos]; }
205205+ advance(): Token { return this.tokens[this.pos++]; }
184206185185- expect(type) {
207207+ expect(type: TokenTypeValue): Token {
186208 const t = this.advance();
187209 if (t.type !== type) throw new Error(`Expected ${type}, got ${t.type}`);
188210 return t;
189211 }
190212191191- parse() {
213213+ parse(): unknown {
192214 const result = this.expression();
193215 return result;
194216 }
195217196218 // expression → comparison
197197- expression() {
219219+ expression(): unknown {
198220 return this.comparison();
199221 }
200222201223 // comparison → concat (('=' | '<>' | '<' | '>' | '<=' | '>=') concat)?
202202- comparison() {
224224+ comparison(): unknown {
203225 let left = this.concat();
204226 const t = this.peek();
205227 if (t.type === TokenType.OPERATOR && ['=', '<>', '<', '>', '<=', '>='].includes(t.value)) {
···218240 }
219241220242 // concat → addition ('&' addition)*
221221- concat() {
243243+ concat(): unknown {
222244 let left = this.addition();
223245 while (this.peek().type === TokenType.OPERATOR && this.peek().value === '&') {
224246 this.advance();
···229251 }
230252231253 // addition → multiplication (('+' | '-') multiplication)*
232232- addition() {
254254+ addition(): unknown {
233255 let left = this.multiplication();
234256 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '+' || this.peek().value === '-')) {
235257 const op = this.advance().value;
···240262 }
241263242264 // multiplication → power (('*' | '/') power)*
243243- multiplication() {
265265+ multiplication(): unknown {
244266 let left = this.power();
245267 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '*' || this.peek().value === '/')) {
246268 const op = this.advance().value;
···251273 }
252274253275 // power → unary ('^' unary)*
254254- power() {
276276+ power(): unknown {
255277 let left = this.unary();
256278 while (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') {
257279 this.advance();
···262284 }
263285264286 // unary → ('-' | '+') unary | primary
265265- unary() {
287287+ unary(): unknown {
266288 if (this.peek().type === TokenType.OPERATOR && (this.peek().value === '-' || this.peek().value === '+')) {
267289 const op = this.advance().value;
268290 const val = this.unary();
···272294 }
273295274296 // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | CROSS_SHEET_REF | IDENTIFIER | FUNCTION '(' args ')' | '(' expression ')'
275275- primary() {
297297+ primary(): unknown {
276298 const t = this.peek();
277299278300 if (t.type === TokenType.NUMBER) {
···363385 }
364386365387 // Function args can be ranges (CELL_REF:CELL_REF), cross-sheet ranges, named ranges, or expressions
366366- parseFunctionArg() {
388388+ parseFunctionArg(): unknown {
367389 // Cross-sheet ref in function arg (range already parsed in tokenizer)
368390 if (this.peek().type === TokenType.CROSS_SHEET_REF) {
369391 const saved = this.pos;
···405427 }
406428407429 // Parse LET(name1, value1, [name2, value2, ...], calculation)
408408- parseLet() {
430430+ parseLet(): unknown {
409431 this.expect(TokenType.LPAREN);
410432 const prevScope = this._letScope ? { ...this._letScope } : null;
411433 if (!this._letScope) this._letScope = {};
···482504 }
483505 }
484506485485- resolveRange(startRef, endRef) {
507507+ resolveRange(startRef: string, endRef: string): RangeArray {
486508 const start = parseRef(startRef);
487509 const end = parseRef(endRef);
488488- const values = [];
510510+ const values: RangeArray = [];
489511 const rowMin = Math.min(start.row, end.row);
490512 const rowMax = Math.max(start.row, end.row);
491513 const colMin = Math.min(start.col, end.col);
···503525 }
504526505527 // Resolve a cross-sheet single cell reference
506506- resolveCrossSheetCell(sheetName, cellRef) {
528528+ resolveCrossSheetCell(sheetName: string, cellRef: string): unknown {
507529 if (!this.crossSheetResolver) return '#REF!';
508530 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!';
509531 return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef);
510532 }
511533512534 // Resolve a cross-sheet range reference (e.g. Sheet2!A1:B5)
513513- resolveCrossSheetRange(sheetName, rangeStr) {
535535+ resolveCrossSheetRange(sheetName: string, rangeStr: string): RangeArray | string {
514536 if (!this.crossSheetResolver) return '#REF!';
515537 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!';
516538 const parts = rangeStr.split(':');
···518540 const start = parseRef(parts[0]);
519541 const end = parseRef(parts[1]);
520542 if (!start || !end) return '#REF!';
521521- const values = [];
543543+ const values: RangeArray = [];
522544 const rowMin = Math.min(start.row, end.row);
523545 const rowMax = Math.max(start.row, end.row);
524546 const colMin = Math.min(start.col, end.col);
···535557 }
536558537559 // Resolve a named range identifier to its values
538538- resolveNamedRange(name) {
560560+ resolveNamedRange(name: string): unknown {
539561 const key = name.toLowerCase();
540562 const entry = this.namedRanges[key];
541563 if (!entry) {
···553575}
554576555577// --- Function library ---
556556-function callFunction(name, args) {
578578+function callFunction(name: string, args: unknown[]): unknown {
557579 // Flatten any range arrays in args
558558- const flat = (arr) => arr.flat(Infinity).filter(v => v !== '' && v !== null && v !== undefined);
559559- const nums = (arr) => flat(arr).map(toNum).filter(v => !isNaN(v));
580580+ const flat = (arr: unknown[]): unknown[] => (arr as unknown[]).flat(Infinity).filter(v => v !== '' && v !== null && v !== undefined);
581581+ const nums = (arr: unknown[]): number[] => flat(arr).map(toNum).filter(v => !isNaN(v));
560582561583 switch (name) {
562584 case 'SUM': return nums(args).reduce((a, b) => a + b, 0);
···855877}
856878857879// --- Helpers ---
858858-function toNum(v) {
880880+function toNum(v: unknown): number {
859881 if (v === '' || v === null || v === undefined) return 0;
860882 if (typeof v === 'boolean') return v ? 1 : 0;
861883 if (typeof v === 'number') return v;
···863885 return isNaN(n) ? 0 : n;
864886}
865887866866-function escapeRegex(s) {
888888+function escapeRegex(s: string): string {
867889 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
868890}
869891870870-function matchCriteria(value, criteria) {
892892+function matchCriteria(value: unknown, criteria: unknown): boolean {
871893 if (typeof criteria === 'string') {
872894 if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2));
873895 if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2));
···881903}
882904883905/** Convert wildcard pattern (* and ?) to a RegExp */
884884-function wildcardToRegex(pattern) {
906906+function wildcardToRegex(pattern: string): RegExp {
885907 const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
886908 const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
887909 return new RegExp('^' + regexStr + '$', 'i');
888910}
889911890912/** matchCriteria with wildcard support for SUMIFS/COUNTIFS/AVERAGEIFS */
891891-function matchCriteriaWild(value, criteria) {
913913+function matchCriteriaWild(value: unknown, criteria: unknown): boolean {
892914 if (typeof criteria === 'string') {
893915 if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2));
894916 if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2));
···910932 return value === criteria;
911933}
912934913913-function formatValue(num, fmt) {
935935+function formatValue(num: number, fmt: string): string {
914936 // Simplified TEXT() formatting
915937 if (fmt === '0') return Math.round(num).toString();
916938 if (fmt === '0.00') return num.toFixed(2);
···922944}
923945924946// --- VLOOKUP / HLOOKUP helpers ---
925925-function vlookup(needle, flatRange, rows, cols, colIdx, rangeLookup) {
947947+function vlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, colIdx: number, rangeLookup: boolean): unknown {
926948 if (rangeLookup) {
927949 let bestRow = -1;
928950 for (let r = 0; r < rows; r++) {
···944966 }
945967}
946968947947-function hlookup(needle, flatRange, rows, cols, rowIdx, rangeLookup) {
969969+function hlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, rowIdx: number, rangeLookup: boolean): unknown {
948970 if (rangeLookup) {
949971 let bestCol = -1;
950972 for (let c = 0; c < cols; c++) {
···966988 }
967989}
968990969969-function compareValues(a, b) {
991991+function compareValues(a: unknown, b: unknown): number {
970992 if (typeof a === 'number' && typeof b === 'number') return a - b;
971993 return String(a).toLowerCase().localeCompare(String(b).toLowerCase());
972994}
973995974974-function valuesEqual(a, b) {
996996+function valuesEqual(a: unknown, b: unknown): boolean {
975997 if (typeof a === 'number' && typeof b === 'number') return a === b;
976998 if (typeof a === 'number' || typeof b === 'number') {
977999 const na = toNum(a), nb = toNum(b);
···9811003}
98210049831005// --- Cell reference utilities ---
984984-export function parseRef(ref) {
10061006+export function parseRef(ref: string): CellRef | null {
9851007 const match = ref.match(/^([A-Z]+)(\d+)$/);
9861008 if (!match) return null;
9871009 return { col: letterToCol(match[1]), row: parseInt(match[2]) };
9881010}
9891011990990-export function colToLetter(col) {
10121012+export function colToLetter(col: number): string {
9911013 let result = '';
9921014 while (col > 0) {
9931015 col--;
···9971019 return result;
9981020}
999102110001000-export function letterToCol(letter) {
10221022+export function letterToCol(letter: string): number {
10011023 let col = 0;
10021024 for (let i = 0; i < letter.length; i++) {
10031025 col = col * 26 + (letter.charCodeAt(i) - 64);
···10051027 return col;
10061028}
1007102910081008-export function cellId(col, row) {
10301030+export function cellId(col: number, row: number): string {
10091031 return colToLetter(col) + row;
10101032}
1011103310121034// --- Main evaluate function ---
10131035/**
10141036 * Evaluate a formula string.
10151015- * @param {string} formula - The formula (without leading '=')
10161016- * @param {(ref: string) => any} getCellValue - Resolver for cell references
10171017- * @param {object} [crossSheetResolver] - Optional resolver for cross-sheet refs
10181018- * @param {object} [namedRanges] - Optional map of lowercase name -> { range, sheet }
10191019- * @returns {any} The computed value
10201037 */
10211021-export function evaluate(formula, getCellValue, crossSheetResolver, namedRanges) {
10381038+export function evaluate(formula: string, getCellValue: (ref: string) => CellValue | '', crossSheetResolver?: CrossSheetResolver | null, namedRanges?: NamedRangesMap | null): unknown {
10221039 try {
10231040 const tokens = tokenize(formula);
10241041 const parser = new Parser(tokens, getCellValue, crossSheetResolver, namedRanges);
···10321049 * Extract cell references from a formula for dependency tracking.
10331050 * Cross-sheet refs are returned as 'SheetName!CellId'.
10341051 */
10351035-export function extractRefs(formula) {
10361036- const refs = new Set();
10521052+export function extractRefs(formula: string): Set<string> {
10531053+ const refs = new Set<string>();
10371054 const tokens = tokenize(formula);
10381055 for (let i = 0; i < tokens.length; i++) {
10391056 const t = tokens[i];
···10781095/**
10791096 * Display-format a cell value based on format type.
10801097 */
10811081-export function formatCell(value, format) {
10981098+export function formatCell(value: unknown, format: string | undefined): string {
10821099 if (value === '' || value === null || value === undefined) return '';
10831100 if (typeof value === 'string' && value.startsWith('#')) return value; // Error
10841101
···1212 * The colors are designed to be vibrant enough to stand out on the grid
1313 * but not overpowering.
1414 */
1515-export const RANGE_COLORS = [
1515+1616+import type { FormulaRange, ColoredRange, HighlightBorders, CellRef } from './types.js';
1717+1818+export const RANGE_COLORS: readonly string[] = [
1619 'oklch(0.55 0.2 250)', // blue
1720 'oklch(0.55 0.18 155)', // green
1821 'oklch(0.5 0.2 300)', // purple
···5861 * @param {string} formula - The formula (without leading '=')
5962 * @returns {Array<{ref: string, startIndex: number, endIndex: number}>}
6063 */
6161-export function extractFormulaRanges(formula) {
6262- const results = [];
6464+export function extractFormulaRanges(formula: string): FormulaRange[] {
6565+ const results: FormulaRange[] = [];
6366 // Track which positions we've already consumed (for cross-sheet refs)
6464- const consumed = new Set();
6767+ const consumed = new Set<number>();
65686669 // 1. Extract quoted cross-sheet refs first: 'Sheet Name'!A1:B5
6770 {
···168171 * @param {Array<{ref: string, startIndex: number, endIndex: number}>} ranges
169172 * @returns {Array<{ref: string, startIndex: number, endIndex: number, color: string}>}
170173 */
171171-export function assignRangeColors(ranges) {
174174+export function assignRangeColors(ranges: FormulaRange[]): ColoredRange[] {
172175 if (ranges.length === 0) return [];
173176174174- const colorMap = new Map();
177177+ const colorMap = new Map<string, string>();
175178 let nextColorIdx = 0;
176179177180 return ranges.map(r => {
···181184 }
182185 return {
183186 ...r,
184184- color: colorMap.get(r.ref),
187187+ color: colorMap.get(r.ref)!,
185188 };
186189 });
187190}
···195198 * @param {Function} parseRef - Function to parse cell refs into {col, row}
196199 * @param {Function} colToLetter - Function to convert col number to letter
197200 */
198198-export function renderGridHighlights(coloredRanges, gridElement, parseRef, colToLetter) {
201201+export function renderGridHighlights(coloredRanges: ColoredRange[], gridElement: HTMLElement, parseRef: (ref: string) => CellRef | null, colToLetter: (col: number) => string): void {
199202 clearGridHighlights();
200203201204 for (const range of coloredRanges) {
202205 const ref = range.ref;
203206 // Strip sheet name prefix for grid cell lookup
204204- const localRef = ref.includes('!') ? ref.split('!').pop() : ref;
207207+ const localRef = ref.includes('!') ? ref.split('!').pop()! : ref;
205208206209 // Handle range refs (A1:B5)
207210 if (localRef.includes(':')) {
···239242/**
240243 * Add a highlight overlay to a single cell.
241244 */
242242-function highlightCell(gridElement, cellRef, color, borders) {
245245+function highlightCell(gridElement: HTMLElement, cellRef: string, color: string, borders: HighlightBorders): void {
243246 const td = gridElement.querySelector(`td[data-id="${cellRef}"]`);
244247 if (!td) return;
245248···267270/**
268271 * Remove all range highlight overlays from the grid.
269272 */
270270-export function clearGridHighlights() {
273273+export function clearGridHighlights(): void {
271274 document.querySelectorAll('.range-highlight-overlay').forEach(el => el.remove());
272275}
+35-45
src/sheets/recalc.js
src/sheets/recalc.ts
···1313 */
14141515import { extractRefs, evaluate, parseRef, colToLetter } from './formulas.js';
1616+import type { CellStore, RecalcOptions, CellValue, NamedRangesMap } from './types.js';
16171718// --- Volatile functions ---
18191919-export const VOLATILE_FUNCTIONS = ['NOW', 'TODAY', 'RAND', 'RANDBETWEEN'];
2020+export const VOLATILE_FUNCTIONS: readonly string[] = ['NOW', 'TODAY', 'RAND', 'RANDBETWEEN'];
20212122/**
2223 * Check if a formula contains any volatile function.
2324 * @param {string} formula - Formula string (without leading '=')
2425 * @returns {boolean}
2526 */
2626-export function isVolatile(formula) {
2727+export function isVolatile(formula: string): boolean {
2728 const upper = formula.toUpperCase();
2829 return VOLATILE_FUNCTIONS.some(fn => upper.includes(fn + '(') || upper.includes(fn + ' ('));
2930}
30313132// --- Recalculation Engine ---
32333333-/**
3434- * @typedef {Object} CellStore
3535- * @property {(id: string) => {v: any, f: string} | null} get
3636- * @property {(id: string, cell: {v: any, f: string}) => void} set
3737- * @property {(id: string) => boolean} has
3838- * @property {() => IterableIterator<[string, {v: any, f: string}]>} entries
3939- * @property {() => [string, {v: any, f: string}][]} getAllFormulaCells
4040- */
4141-4242-/**
4343- * @typedef {Object} RecalcOptions
4444- * @property {(cellId: string) => void} [onEvaluate] - Called when a cell is evaluated (for testing)
4545- * @property {Object} [namedRanges] - Map of lowercase name -> { range, sheet }
4646- * @property {Object} [crossSheetResolver] - Resolver for cross-sheet references
4747- */
3434+// CellStore and RecalcOptions types are defined in ./types.ts
48354936export class RecalcEngine {
5050- /**
5151- * @param {CellStore} store - Cell data store
5252- * @param {RecalcOptions} [options]
5353- */
5454- constructor(store, options = {}) {
3737+ store: CellStore;
3838+ options: RecalcOptions;
3939+ precedents: Map<string, Set<string>>;
4040+ dependents: Map<string, Set<string>>;
4141+ volatileCells: Set<string>;
4242+ _cyclePaths: string[][];
4343+4444+ constructor(store: CellStore, options: RecalcOptions = {}) {
5545 this.store = store;
5646 this.options = options;
5747···7262 * Build the full dependency graph from scratch.
7363 * Call this once at initialization or when the entire sheet changes.
7464 */
7575- buildFullGraph() {
6565+ buildFullGraph(): void {
7666 this.precedents.clear();
7767 this.dependents.clear();
7868 this.volatileCells.clear();
···8777 * Incrementally update the graph for a single cell whose formula changed.
8878 * @param {string} cellId
8979 */
9090- updateCell(cellId) {
8080+ updateCell(cellId: string): void {
9181 // Remove old edges
9282 this._removeCellEdges(cellId);
9383···10393 * @param {string} cellId
10494 * @returns {Set<string>}
10595 */
106106- getPrecedents(cellId) {
9696+ getPrecedents(cellId: string): Set<string> {
10797 return this.precedents.get(cellId) || new Set();
10898 }
10999···112102 * @param {string} cellId
113103 * @returns {Set<string>}
114104 */
115115- getDependents(cellId) {
105105+ getDependents(cellId: string): Set<string> {
116106 return this.dependents.get(cellId) || new Set();
117107 }
118108···121111 * Each path is an array of cellIds forming the cycle, e.g. ["A1", "B1", "C1", "A1"].
122112 * @returns {string[][]}
123113 */
124124- getCyclePaths() {
114114+ getCyclePaths(): string[][] {
125115 return this._cyclePaths;
126116 }
127117···133123 * @param {string} editedCellId - The cell that was edited
134124 * @returns {Set<string>} Set of cell IDs whose display values actually changed
135125 */
136136- recalculate(editedCellId) {
126126+ recalculate(editedCellId: string): Set<string> {
137127 return this.recalculateMultiple([editedCellId]);
138128 }
139129···142132 * @param {string[]} editedCellIds - The cells that were edited
143133 * @returns {Set<string>} Set of cell IDs whose display values actually changed
144134 */
145145- recalculateMultiple(editedCellIds) {
135135+ recalculateMultiple(editedCellIds: string[]): Set<string> {
146136 // Step 1: Collect all dirty cells (edited + transitive dependents)
147137 const dirty = this._collectDirty(editedCellIds);
148138···155145 * Call this on a timer or on any UI interaction.
156146 * @returns {Set<string>} Set of cell IDs whose display values actually changed
157147 */
158158- recalculateVolatile() {
148148+ recalculateVolatile(): Set<string> {
159149 if (this.volatileCells.size === 0) return new Set();
160150 return this.recalculateMultiple([...this.volatileCells]);
161151 }
···167157 * @param {string} cellId
168158 * @param {string} formula
169159 */
170170- _addCellEdges(cellId, formula) {
160160+ _addCellEdges(cellId: string, formula: string): void {
171161 let refs = extractRefs(formula);
172162173163 // Also resolve named ranges to their constituent cells
···203193 * Remove all edges for a cell from the graph.
204194 * @param {string} cellId
205195 */
206206- _removeCellEdges(cellId) {
196196+ _removeCellEdges(cellId: string): void {
207197 const oldPrecs = this.precedents.get(cellId);
208198 if (oldPrecs) {
209199 for (const ref of oldPrecs) {
···225215 * @param {Set<string>} existingRefs
226216 * @returns {Set<string>}
227217 */
228228- _resolveNamedRangeRefs(formula, existingRefs) {
218218+ _resolveNamedRangeRefs(formula: string, existingRefs: Set<string>): Set<string> {
229219 const namedRanges = this.options.namedRanges;
230220 if (!namedRanges) return existingRefs;
231221···272262 * @param {string[]} editedCellIds
273263 * @returns {Set<string>}
274264 */
275275- _collectDirty(editedCellIds) {
276276- const dirty = new Set();
265265+ _collectDirty(editedCellIds: string[]): Set<string> {
266266+ const dirty = new Set<string>();
277267 const queue = [...editedCellIds];
278268279269 while (queue.length > 0) {
···301291 * @param {Set<string>} dirty - Set of dirty cell IDs
302292 * @returns {Set<string>} Set of cell IDs whose display value actually changed
303293 */
304304- _evaluateDirty(dirty) {
294294+ _evaluateDirty(dirty: Set<string>): Set<string> {
305295 this._cyclePaths = [];
306306- const changed = new Set();
296296+ const changed = new Set<string>();
307297308298 // Filter to only formula cells that need recalculation
309309- const formulaCells = new Set();
299299+ const formulaCells = new Set<string>();
310300 for (const cellId of dirty) {
311301 const cell = this.store.get(cellId);
312302 if (cell && cell.f) {
···320310 // In-degree only counts edges from OTHER formula cells in the dirty set.
321311 // Non-formula dirty cells (edited value cells) are already resolved — they
322312 // act as sources in the topological sort without needing evaluation.
323323- const inDegree = new Map();
324324- const subDeps = new Map(); // within the dirty subgraph: source -> Set<target>
313313+ const inDegree = new Map<string, number>();
314314+ const subDeps = new Map<string, Set<string>>(); // within the dirty subgraph: source -> Set<target>
325315326316 for (const cellId of formulaCells) {
327317 let degree = 0;
···374364 }
375365376366 // Detect cycles: formula cells not in sorted order are in cycles
377377- const cycleCells = new Set();
367367+ const cycleCells = new Set<string>();
378368 for (const cellId of formulaCells) {
379369 if (!sortedSet.has(cellId)) {
380370 cycleCells.add(cellId);
···412402 * @param {string} cellId
413403 * @param {Set<string>} changed - Accumulator for cells whose values changed
414404 */
415415- _evaluateCell(cellId, changed) {
405405+ _evaluateCell(cellId: string, changed: Set<string>): void {
416406 const cell = this.store.get(cellId);
417407 if (!cell || !cell.f) return;
418408···423413 const oldVal = cell.v;
424414425415 // Build a getCellValue that reads from the store
426426- const getCellValue = (ref) => {
416416+ const getCellValue = (ref: string) => {
427417 const data = this.store.get(ref);
428418 if (!data) return '';
429419 if (data.f) return data.v; // Already evaluated (topo order guarantees this)
···451441 * Uses DFS from cycle cells to find actual cycle paths.
452442 * @param {Set<string>} cycleCells
453443 */
454454- _buildCyclePaths(cycleCells) {
444444+ _buildCyclePaths(cycleCells: Set<string>): void {
455445 const visited = new Set();
456446 const paths = [];
457447···479469 * @param {Set<string>} visited
480470 * @returns {string[] | null}
481471 */
482482- _dfsFindCycle(cellId, cycleCells, path, inStack, visited) {
472472+ _dfsFindCycle(cellId: string, cycleCells: Set<string>, path: string[], inStack: Set<string>, visited: Set<string>): string[] | null {
483473 if (inStack.has(cellId)) {
484474 // Found a cycle — extract path from the first occurrence to here
485475 const cycleStart = path.indexOf(cellId);
+9-10
src/sheets/sort.js
src/sheets/sort.ts
···44 * Pure-logic: no DOM dependencies. The UI integration is in main.js.
55 */
6677+import type { SortKey } from './types.js';
88+99+interface SortRow {
1010+ [key: string]: unknown;
1111+ [key: number]: unknown;
1212+}
1313+714/**
815 * Compare two values for sorting. Numbers sort numerically,
916 * strings sort lexicographically, numbers sort before strings,
1017 * empty strings sort before everything.
1111- *
1212- * @param {any} a
1313- * @param {any} b
1414- * @returns {number} - Negative if a < b, positive if a > b, 0 if equal
1518 */
1616-function compareValues(a, b) {
1919+function compareValues(a: unknown, b: unknown): number {
1720 // Handle empty strings — sort first
1821 const aEmpty = a === '' || a === null || a === undefined;
1922 const bEmpty = b === '' || b === null || b === undefined;
···4447 * stable in all modern engines since ES2019).
4548 *
4649 * Does NOT mutate the input array.
4747- *
4848- * @param {object[]} rows - Array of row objects ({ _row, [colNum]: value })
4949- * @param {{ col: number, order: 'asc' | 'desc' }[]} sortKeys - Up to 3 sort levels
5050- * @returns {object[]} - New sorted array
5150 */
5252-export function multiColumnSort(rows, sortKeys) {
5151+export function multiColumnSort(rows: SortRow[], sortKeys: SortKey[]): SortRow[] {
5352 if (!rows || rows.length === 0) return [];
5453 if (!sortKeys || sortKeys.length === 0) return [...rows];
5554
+11-9
src/sheets/status-bar.js
src/sheets/status-bar.ts
···55 * Only counts numeric values for stats computation.
66 */
7788+interface SelectionStats {
99+ sum: number;
1010+ average: number;
1111+ count: number;
1212+ min: number;
1313+ max: number;
1414+}
1515+816/**
917 * Convert a value to a number, returning NaN for non-numeric values.
1018 * Booleans count as numbers (true=1, false=0).
1119 */
1212-function toNumeric(v) {
2020+function toNumeric(v: unknown): number {
1321 if (v === '' || v === null || v === undefined) return NaN;
1422 if (typeof v === 'boolean') return v ? 1 : 0;
1523 if (typeof v === 'number') return v;
···2028/**
2129 * Compute aggregate statistics for an array of cell values.
2230 * Returns null if fewer than 2 values (single-cell selection).
2323- *
2424- * @param {Array} values - Array of cell display values
2525- * @returns {{ sum: number, average: number, count: number, min: number, max: number } | null}
2631 */
2727-export function computeSelectionStats(values) {
3232+export function computeSelectionStats(values: unknown[]): SelectionStats | null {
2833 if (!values || values.length < 2) return null;
29343035 const nums = values.map(toNumeric).filter(n => !isNaN(n));
···5055/**
5156 * Format a stat value for display in the status bar.
5257 * Integers show without decimals; floats show up to 2 decimal places.
5353- *
5454- * @param {number} value
5555- * @returns {string}
5658 */
5757-export function formatStatValue(value) {
5959+export function formatStatValue(value: number): string {
5860 if (value === 0) return '0';
5961 // Round to 2 decimal places first
6062 const rounded = Math.round(value * 100) / 100;
-16
src/sheets/tab-handler.js
···11-/**
22- * Tab key behavior resolver for the sheets editor.
33- *
44- * Pure function: given context, returns the action to take.
55- */
66-77-/**
88- * Determine what Tab/Shift+Tab should do in the spreadsheet.
99- *
1010- * @param {object} ctx
1111- * @param {boolean} ctx.shiftKey - Shift key is held
1212- * @returns {'moveRight' | 'moveLeft'}
1313- */
1414-export function resolveSheetTabAction({ shiftKey }) {
1515- return shiftKey ? 'moveLeft' : 'moveRight';
1616-}
+14
src/sheets/tab-handler.ts
···11+/**
22+ * Tab key behavior resolver for the sheets editor.
33+ *
44+ * Pure function: given context, returns the action to take.
55+ */
66+77+import type { TabAction } from './types.js';
88+99+/**
1010+ * Determine what Tab/Shift+Tab should do in the spreadsheet.
1111+ */
1212+export function resolveSheetTabAction({ shiftKey }: { shiftKey: boolean }): TabAction {
1313+ return shiftKey ? 'moveLeft' : 'moveRight';
1414+}
···77 * rendering is handled by renderGrid() in main.js.
88 */
991010+import type { VisibleRange, VirtualScrollParams } from './types.js';
1111+1012// --- Constants ---
1113export const DEFAULT_ROW_HEIGHT = 26; // px, matches body row height in main.js
1214export const DEFAULT_BUFFER_ROWS = 10; // extra rows above and below viewport
13151416/**
1517 * Calculate which rows should be rendered based on scroll position.
1616- *
1717- * @param {Object} params
1818- * @param {number} params.scrollTop - Current scroll position (px)
1919- * @param {number} params.viewportHeight - Height of visible area (px)
2020- * @param {number} params.totalRows - Total number of rows in the sheet
2121- * @param {number} [params.rowHeight=DEFAULT_ROW_HEIGHT] - Height of each row (px)
2222- * @param {number} [params.bufferRows=DEFAULT_BUFFER_ROWS] - Extra rows above/below
2323- * @returns {{ startRow: number, endRow: number }} 1-based row range to render
2418 */
2519export function calculateVisibleRange({
2620 scrollTop,
···2822 totalRows,
2923 rowHeight = DEFAULT_ROW_HEIGHT,
3024 bufferRows = DEFAULT_BUFFER_ROWS,
3131-}) {
2525+}: VirtualScrollParams): VisibleRange {
3226 // Calculate first visible row (0-based index)
3327 const firstVisibleIndex = Math.floor(scrollTop / rowHeight);
3428···4943/**
5044 * Calculate the total height needed for the spacer element.
5145 * This maintains correct scrollbar size when not all rows are rendered.
5252- *
5353- * @param {number} totalRows - Total number of rows in the sheet
5454- * @param {number} [rowHeight=DEFAULT_ROW_HEIGHT] - Height of each row (px)
5555- * @returns {number} Total height in pixels
5646 */
5757-export function calculateSpacerHeight(totalRows, rowHeight = DEFAULT_ROW_HEIGHT) {
4747+export function calculateSpacerHeight(totalRows: number, rowHeight: number = DEFAULT_ROW_HEIGHT): number {
5848 return totalRows * rowHeight;
5949}