mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(preview): address review of the XLSX preview
- Normalize the value shapes ExcelJS loads before formatting: Date cells are formatted from their serial (UTC), so they no longer render as a local-time string a day early at negative UTC offsets; rich text joins its runs, hyperlinks show their text, error values show the error, and formula and shared-formula results (including error results) recurse. Excel serials are rounded to whole milliseconds so 00:05 no longer shows as 00:04. - sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a truncated tile with a warning instead of failing the whole preview. - ExcelJS now parses a STORE-only archive rebuilt from exactly the entries admitXlsx() inflated and counted, never the fetched bytes. Admission walks local headers while JSZip reads the central directory, so overlapping entries could show the two readers different sheets. A duplicate local entry name is refused. The theme fallback reads the admitted entry too. - Row and column headings take their size from the same axis math as cells. - Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION rules in architecture-invariants, and list .xlsx in the attachments panel help and the `codeman attach` error text (built from the accepted list).
This commit is contained in:
1 parent
0b122e2c76
commit
4edb7b8f80
14 files changed
+557
-46
No files matched your search
@@ -53,17 +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',
|
||||
// Previewed client-side (spreadsheet-preview-worker.js); served raw like the rest.
|
||||
'xlsx',
|
||||
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||
'md',
|
||||
'txt',
|
||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4790,7 +4790,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;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* 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). The page never
|
||||
* 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:
|
||||
@@ -35,22 +37,16 @@ function postError(error) {
|
||||
});
|
||||
}
|
||||
|
||||
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the
|
||||
// fflate re-read is a fallback (admission already bounded the file) and the
|
||||
// default Office palette is the last resort.
|
||||
function readThemeXml(loadedWorkbook, bytes) {
|
||||
// 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;
|
||||
try {
|
||||
const entries = self.fflate.unzipSync(new Uint8Array(bytes), {
|
||||
filter: (file) => file.name === 'xl/theme/theme1.xml',
|
||||
});
|
||||
const theme = entries['xl/theme/theme1.xml'];
|
||||
if (theme) return new TextDecoder().decode(theme);
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
return '';
|
||||
const theme = admittedEntries?.['xl/theme/theme1.xml'];
|
||||
return theme ? new TextDecoder().decode(theme) : '';
|
||||
}
|
||||
|
||||
function normalizeStyle(cell) {
|
||||
@@ -126,14 +122,15 @@ function cellDisplay(cell, date1904, warnings) {
|
||||
|
||||
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();
|
||||
await nextWorkbook.xlsx.load(bytes);
|
||||
await nextWorkbook.xlsx.load(admitted);
|
||||
const nextSheets = new Map();
|
||||
const nextRows = new Map();
|
||||
normalizedStyles = [];
|
||||
styleIds = new Map();
|
||||
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, bytes));
|
||||
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
|
||||
const sheets = [];
|
||||
for (const sheet of nextWorkbook.worksheets) {
|
||||
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
|
||||
@@ -164,9 +161,22 @@ function sendTile(message) {
|
||||
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,
|
||||
@@ -177,20 +187,22 @@ function sendTile(message) {
|
||||
};
|
||||
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
|
||||
for (const rowNumber of populatedRows) {
|
||||
if (truncated) break;
|
||||
if (rowNumber < range.r1) continue;
|
||||
if (rowNumber > range.r2) break;
|
||||
const row = sheet.getRow(rowNumber);
|
||||
if (row.hidden) continue;
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (cell.col < range.c1 || cell.col > range.c2) return;
|
||||
addCell(cell);
|
||||
});
|
||||
if (cells.length > 2500) throw new core.XlsxPreviewError('tile-limit', 'Spreadsheet tile exceeds the cell limit');
|
||||
}
|
||||
const merges = core.intersectingMerges(Array.from(sheet.model?.merges || []), 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,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '4b7074e75ab1';
|
||||
const SPREADSHEET_ASSET_VERSION = '91b615278d5b';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
@@ -213,22 +213,38 @@
|
||||
element.style.width = `${Math.max(0, (axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, finalCol + 1) - axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, cell.col)) / scaleX)}px`;
|
||||
cellsLayer.appendChild(element);
|
||||
}
|
||||
for (let row = latestRange.r1; row <= latestRange.r2 && row < latestRange.r1 + 200; row += 1) {
|
||||
// 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 top = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row);
|
||||
const height = (axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row + 1) - top) / scaleY;
|
||||
if (height <= 0) continue;
|
||||
rowHeadings += 1;
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'spreadsheet-row-heading';
|
||||
heading.textContent = String(row);
|
||||
heading.style.top = `${COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) / scaleY}px`;
|
||||
heading.style.top = `${COLUMN_HEADING_HEIGHT + top / scaleY}px`;
|
||||
heading.style.height = `${height}px`;
|
||||
heading.style.left = `${grid.scrollLeft}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
for (let col = latestRange.c1; col <= latestRange.c2 && col < latestRange.c1 + 100; col += 1) {
|
||||
let columnHeadings = 0;
|
||||
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
||||
const left = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col);
|
||||
const width =
|
||||
(axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col + 1) - left) / scaleX;
|
||||
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 = `${ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) / scaleX}px`;
|
||||
heading.style.left = `${ROW_HEADING_WIDTH + left / scaleX}px`;
|
||||
heading.style.width = `${width}px`;
|
||||
heading.style.top = `${grid.scrollTop}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* test/spreadsheet-xlsx-core.test.ts. `admitXlsx()` walks the ZIP central
|
||||
* directory and streams every entry through fflate BEFORE ExcelJS sees the
|
||||
* bytes, enforcing {@link LIMITS}; a workbook that trips any cap is refused
|
||||
* rather than truncated.
|
||||
* rather than truncated. It returns the entries it inflated, and the worker
|
||||
* hands ExcelJS a STORE-only archive rebuilt from exactly those
|
||||
* (`buildAdmittedArchive()`), never the original bytes: admission follows local
|
||||
* headers while ExcelJS (JSZip) follows the central directory, so overlapping
|
||||
* entries could otherwise show each reader a different file.
|
||||
*/
|
||||
|
||||
(function initSpreadsheetXlsxCore(global) {
|
||||
@@ -157,6 +161,7 @@
|
||||
const expectedEntries = new Map();
|
||||
for (const entry of directory.entries) expectedEntries.set(entry.name, (expectedEntries.get(entry.name) || 0) + 1);
|
||||
const streamedEntries = new Map();
|
||||
const inflatedEntries = Object.create(null);
|
||||
const counts = { worksheets: 0, cells: 0, merges: 0, styles: 0 };
|
||||
const features = new Set();
|
||||
let totalInflated = 0;
|
||||
@@ -164,6 +169,9 @@
|
||||
let thrown;
|
||||
const unzip = new zipApi.Unzip((file) => {
|
||||
if (!directoryByName.has(file.name)) fail('malformed', 'Local XLSX entry is absent from the central directory');
|
||||
// The admitted archive is rebuilt from these entries, which cannot hold two
|
||||
// files under one name, so a duplicate name is refused rather than dropped.
|
||||
if (streamedEntries.has(file.name)) fail('malformed', 'Duplicate XLSX entry name');
|
||||
streamedEntries.set(file.name, (streamedEntries.get(file.name) || 0) + 1);
|
||||
seenEntries += 1;
|
||||
if (seenEntries > limits.maxEntries) fail('entry-limit', 'Workbook exceeds the ZIP entries limit');
|
||||
@@ -175,8 +183,10 @@
|
||||
if (feature) features.add(feature);
|
||||
const counter = createXmlCounter(file.name, counts, limits);
|
||||
let entryInflated = 0;
|
||||
const chunks = [];
|
||||
file.ondata = (error, chunk, final) => {
|
||||
if (error) throw error;
|
||||
chunks.push(chunk.slice());
|
||||
entryInflated += chunk.length;
|
||||
totalInflated += chunk.length;
|
||||
if (entryInflated > limits.maxEntryBytes) fail('entry-size', 'Inflated ZIP entry exceeds the entry limit');
|
||||
@@ -186,6 +196,16 @@
|
||||
fail('compression-ratio', 'ZIP entry exceeds the compression ratio limit');
|
||||
}
|
||||
counter.push(chunk, final);
|
||||
if (final) {
|
||||
const data = new Uint8Array(entryInflated);
|
||||
let offset = 0;
|
||||
for (const part of chunks) {
|
||||
data.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
chunks.length = 0;
|
||||
inflatedEntries[file.name] = data;
|
||||
}
|
||||
};
|
||||
file.start();
|
||||
});
|
||||
@@ -203,7 +223,17 @@
|
||||
for (const [name, count] of expectedEntries) {
|
||||
if (streamedEntries.get(name) !== count) fail('malformed', 'Central XLSX entry was not streamed for admission');
|
||||
}
|
||||
return { counts, features: Array.from(features), inflatedBytes: totalInflated };
|
||||
for (const name of streamedEntries.keys()) {
|
||||
if (!(name in inflatedEntries)) fail('malformed', 'XLSX entry did not finish streaming for admission');
|
||||
}
|
||||
return { counts, features: Array.from(features), inflatedBytes: totalInflated, entries: inflatedEntries };
|
||||
}
|
||||
|
||||
// The ONLY bytes ExcelJS may parse: the entries admission itself inflated and
|
||||
// counted, re-packed uncompressed. Its size is ~ the inflated total (already
|
||||
// capped at LIMITS.maxInflatedBytes) plus per-entry headers.
|
||||
function buildAdmittedArchive(admission, zipApi) {
|
||||
return zipApi.zipSync(admission.entries, { level: 0 });
|
||||
}
|
||||
|
||||
function parseCellRef(ref) {
|
||||
@@ -292,37 +322,82 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Rounded to whole milliseconds: `new Date(fraction)` truncates, which turned
|
||||
// midnight minus a float error into the previous day.
|
||||
function excelDate(serial, date1904) {
|
||||
if (date1904) return new Date(Date.UTC(1904, 0, 1) + Number(serial) * 86400000);
|
||||
if (date1904) return new Date(Math.round(Date.UTC(1904, 0, 1) + Number(serial) * 86400000));
|
||||
const numeric = Number(serial);
|
||||
const adjusted = numeric >= 60 ? numeric - 1 : numeric;
|
||||
return new Date(Date.UTC(1899, 11, 31) + adjusted * 86400000);
|
||||
return new Date(Math.round(Date.UTC(1899, 11, 31) + adjusted * 86400000));
|
||||
}
|
||||
|
||||
function isDateValue(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Date]' && Number.isFinite(value.getTime());
|
||||
}
|
||||
|
||||
// Inverse of ExcelJS's own `excelToDate()` (utils.js), which builds the Date
|
||||
// from the serial in UTC. Recovering the serial keeps the result independent of
|
||||
// the viewer's timezone; `String(date)` rendered it in local time, a day early
|
||||
// at any negative UTC offset.
|
||||
function dateToSerial(date, date1904) {
|
||||
return 25569 + date.getTime() / 86400000 - (date1904 ? 1462 : 0);
|
||||
}
|
||||
|
||||
const DATE_FORMAT = /^[ymd\-/ ]+$/i;
|
||||
const TIME_FORMAT = /^[hms: ]+$/i;
|
||||
const DATE_TIME_FORMAT = /^[ymdhis\-/: ]+$/i;
|
||||
|
||||
function isFormulaValue(value) {
|
||||
return 'formula' in value || 'sharedFormula' in value;
|
||||
}
|
||||
|
||||
// Every non-scalar shape ExcelJS loads a cell value as. Anything not handled
|
||||
// here would otherwise reach String() and render as "[object Object]".
|
||||
function formatCellValue(value, format, date1904) {
|
||||
if (value && typeof value === 'object' && 'formula' in value) {
|
||||
if (value.result !== undefined && value.result !== null) return formatCellValue(value.result, format, date1904);
|
||||
return { text: `=${String(value.formula)}`, warning: 'Formula has no cached result' };
|
||||
}
|
||||
if (value === null || value === undefined) return { text: '' };
|
||||
if (isDateValue(value)) {
|
||||
const code = String(format || 'General');
|
||||
const known = DATE_FORMAT.test(code) || TIME_FORMAT.test(code) || DATE_TIME_FORMAT.test(code);
|
||||
const serial = dateToSerial(value, date1904);
|
||||
if (known) return formatCellValue(serial, code, date1904);
|
||||
const fallback = serial % 1 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm';
|
||||
const formatted = formatCellValue(serial, fallback, date1904);
|
||||
return /^General$/i.test(code)
|
||||
? formatted
|
||||
: { text: formatted.text, warning: `Unsupported number format: ${code}` };
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (isFormulaValue(value)) {
|
||||
if (value.result !== undefined && value.result !== null) return formatCellValue(value.result, format, date1904);
|
||||
const source = typeof value.formula === 'string' ? `=${value.formula}` : '';
|
||||
return { text: source, warning: 'Formula has no cached result' };
|
||||
}
|
||||
if (typeof value.error === 'string') return { text: value.error };
|
||||
if (Array.isArray(value.richText)) {
|
||||
return { text: value.richText.map((run) => (typeof run?.text === 'string' ? run.text : '')).join('') };
|
||||
}
|
||||
// Hyperlink: the display text, never the target. The text may be rich.
|
||||
if ('text' in value) return formatCellValue(value.text, 'General', date1904);
|
||||
return { text: '', warning: 'Unsupported cell value' };
|
||||
}
|
||||
const code = String(format || 'General');
|
||||
if (typeof value !== 'number') return { text: String(value) };
|
||||
if (/^General$/i.test(code)) return { text: String(value) };
|
||||
if (/^[ymd\-/ ]+$/i.test(code)) {
|
||||
if (DATE_FORMAT.test(code)) {
|
||||
const date = excelDate(value, Boolean(date1904));
|
||||
const yyyy = date.getUTCFullYear();
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return { text: `${yyyy}-${mm}-${dd}` };
|
||||
}
|
||||
if (/^[hms: ]+$/i.test(code)) {
|
||||
if (TIME_FORMAT.test(code)) {
|
||||
const seconds = Math.round((value - Math.floor(value)) * 86400) % 86400;
|
||||
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
|
||||
const ss = String(seconds % 60).padStart(2, '0');
|
||||
return { text: `${hh}:${mm}:${ss}` };
|
||||
}
|
||||
if (/^[ymdhis\-/: ]+$/i.test(code)) {
|
||||
if (DATE_TIME_FORMAT.test(code)) {
|
||||
const date = excelDate(value, Boolean(date1904));
|
||||
const yyyy = date.getUTCFullYear();
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
@@ -567,6 +642,7 @@
|
||||
XlsxPreviewError,
|
||||
inspectZipDirectory,
|
||||
admitXlsx,
|
||||
buildAdmittedArchive,
|
||||
parseCellRef,
|
||||
parseRange,
|
||||
deriveExtent,
|
||||
|
||||
@@ -19167,15 +19167,15 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
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;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.spreadsheet-column-heading {
|
||||
top: 0;
|
||||
width: 64px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user