mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
Merge #502: read-only XLSX spreadsheet preview in the file-preview overlay
# Conflicts: # CLAUDE.md
This commit is contained in:
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
*/
|
||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||
|
||||
/**
|
||||
* Document types an attachment card previews. Also the list `codeman attach`'s
|
||||
* error text names, so the help cannot drift from what is accepted. `xlsx` is
|
||||
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
|
||||
*/
|
||||
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'pdf',
|
||||
'docx',
|
||||
'pptx',
|
||||
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||
'md',
|
||||
'txt',
|
||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
@@ -154,6 +159,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
||||
if (normalized === 'pdf') return 'pdf';
|
||||
if (normalized === 'pptx') return 'presentation';
|
||||
if (normalized === 'xlsx') return 'spreadsheet';
|
||||
if (normalized === 'md') return 'markdown';
|
||||
// Everything else in the text family reads as text, including code and
|
||||
// config: the card and the preview both treat it as a plain-text file.
|
||||
|
||||
+6
-2
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
import { getStore } from './state-store.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||
@@ -111,7 +111,11 @@ program
|
||||
.action(async (filePath, options) => {
|
||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
console.error(
|
||||
palette.err(
|
||||
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
|
||||
| 'pdf'
|
||||
| 'document'
|
||||
| 'presentation'
|
||||
| 'spreadsheet'
|
||||
| 'markdown'
|
||||
| 'text';
|
||||
|
||||
|
||||
@@ -1830,7 +1830,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -1848,7 +1848,7 @@ function absoluteFilePathPattern() {
|
||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||
*/
|
||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
);
|
||||
|
||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||
|
||||
@@ -4039,5 +4039,6 @@
|
||||
<script defer src="ultracode-windows.js"></script>
|
||||
<script defer src="session-lineage.js"></script>
|
||||
<script defer src="image-input.js"></script>
|
||||
<script defer src="spreadsheet-preview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4037,3 +4037,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
|
||||
touch-scrollable grid on phones. */
|
||||
@media (max-width: 700px) {
|
||||
.spreadsheet-sheet-tabs {
|
||||
padding-inline: 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.spreadsheet-sheet-tab {
|
||||
min-width: 96px;
|
||||
min-height: 40px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.spreadsheet-grid {
|
||||
min-height: 55vh;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4178,6 +4178,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else if (ext === 'docx' || ext === 'pptx') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else if (ext === 'xlsx') {
|
||||
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
|
||||
} else {
|
||||
try {
|
||||
// Bounded like the workspace text preview: a Range for the first
|
||||
@@ -4274,6 +4276,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'spreadsheet') {
|
||||
this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size);
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
@@ -4347,6 +4352,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
|
||||
*/
|
||||
_stopFilePreviewMedia() {
|
||||
// A spreadsheet preview owns a fetch and a Web Worker; emptying the body
|
||||
// leaves both running, so tear them down with the rest of the media.
|
||||
this._disposeSpreadsheetPreview();
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
if (!bodyEl) return;
|
||||
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
||||
@@ -4361,6 +4369,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Render an XLSX into the preview body via spreadsheet-preview.js, which
|
||||
* parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and
|
||||
* never on page load). `url` is a raw route; the renderer adds `?preview=true`
|
||||
* so the server applies its preview size cap. Superseded by the next
|
||||
* _stopFilePreviewMedia(), which runs on every open and on close.
|
||||
*/
|
||||
_openSpreadsheetPreview(bodyEl, url, size) {
|
||||
this._disposeSpreadsheetPreview();
|
||||
const renderer = window.CodemanSpreadsheetPreview;
|
||||
if (!renderer?.open) {
|
||||
bodyEl.innerHTML = '<div class="binary-message">Spreadsheet preview is unavailable.</div>';
|
||||
return;
|
||||
}
|
||||
bodyEl.textContent = '';
|
||||
const token = {};
|
||||
this._spreadsheetPreviewToken = token;
|
||||
this._spreadsheetPreview = renderer.open({
|
||||
container: bodyEl,
|
||||
url,
|
||||
size,
|
||||
isCurrent: () => this._spreadsheetPreviewToken === token,
|
||||
});
|
||||
},
|
||||
|
||||
_disposeSpreadsheetPreview() {
|
||||
const handle = this._spreadsheetPreview;
|
||||
this._spreadsheetPreview = null;
|
||||
this._spreadsheetPreviewToken = null;
|
||||
try {
|
||||
handle?.dispose();
|
||||
} catch (err) {
|
||||
console.warn('Failed to dispose spreadsheet preview:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer text view: rendered markdown, line numbers, wrap
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -5056,7 +5100,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||
<div>Show a file here by running:</div>
|
||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
||||
<div>Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
|
||||
*
|
||||
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
|
||||
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
|
||||
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). ExcelJS is then
|
||||
* given a STORE-only archive rebuilt from the entries admission inflated, never
|
||||
* the fetched bytes, so it can only parse what admission counted. The page never
|
||||
* loads either vendor file. Cell values are sent back as plain strings; the
|
||||
* renderer writes them with `textContent`. Formulas are never evaluated (the
|
||||
* cached result is shown, else the formula text), and nothing here fetches:
|
||||
* external links, images and drawings are reported as unsupported features.
|
||||
*
|
||||
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
|
||||
* keeps a reverse-proxy `--base-url` mount working.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
|
||||
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
|
||||
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
|
||||
|
||||
const core = self.CodemanSpreadsheetXlsxCore;
|
||||
let workbook = null;
|
||||
let sheetsById = new Map();
|
||||
// Per sheet: its populated rows in order, each with its populated cells in
|
||||
// column order, built once at load from the keys that exist (`populatedRowIndex`).
|
||||
let populatedRowsById = new Map();
|
||||
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
|
||||
// which is far too much to pay on every tile.
|
||||
let mergesById = new Map();
|
||||
let normalizedStyles = [];
|
||||
let styleIds = new Map();
|
||||
let themePalette = core.DEFAULT_THEME_PALETTE;
|
||||
|
||||
function postError(error) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
code: error?.code || 'parse-failed',
|
||||
message: error?.message || 'Spreadsheet preview failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Maximum cells in one tile reply; the renderer draws at most this many too.
|
||||
const MAX_TILE_CELLS = 2500;
|
||||
|
||||
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
|
||||
// entry is the fallback and the default Office palette is the last resort.
|
||||
function readThemeXml(loadedWorkbook, admittedEntries) {
|
||||
const stashed = loadedWorkbook?._themes?.theme1;
|
||||
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
|
||||
const theme = admittedEntries?.['xl/theme/theme1.xml'];
|
||||
return theme ? new TextDecoder().decode(theme) : '';
|
||||
}
|
||||
|
||||
function normalizeStyle(cell) {
|
||||
// Colours are resolved and contrast-checked as a PAIR. Emitting a
|
||||
// font colour without its background lets workbook text land on the skin's
|
||||
// `var(--bg-primary)` and disappear.
|
||||
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
|
||||
const style = {
|
||||
font: {
|
||||
bold: Boolean(cell.font?.bold),
|
||||
italic: Boolean(cell.font?.italic),
|
||||
color: colors.foreground,
|
||||
},
|
||||
fill: colors.background,
|
||||
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
|
||||
wrapText: Boolean(cell.alignment?.wrapText),
|
||||
};
|
||||
const key = JSON.stringify(style);
|
||||
if (styleIds.has(key)) return styleIds.get(key);
|
||||
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
|
||||
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
|
||||
}
|
||||
const id = normalizedStyles.length;
|
||||
normalizedStyles.push(style);
|
||||
styleIds.set(key, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
|
||||
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
|
||||
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
|
||||
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
|
||||
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
|
||||
function presentIndices(sparse) {
|
||||
const indices = [];
|
||||
for (const key of Object.keys(sparse || [])) {
|
||||
const index = Number(key);
|
||||
if (Number.isInteger(index) && index >= 0) indices.push(index);
|
||||
}
|
||||
return indices.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
|
||||
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
|
||||
// cell counts when it exists and its type is not `ValueType.Null`, and a row
|
||||
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
|
||||
function populatedRowIndex(sheet) {
|
||||
const nullType = self.ExcelJS.ValueType.Null;
|
||||
const rows = [];
|
||||
for (const rowIndex of presentIndices(sheet._rows)) {
|
||||
const row = sheet._rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const cells = [];
|
||||
for (const cellIndex of presentIndices(row._cells)) {
|
||||
const cell = row._cells[cellIndex];
|
||||
if (cell && cell.type !== nullType) cells.push(cell);
|
||||
}
|
||||
if (cells.length > 0) rows.push({ number: row.number, row, cells });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function worksheetMetadata(sheet) {
|
||||
const cellRefs = [];
|
||||
const rowOverrides = [];
|
||||
const populatedRows = populatedRowIndex(sheet);
|
||||
for (const { number, row, cells } of populatedRows) {
|
||||
for (const cell of cells) {
|
||||
cellRefs.push(cell.address);
|
||||
normalizeStyle(cell);
|
||||
}
|
||||
if (row.hidden) rowOverrides.push([number, 0]);
|
||||
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
|
||||
}
|
||||
// `sheet.model` rebuilds every row and cell model, so merges come straight
|
||||
// from ExcelJS's own merge map, in the order the model getter would list them.
|
||||
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
|
||||
const extent = core.deriveExtent(cellRefs, merges);
|
||||
const columnOverrides = [];
|
||||
for (let col = 1; col <= extent.cols; col += 1) {
|
||||
const column = sheet.getColumn(col);
|
||||
if (column.hidden) columnOverrides.push([col, 0]);
|
||||
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
|
||||
}
|
||||
return {
|
||||
populatedRows,
|
||||
merges,
|
||||
metadata: {
|
||||
id: String(sheet.id),
|
||||
name: sheet.name,
|
||||
rows: extent.rows,
|
||||
cols: extent.cols,
|
||||
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
|
||||
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
|
||||
rowOverrides,
|
||||
columnOverrides,
|
||||
merges,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cellDisplay(cell, date1904, warnings) {
|
||||
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
|
||||
if (formatted.warning) warnings.add(formatted.warning);
|
||||
return formatted.text;
|
||||
}
|
||||
|
||||
async function loadWorkbook(bytes) {
|
||||
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
|
||||
const admitted = core.buildAdmittedArchive(admission, self.fflate);
|
||||
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
|
||||
const nextWorkbook = new self.ExcelJS.Workbook();
|
||||
// ExcelJS's DefinedNames model setter expands every range into one object per
|
||||
// cell (a whole-sheet name exhausts the heap), and admission reads only the
|
||||
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
|
||||
// stored at all; print areas and titles are split off before this setter runs.
|
||||
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||
// than silently expanding again.
|
||||
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||
// object (a whole-column dropdown is a million), and the preview never shows
|
||||
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||
// backstop behind admission's row count, which also caps the workbook total.
|
||||
await nextWorkbook.xlsx.load(admitted, {
|
||||
ignoreNodes: ['dataValidations'],
|
||||
maxRows: core.LIMITS.maxRowsPerSheet,
|
||||
});
|
||||
const nextSheets = new Map();
|
||||
const nextRows = new Map();
|
||||
const nextMerges = new Map();
|
||||
normalizedStyles = [];
|
||||
styleIds = new Map();
|
||||
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
|
||||
const sheets = [];
|
||||
for (const sheet of nextWorkbook.worksheets) {
|
||||
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
|
||||
const sheetResult = worksheetMetadata(sheet);
|
||||
const metadata = sheetResult.metadata;
|
||||
nextSheets.set(metadata.id, sheet);
|
||||
nextRows.set(metadata.id, sheetResult.populatedRows);
|
||||
nextMerges.set(metadata.id, sheetResult.merges);
|
||||
sheets.push(metadata);
|
||||
}
|
||||
workbook = nextWorkbook;
|
||||
sheetsById = nextSheets;
|
||||
populatedRowsById = nextRows;
|
||||
mergesById = nextMerges;
|
||||
self.postMessage({
|
||||
type: 'metadata',
|
||||
sheets,
|
||||
styles: normalizedStyles,
|
||||
date1904: Boolean(workbook.properties?.date1904),
|
||||
empty: sheets.length === 0,
|
||||
warnings: admission.features,
|
||||
});
|
||||
}
|
||||
|
||||
function sendTile(message) {
|
||||
if (!workbook) throw new Error('Workbook is not loaded');
|
||||
const sheet = sheetsById.get(String(message.sheetId));
|
||||
if (!sheet) throw new Error('Worksheet is unavailable');
|
||||
const range = message.range;
|
||||
const warnings = new Set();
|
||||
const cells = [];
|
||||
const seenCells = new Set();
|
||||
let truncated = false;
|
||||
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
|
||||
// (a filtered sheet); they are never drawn, so never sent.
|
||||
const hiddenColumns = new Map();
|
||||
const columnHidden = (col) => {
|
||||
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
|
||||
return hiddenColumns.get(col);
|
||||
};
|
||||
const addCell = (cell) => {
|
||||
const key = `${cell.row}:${cell.col}`;
|
||||
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
|
||||
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
|
||||
if (cells.length >= MAX_TILE_CELLS) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
seenCells.add(key);
|
||||
cells.push({
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
|
||||
styleId: normalizeStyle(cell),
|
||||
});
|
||||
};
|
||||
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
|
||||
for (const populated of populatedRows) {
|
||||
if (truncated) break;
|
||||
if (populated.number < range.r1) continue;
|
||||
if (populated.number > range.r2) break;
|
||||
if (populated.row.hidden) continue;
|
||||
for (const cell of populated.cells) {
|
||||
if (cell.col < range.c1) continue;
|
||||
if (cell.col > range.c2) break;
|
||||
addCell(cell);
|
||||
}
|
||||
}
|
||||
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
|
||||
for (const merge of merges) {
|
||||
const anchor = core.parseRange(merge);
|
||||
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
|
||||
}
|
||||
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
|
||||
self.postMessage({
|
||||
type: 'tile',
|
||||
requestId: message.requestId,
|
||||
sheetId: String(message.sheetId),
|
||||
cells,
|
||||
merges,
|
||||
// One counted entry for many unsupported number formats keeps the notice bar short.
|
||||
warnings: core.foldWarnings(Array.from(warnings)),
|
||||
});
|
||||
}
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
try {
|
||||
const message = event.data || {};
|
||||
if (message.type === 'load') await loadWorkbook(message.bytes);
|
||||
else if (message.type === 'tile') sendTile(message);
|
||||
else if (message.type === 'dispose') {
|
||||
workbook = null;
|
||||
sheetsById = new Map();
|
||||
populatedRowsById = new Map();
|
||||
mergesById = new Map();
|
||||
themePalette = core.DEFAULT_THEME_PALETTE;
|
||||
}
|
||||
} catch (error) {
|
||||
postError(error);
|
||||
}
|
||||
};
|
||||
|
||||
self.postMessage({ type: 'ready' });
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
|
||||
*
|
||||
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
|
||||
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
|
||||
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
|
||||
* the visible tile of cells. Parsing happens entirely in the browser worker; the
|
||||
* server just streams the file through its existing confined raw routes.
|
||||
*
|
||||
* Every workbook string (cell text, sheet names) is written with `textContent`,
|
||||
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
|
||||
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
|
||||
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
|
||||
* or closed.
|
||||
*
|
||||
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
|
||||
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
|
||||
*/
|
||||
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
const ROW_HEADING_WIDTH = 36;
|
||||
const COLUMN_HEADING_HEIGHT = 20;
|
||||
const assets = Object.freeze({
|
||||
version: SPREADSHEET_ASSET_VERSION,
|
||||
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
|
||||
});
|
||||
|
||||
function message(container, text, kind) {
|
||||
container.textContent = '';
|
||||
const state = document.createElement('div');
|
||||
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
|
||||
state.textContent = text;
|
||||
container.appendChild(state);
|
||||
}
|
||||
|
||||
function safePreviewUrl(candidate) {
|
||||
const url = new URL(candidate, global.location.href);
|
||||
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
|
||||
url.searchParams.set('preview', 'true');
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
const container = options.container;
|
||||
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
|
||||
let disposed = false;
|
||||
let worker = null;
|
||||
let controller = null;
|
||||
let timer = null;
|
||||
let metadata = null;
|
||||
let activeSheetId = null;
|
||||
let latestRequestId = 0;
|
||||
let grid = null;
|
||||
let spacer = null;
|
||||
let cellsLayer = null;
|
||||
let headingsLayer = null;
|
||||
let emptySheetState = null;
|
||||
let resizeObserver = null;
|
||||
let latestRange = null;
|
||||
let latestAxes = null;
|
||||
let scrollFrame = null;
|
||||
|
||||
const current = () => !disposed && isCurrent();
|
||||
const clearTimer = () => {
|
||||
if (timer !== null) global.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const fail = (text) => {
|
||||
if (!current()) return;
|
||||
clearTimer();
|
||||
message(container, text || 'Spreadsheet preview failed', 'error');
|
||||
};
|
||||
|
||||
function sheetMetadata() {
|
||||
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
|
||||
}
|
||||
|
||||
// Prefix sums per override list, built once per sheet's axis: renderTile
|
||||
// asks for several offsets per cell, so a linear walk over every override
|
||||
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
|
||||
const axisDeltas = new WeakMap();
|
||||
|
||||
function overrideDeltas(overrides, defaultSize) {
|
||||
let entry = axisDeltas.get(overrides);
|
||||
if (!entry || entry.defaultSize !== defaultSize) {
|
||||
const deltas = [];
|
||||
let delta = 0;
|
||||
for (const [, size] of overrides) {
|
||||
delta += size - defaultSize;
|
||||
deltas.push(delta);
|
||||
}
|
||||
entry = { defaultSize, deltas };
|
||||
axisDeltas.set(overrides, entry);
|
||||
}
|
||||
return entry.deltas;
|
||||
}
|
||||
|
||||
function axisOffset(count, defaultSize, overrides, index) {
|
||||
const bounded = Math.max(1, Math.min(count + 1, index));
|
||||
const list = overrides || [];
|
||||
let low = 0;
|
||||
let high = list.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (list[mid][0] < bounded) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
|
||||
}
|
||||
|
||||
function axisIndex(count, defaultSize, overrides, offset) {
|
||||
let low = 1;
|
||||
let high = Math.max(1, count);
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high + 1) / 2);
|
||||
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
|
||||
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
|
||||
// range, so the last row stays reachable) and the tile is laid out at real
|
||||
// sizes from there. `shift` is the logical offset minus the scroll offset,
|
||||
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
|
||||
function scrollAxis(logical, scroll, viewport, heading) {
|
||||
const shown = Math.min(MAX_SCROLL_PX, logical);
|
||||
const scrollRange = Math.max(0, heading + shown - viewport);
|
||||
const logicalRange = Math.max(0, heading + logical - viewport);
|
||||
const virtual =
|
||||
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
|
||||
return { virtual, shift: virtual - scroll, end: heading + shown };
|
||||
}
|
||||
|
||||
function requestTile() {
|
||||
if (!current() || !worker || !grid) return;
|
||||
const sheet = sheetMetadata();
|
||||
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
|
||||
const viewHeight = grid.clientHeight || 500;
|
||||
const viewWidth = grid.clientWidth || 800;
|
||||
const y = scrollAxis(
|
||||
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
|
||||
grid.scrollTop,
|
||||
viewHeight,
|
||||
COLUMN_HEADING_HEIGHT
|
||||
);
|
||||
const x = scrollAxis(
|
||||
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
|
||||
grid.scrollLeft,
|
||||
viewWidth,
|
||||
ROW_HEADING_WIDTH
|
||||
);
|
||||
const r1 = Math.max(
|
||||
1,
|
||||
axisIndex(
|
||||
sheet.rows,
|
||||
sheet.defaultRowHeight,
|
||||
sheet.rowOverrides,
|
||||
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
|
||||
) - 2
|
||||
);
|
||||
const c1 = Math.max(
|
||||
1,
|
||||
axisIndex(
|
||||
sheet.cols,
|
||||
sheet.defaultColumnWidth,
|
||||
sheet.columnOverrides,
|
||||
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
|
||||
) - 2
|
||||
);
|
||||
const r2 = Math.min(
|
||||
sheet.rows,
|
||||
axisIndex(
|
||||
sheet.rows,
|
||||
sheet.defaultRowHeight,
|
||||
sheet.rowOverrides,
|
||||
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
|
||||
) + 2
|
||||
);
|
||||
const c2 = Math.min(
|
||||
sheet.cols,
|
||||
axisIndex(
|
||||
sheet.cols,
|
||||
sheet.defaultColumnWidth,
|
||||
sheet.columnOverrides,
|
||||
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
|
||||
) + 2
|
||||
);
|
||||
latestRequestId += 1;
|
||||
latestRange = { r1, c1, r2, c2 };
|
||||
latestAxes = { y, x };
|
||||
worker.postMessage({
|
||||
type: 'tile',
|
||||
requestId: latestRequestId,
|
||||
sheetId: String(activeSheetId),
|
||||
range: { r1, c1, r2, c2 },
|
||||
});
|
||||
}
|
||||
|
||||
function renderWarnings(tileWarnings) {
|
||||
const notice = container.querySelector('.spreadsheet-preview-notice');
|
||||
if (!notice) return;
|
||||
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
|
||||
notice.hidden = warnings.length === 0;
|
||||
const warningLabel =
|
||||
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
|
||||
}
|
||||
|
||||
function pinHeadings() {
|
||||
if (!grid || !headingsLayer) return;
|
||||
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
|
||||
heading.style.left = `${grid.scrollLeft}px`;
|
||||
});
|
||||
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
|
||||
heading.style.top = `${grid.scrollTop}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTile(tile) {
|
||||
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
|
||||
const sheet = sheetMetadata();
|
||||
if (!sheet || !cellsLayer || !headingsLayer || !latestRange || !latestAxes) return;
|
||||
const { y, x } = latestAxes;
|
||||
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
|
||||
// it never grows the scroll area.
|
||||
const rowTop = (row) =>
|
||||
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
|
||||
const colLeft = (col) =>
|
||||
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
|
||||
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
|
||||
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
|
||||
cellsLayer.textContent = '';
|
||||
headingsLayer.textContent = '';
|
||||
const mergeByAnchor = new Map();
|
||||
for (const merge of tile.merges || []) {
|
||||
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
|
||||
if (!match) continue;
|
||||
const column = (letters) =>
|
||||
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
|
||||
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
|
||||
r2: Number(match[4]),
|
||||
c2: column(match[3]),
|
||||
});
|
||||
}
|
||||
for (const cell of tile.cells.slice(0, 2500)) {
|
||||
const element = document.createElement('div');
|
||||
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
|
||||
element.dataset.row = String(cell.row);
|
||||
element.dataset.col = String(cell.col);
|
||||
element.textContent = String(cell.text ?? '');
|
||||
element.style.top = `${rowTop(cell.row)}px`;
|
||||
element.style.left = `${colLeft(cell.col)}px`;
|
||||
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
|
||||
element.style.height = `${rowSpan(cell.row, merge?.r2 || cell.row)}px`;
|
||||
element.style.width = `${colSpan(cell.col, merge?.c2 || cell.col)}px`;
|
||||
cellsLayer.appendChild(element);
|
||||
}
|
||||
// Headings take their size from the same axis math as the cells, so custom
|
||||
// widths/heights line up; hidden (0 px) rows and columns get no heading and
|
||||
// do not count against the heading caps.
|
||||
let rowHeadings = 0;
|
||||
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
|
||||
const height = rowSpan(row, row);
|
||||
if (height <= 0) continue;
|
||||
rowHeadings += 1;
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'spreadsheet-row-heading';
|
||||
heading.textContent = String(row);
|
||||
heading.style.top = `${rowTop(row)}px`;
|
||||
heading.style.height = `${height}px`;
|
||||
heading.style.left = `${grid.scrollLeft}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
let columnHeadings = 0;
|
||||
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
||||
const width = colSpan(col, col);
|
||||
if (width <= 0) continue;
|
||||
columnHeadings += 1;
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'spreadsheet-column-heading';
|
||||
let label = '';
|
||||
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
|
||||
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
|
||||
heading.textContent = label;
|
||||
heading.style.left = `${colLeft(col)}px`;
|
||||
heading.style.width = `${width}px`;
|
||||
heading.style.top = `${grid.scrollTop}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
renderWarnings(tile.warnings);
|
||||
}
|
||||
|
||||
function selectSheet(sheetId) {
|
||||
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
|
||||
activeSheetId = String(sheetId);
|
||||
latestRequestId += 1;
|
||||
latestRange = null;
|
||||
latestAxes = null;
|
||||
if (cellsLayer) cellsLayer.textContent = '';
|
||||
if (headingsLayer) headingsLayer.textContent = '';
|
||||
container.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||
const selected = tab.dataset.sheetId === activeSheetId;
|
||||
tab.setAttribute('aria-selected', String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
});
|
||||
if (grid) {
|
||||
grid.scrollTop = 0;
|
||||
grid.scrollLeft = 0;
|
||||
}
|
||||
const sheet = sheetMetadata();
|
||||
if (sheet && spacer) {
|
||||
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
|
||||
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
|
||||
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
|
||||
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
|
||||
}
|
||||
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
|
||||
renderWarnings([]);
|
||||
requestTile();
|
||||
}
|
||||
|
||||
function renderMetadata(nextMetadata) {
|
||||
if (!current()) return;
|
||||
metadata = nextMetadata;
|
||||
container.textContent = '';
|
||||
if (!metadata.sheets?.length) {
|
||||
message(container, 'This workbook has no visible worksheets.', 'empty');
|
||||
return;
|
||||
}
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'spreadsheet-preview-shell';
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.textContent = (metadata.styles || [])
|
||||
.map((style, id) => {
|
||||
const declarations = [];
|
||||
if (style.font?.bold) declarations.push('font-weight:700');
|
||||
if (style.font?.italic) declarations.push('font-style:italic');
|
||||
// Colour and background are emitted together or not at all.
|
||||
// The worker already contrast-checked them as a pair; contributing
|
||||
// one half would drop the cell back onto the skin's own background.
|
||||
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
|
||||
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
|
||||
}
|
||||
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
|
||||
if (style.wrapText) declarations.push('white-space:normal');
|
||||
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
|
||||
})
|
||||
.join('');
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'spreadsheet-sheet-tabs';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
tabs.setAttribute('data-i18n-skip', '');
|
||||
for (const sheet of metadata.sheets) {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'spreadsheet-sheet-tab';
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.dataset.sheetId = String(sheet.id);
|
||||
tab.textContent = sheet.name;
|
||||
tab.addEventListener('click', () => selectSheet(sheet.id));
|
||||
tabs.appendChild(tab);
|
||||
}
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'spreadsheet-preview-notice';
|
||||
notice.hidden = true;
|
||||
emptySheetState = document.createElement('div');
|
||||
emptySheetState.className = 'spreadsheet-empty-sheet';
|
||||
emptySheetState.textContent = 'This worksheet is empty.';
|
||||
emptySheetState.hidden = true;
|
||||
grid = document.createElement('div');
|
||||
grid.className = 'spreadsheet-grid';
|
||||
grid.setAttribute('data-i18n-skip', '');
|
||||
spacer = document.createElement('div');
|
||||
spacer.className = 'spreadsheet-grid-spacer';
|
||||
cellsLayer = document.createElement('div');
|
||||
cellsLayer.className = 'spreadsheet-cells';
|
||||
headingsLayer = document.createElement('div');
|
||||
headingsLayer.className = 'spreadsheet-headings';
|
||||
grid.append(spacer, cellsLayer, headingsLayer);
|
||||
grid.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
pinHeadings();
|
||||
if (scrollFrame !== null) return;
|
||||
scrollFrame = global.requestAnimationFrame(() => {
|
||||
scrollFrame = null;
|
||||
requestTile();
|
||||
});
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
|
||||
container.appendChild(shell);
|
||||
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
|
||||
resizeObserver?.observe(grid);
|
||||
selectSheet(metadata.sheets[0].id);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimer();
|
||||
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
|
||||
controller?.abort();
|
||||
resizeObserver?.disconnect();
|
||||
try {
|
||||
worker?.postMessage({ type: 'dispose' });
|
||||
worker?.terminate();
|
||||
} catch {
|
||||
// A worker that failed during startup may already be unavailable.
|
||||
}
|
||||
worker = null;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (Number(options.size) > MAX_PREVIEW_BYTES) {
|
||||
fail('This workbook is too large to preview (10 MB limit).');
|
||||
return;
|
||||
}
|
||||
message(container, 'Loading spreadsheet…', 'loading');
|
||||
let previewUrl;
|
||||
try {
|
||||
previewUrl = safePreviewUrl(options.url);
|
||||
controller = new AbortController();
|
||||
// Root-absolute paths ignore <base href>; route through the mount prefix.
|
||||
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
|
||||
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data?.type === 'ready') resolve();
|
||||
};
|
||||
});
|
||||
const responsePromise = fetch(previewUrl, { signal: controller.signal });
|
||||
const [response] = await Promise.all([responsePromise, ready]);
|
||||
if (!current()) return;
|
||||
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
|
||||
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (!current()) return;
|
||||
worker.onmessage = (event) => {
|
||||
if (!current()) return;
|
||||
const payload = event.data || {};
|
||||
if (payload.type === 'metadata') {
|
||||
clearTimer();
|
||||
renderMetadata(payload);
|
||||
} else if (payload.type === 'tile') renderTile(payload);
|
||||
else if (payload.type === 'error') fail(payload.message);
|
||||
};
|
||||
worker.onerror = () => fail('Spreadsheet parser failed.');
|
||||
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
|
||||
timer = global.setTimeout(() => {
|
||||
worker?.terminate();
|
||||
fail('Spreadsheet preview timed out.');
|
||||
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
worker.postMessage({ type: 'load', bytes }, [bytes]);
|
||||
} catch (error) {
|
||||
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
|
||||
}
|
||||
}
|
||||
|
||||
void start();
|
||||
return Object.freeze({ dispose, selectSheet, resize: requestTile });
|
||||
}
|
||||
|
||||
global.CodemanSpreadsheetPreviewAssets = assets;
|
||||
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
|
||||
})(window);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20188,6 +20188,138 @@ body.tile-grid-resizing--row * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
|
||||
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
|
||||
.spreadsheet-preview-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.spreadsheet-sheet-tabs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding: 6px 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.spreadsheet-sheet-tab {
|
||||
flex: 0 0 auto;
|
||||
min-width: 80px;
|
||||
max-width: 220px;
|
||||
padding: 6px 12px;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.spreadsheet-sheet-tab[aria-selected='true'] {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.spreadsheet-preview-notice {
|
||||
flex: 0 0 auto;
|
||||
/* A long warning list scrolls inside the bar instead of pushing the grid out of view. */
|
||||
max-height: 4.5em;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 5px 10px;
|
||||
color: var(--warning, #f59e0b);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.spreadsheet-grid {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 220px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.spreadsheet-grid-spacer,
|
||||
.spreadsheet-cells,
|
||||
.spreadsheet-headings {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.spreadsheet-row-heading,
|
||||
.spreadsheet-column-heading {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Only the fixed gutter dimension lives here; the per-row height and per-column
|
||||
width come inline from spreadsheet-preview.js's axis math. */
|
||||
.spreadsheet-row-heading {
|
||||
left: 0;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.spreadsheet-column-heading {
|
||||
top: 0;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.spreadsheet-empty-sheet {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
left: 36px;
|
||||
z-index: 3;
|
||||
width: max-content;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.spreadsheet-cell {
|
||||
position: absolute;
|
||||
min-width: 0;
|
||||
padding: 2px 5px;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.spreadsheet-preview-message {
|
||||
display: grid;
|
||||
min-height: 220px;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spreadsheet-preview-message.error {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
||||
|
||||
|
||||
@@ -73,6 +73,35 @@ import {
|
||||
isEditableFileName,
|
||||
} from '../../config/file-editing.js';
|
||||
|
||||
/**
|
||||
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
|
||||
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
|
||||
* what a single preview can hand the worker; the renderer refuses the same size
|
||||
* before fetching. An explicit download is unaffected (global download cap).
|
||||
*/
|
||||
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Whether a raw request is an XLSX browser preview over the preview cap. */
|
||||
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
|
||||
return (
|
||||
extension === 'xlsx' &&
|
||||
query.preview === 'true' &&
|
||||
query.download !== 'true' &&
|
||||
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
|
||||
reply
|
||||
.code(413)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
@@ -100,6 +129,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
json: 'application/json',
|
||||
md: 'text/markdown',
|
||||
txt: 'text/plain',
|
||||
@@ -1778,13 +1808,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
|
||||
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
|
||||
// Return metadata for media/binary files (no text body)
|
||||
// Return metadata for media/binary files (no text body). XLSX is still a
|
||||
// binary here (no text body); `spreadsheet` tells the overlay it can parse
|
||||
// it client-side from `url`. xls/ods stay plain binary (download only).
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
type: mediaType ?? 'binary',
|
||||
type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
|
||||
extension: ext,
|
||||
url: fileRawUrl,
|
||||
},
|
||||
@@ -2009,7 +2041,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Serve raw file content (for images/binary files)
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!filePath) {
|
||||
@@ -2039,6 +2071,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
@@ -2217,7 +2253,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const { download } = req.query as { download?: string };
|
||||
const { download, preview } = req.query as { download?: string; preview?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -2237,6 +2273,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(
|
||||
reply,
|
||||
target,
|
||||
|
||||
Reference in New Issue
Block a user