feat: improve XLSX preview + add code syntax highlighting

XLSX QoL:
- Custom table renderer (excelToTable.ts) with column letters,
  row numbers, right-aligned numbers, empty cell handling
- Monospace font, sticky headers + row nums, cell cursor
- Sheet tabs moved to bottom bar (like PPTX navigation)
- Unified styles between FileNav and FileItemModal

Code highlighting:
- Shiki-based syntax highlighting for code files in FileNav
- Line numbers, dark/light theme support
- Source/Preview toggle for code files
This commit is contained in:
Timothy Jaeryang Baek
2026-03-04 15:59:55 -06:00
parent e08341dab3
commit f962bae983
5 changed files with 326 additions and 41 deletions
+95
View File
@@ -0,0 +1,95 @@
import { codeToHtml, bundledLanguages } from 'shiki';
/**
* Map file extensions to Shiki language identifiers.
* Only extensions whose Shiki lang id differs from the extension itself need explicit entries.
*/
const EXT_OVERRIDE: Record<string, string> = {
py: 'python',
js: 'javascript',
ts: 'typescript',
jsx: 'jsx',
tsx: 'tsx',
rb: 'ruby',
rs: 'rust',
kt: 'kotlin',
cs: 'csharp',
fs: 'fsharp',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yml: 'yaml',
md: 'markdown',
mdx: 'mdx',
dockerfile: 'dockerfile',
tf: 'terraform',
hcl: 'hcl',
ex: 'elixir',
exs: 'elixir',
erl: 'erlang',
hs: 'haskell',
ml: 'ocaml',
mli: 'ocaml',
pl: 'perl',
pm: 'perl',
r: 'r',
m: 'objective-c',
mm: 'objective-cpp',
h: 'c',
hpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
proto: 'proto',
nim: 'nim',
zig: 'zig',
v: 'v',
svelte: 'svelte',
vue: 'vue',
astro: 'astro',
prisma: 'prisma',
graphql: 'graphql',
gql: 'graphql',
jsonc: 'jsonc',
jsonl: 'jsonl'
};
const _langSet = new Set(Object.keys(bundledLanguages));
/**
* Resolve a file extension to a Shiki language id, or null if not supported.
*/
export function extToLang(ext: string): string | null {
const lower = ext.toLowerCase();
// explicit override first
if (EXT_OVERRIDE[lower]) return EXT_OVERRIDE[lower];
// if the extension itself is a bundled language id (e.g. 'go', 'rust', 'sql', 'toml', ...)
if (_langSet.has(lower)) return lower;
return null;
}
/**
* Returns true if the given file path has a code-file extension that Shiki can highlight.
*/
export function isCodeFile(path: string | null): boolean {
if (!path) return false;
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return extToLang(ext) !== null;
}
/**
* Highlight code using Shiki with dual light/dark themes via CSS variables.
* Returns an HTML string. Throws on failure.
*/
export async function highlightCode(code: string, filePath: string): Promise<string> {
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
const lang = extToLang(ext) ?? 'text';
return await codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark'
},
defaultColor: 'light'
});
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Shared Excel → HTML table renderer.
*
* Converts a worksheet to a styled HTML table with:
* - Column letter headers (A, B, C…)
* - Row numbers
* - Proper empty cell handling
* - Sanitized output
*/
import type { WorkSheet } from 'xlsx';
/** Convert column index (0-based) to Excel-style letter (A, B, …, Z, AA, AB, …) */
const colLetter = (i: number): string => {
let s = '';
let n = i;
while (n >= 0) {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
}
return s;
};
/** Escape HTML entities */
const esc = (v: unknown): string => {
if (v === null || v === undefined || v === '') return '&nbsp;';
return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
export interface ExcelTableResult {
html: string;
rowCount: number;
colCount: number;
}
/**
* Render a worksheet as an HTML table string.
* Uses sheet_to_json with header:1 for a raw 2D array.
*/
export async function excelToTable(worksheet: WorkSheet): Promise<ExcelTableResult> {
const XLSX = await import('xlsx');
const rows: unknown[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
if (rows.length === 0) {
return { html: '<table><tbody><tr><td>&nbsp;</td></tr></tbody></table>', rowCount: 0, colCount: 0 };
}
// Determine column count from the widest row
const colCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
const rowCount = rows.length;
const parts: string[] = [];
parts.push('<table>');
// Column letter header row
parts.push('<thead><tr>');
parts.push('<th class="excel-row-num"></th>'); // corner cell
for (let c = 0; c < colCount; c++) {
parts.push(`<th class="excel-col-hdr">${colLetter(c)}</th>`);
}
parts.push('</tr></thead>');
// Data rows
parts.push('<tbody>');
for (let r = 0; r < rowCount; r++) {
const row = rows[r];
parts.push('<tr>');
parts.push(`<td class="excel-row-num">${r + 1}</td>`);
for (let c = 0; c < colCount; c++) {
const val = c < row.length ? row[c] : '';
const isNum = typeof val === 'number';
parts.push(
`<td${isNum ? ' class="excel-num"' : ''}>${esc(val)}</td>`
);
}
parts.push('</tr>');
}
parts.push('</tbody></table>');
const DOMPurify = (await import('dompurify')).default;
return {
html: DOMPurify.sanitize(parts.join('')),
rowCount,
colCount
};
}