Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Rust integration tests reuse the repository's existing files under `tests/data/x
- Borrowed dynamic, typed, and structured readers; repeated metadata reads; callback stopping/errors; borrowed dynamic/schema/typed/multi-sheet writers.
- Structured formula text, cached values, A1 addresses, style IDs, built-in/custom number formats, ranges, and early iterator drop.

Writer tests generate temporary workbooks through `MiniExcel::save_as*()` and read them back through `MiniExcel::query*()`, covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, explicit/hidden column layout, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, and desktop/mobile viewports.
Writer tests generate temporary workbooks through `MiniExcel::save_as*()` and read them back through `MiniExcel::query*()`, covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, explicit/hidden column layout, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, the collapsible and resizable control rail with its validated persisted layout, and desktop/mobile viewports.

`TableStyle` controls ordinary cell formats and is not an OOXML table abstraction. Neither mode creates `xl/tables` entries or worksheet `tableParts`.

Expand Down
2 changes: 1 addition & 1 deletion docs/compatibility.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Rust integration test 复用仓库 `tests/data/xlsx` 下的现有文件,包括
- 借用动态/类型化/structured reader、重复 metadata 读取、callback 停止/error,以及借用动态/schema/类型化/多表 writer。
- structured formula text、缓存值、A1 地址、style ID、内置/自定义 number format、range 和提前丢弃迭代器。

Writer test 通过 `MiniExcel::save_as*()` 生成临时 workbook,并使用 `MiniExcel::query*()` 回读,覆盖动态和类型化 value、date、多工作表、visible/hidden/very-hidden 状态、active sheet 选择、行数、空 schema、默认/自定义/禁用冻结窗格、header/headerless/typed AutoFilter 范围、从右到左 view、有界固定 AutoWidth 输出、explicit/hidden column layout、普通 body 换行及 formatted-value 排除、body 对齐与换行/number format 组合、默认/自定义 header style、默认/最小 cell style 模式、显式 path 覆盖行为和 worksheet name 验证。模板测试覆盖标量与混合文本、原生 number/boolean、XML 转义、公式注入防护、缺失变量策略、空数组与非空数组、多工作表、样式保留、path 覆盖和 byte 工作流。WASM adapter 有原生 unit test,Browser Lab Playwright test 则覆盖生成 workbook 的渲染、query 控件、包含端点的结束 range,以及桌面/移动 viewport。
Writer test 通过 `MiniExcel::save_as*()` 生成临时 workbook,并使用 `MiniExcel::query*()` 回读,覆盖动态和类型化 value、date、多工作表、visible/hidden/very-hidden 状态、active sheet 选择、行数、空 schema、默认/自定义/禁用冻结窗格、header/headerless/typed AutoFilter 范围、从右到左 view、有界固定 AutoWidth 输出、explicit/hidden column layout、普通 body 换行及 formatted-value 排除、body 对齐与换行/number format 组合、默认/自定义 header style、默认/最小 cell style 模式、显式 path 覆盖行为和 worksheet name 验证。模板测试覆盖标量与混合文本、原生 number/boolean、XML 转义、公式注入防护、缺失变量策略、空数组与非空数组、多工作表、样式保留、path 覆盖和 byte 工作流。WASM adapter 有原生 unit test,Browser Lab Playwright test 则覆盖生成 workbook 的渲染、query 控件、包含端点的结束 range、可折叠、可拖曳调整宽度且会校验保存布局的控制栏,以及桌面/移动 viewport。

`TableStyle` 控制普通 cell format,并不是 OOXML table 抽象。两种模式都不会创建 `xl/tables` entry 或 worksheet `tableParts`。

Expand Down
126 changes: 125 additions & 1 deletion web-demo/public/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
const MAX_FILE_SIZE = 64 * 1024 * 1024;
const LAYOUT_STORAGE_KEY = "miniexcel.browser-lab.layout/v1";
const RAIL_WIDTH_DEFAULT = 380;
const RAIL_WIDTH_MIN = 260;
const RAIL_WIDTH_MAX = 720;
const RAIL_WIDTH_RATIO = 0.6;
const RAIL_KEYBOARD_STEP = 16;
const worker = new Worker(new URL("./analysis-worker.js", import.meta.url), { type: "module" });
let nextRequestId = 1;
const pendingRequests = new Map();
Expand All @@ -17,11 +23,14 @@ const state = {
columnTypes: new Map(),
builderInitialized: false,
toastTimer: null,
railWidth: RAIL_WIDTH_DEFAULT,
railCollapsed: false,
};

const elements = Object.fromEntries(
[
"runtimeStatus", "fileInput", "openFileButton", "loadDemoButton", "downloadDemoButton",
"runtimeStatus", "workspace", "controlRail", "railSplitter", "railToggleButton", "fileInput",
"openFileButton", "loadDemoButton", "downloadDemoButton",
"downloadJsonButton", "downloadChunksButton", "downloadMarkdownChunksButton",
"downloadManifestButton", "downloadMarkdownButton", "dropZone",
"fileName", "fileSize", "sheetCount", "sheetSelect", "startCellInput", "endCellInput",
Expand Down Expand Up @@ -51,6 +60,7 @@ worker.addEventListener("error", (event) => {
pendingRequests.clear();
});

initLayout();
bindEvents();
boot();

Expand Down Expand Up @@ -130,6 +140,120 @@ function bindEvents() {
elements.previewTab.addEventListener("click", () => setTab("grid"));
elements.jsonTab.addEventListener("click", () => setTab("json"));
elements.markdownTab.addEventListener("click", () => setTab("markdown"));
elements.railToggleButton.addEventListener("click", () => setRailCollapsed(!state.railCollapsed));
bindRailSplitter();
window.addEventListener("resize", applyRailWidth);
}

function initLayout() {
const stored = readStoredLayout();
state.railWidth = stored.width;
setRailCollapsed(stored.collapsed, { persist: false });
}

function readStoredLayout() {
const fallback = { width: RAIL_WIDTH_DEFAULT, collapsed: false };
try {
const raw = window.localStorage.getItem(LAYOUT_STORAGE_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return fallback;
const { width, collapsed } = parsed;
if (!Number.isFinite(width) || width < RAIL_WIDTH_MIN || width > RAIL_WIDTH_MAX) return fallback;
if (typeof collapsed !== "boolean") return fallback;
return { width, collapsed };
} catch {
return fallback;
}
}

function persistLayout() {
try {
window.localStorage.setItem(
LAYOUT_STORAGE_KEY,
JSON.stringify({ width: Math.round(state.railWidth), collapsed: state.railCollapsed }),
);
} catch {
// Storage can be unavailable (private browsing); the layout then only lasts for this session.
}
}

function railWidthBounds() {
const available = elements.workspace.clientWidth || window.innerWidth;
const scaled = Math.round(available * RAIL_WIDTH_RATIO);
return { min: RAIL_WIDTH_MIN, max: Math.max(RAIL_WIDTH_MIN, Math.min(RAIL_WIDTH_MAX, scaled)) };
}

function applyRailWidth() {
const { min, max } = railWidthBounds();
const width = Math.min(Math.max(Math.round(state.railWidth), min), max);
elements.workspace.style.setProperty("--rail-width", `${width}px`);
elements.railSplitter.setAttribute("aria-valuemin", String(min));
elements.railSplitter.setAttribute("aria-valuemax", String(max));
elements.railSplitter.setAttribute("aria-valuenow", String(width));
}

function setRailWidth(width, { persist = true } = {}) {
const { min, max } = railWidthBounds();
state.railWidth = Math.min(Math.max(Math.round(width), min), max);
applyRailWidth();
if (persist) persistLayout();
}

function setRailCollapsed(collapsed, { persist = true } = {}) {
state.railCollapsed = collapsed;
elements.controlRail.hidden = collapsed;
elements.railSplitter.hidden = collapsed;
elements.workspace.classList.toggle("is-collapsed", collapsed);
elements.railToggleButton.setAttribute("aria-expanded", String(!collapsed));
elements.railToggleButton.title = collapsed ? "Show workbook controls" : "Hide workbook controls";
if (!collapsed) applyRailWidth();
if (persist) persistLayout();
}

function bindRailSplitter() {
let activePointer = null;

elements.railSplitter.addEventListener("pointerdown", (event) => {
if (state.railCollapsed || event.button !== 0) return;
activePointer = event.pointerId;
elements.railSplitter.setPointerCapture(event.pointerId);
document.body.classList.add("is-resizing");
});

elements.railSplitter.addEventListener("pointermove", (event) => {
if (activePointer === null || event.pointerId !== activePointer) return;
const left = elements.controlRail.getBoundingClientRect().left;
setRailWidth(event.clientX - left, { persist: false });
});

const endResize = (event) => {
if (activePointer === null || (event && event.pointerId !== activePointer)) return;
if (elements.railSplitter.hasPointerCapture(activePointer)) {
elements.railSplitter.releasePointerCapture(activePointer);
}
activePointer = null;
document.body.classList.remove("is-resizing");
persistLayout();
};
elements.railSplitter.addEventListener("pointerup", endResize);
elements.railSplitter.addEventListener("pointercancel", endResize);

elements.railSplitter.addEventListener("dblclick", () => setRailWidth(RAIL_WIDTH_DEFAULT));

elements.railSplitter.addEventListener("keydown", (event) => {
const { min, max } = railWidthBounds();
const current = Math.round(elements.controlRail.getBoundingClientRect().width) || state.railWidth;
const next = {
ArrowLeft: current - RAIL_KEYBOARD_STEP,
ArrowRight: current + RAIL_KEYBOARD_STEP,
Home: min,
End: max,
}[event.key];
if (next === undefined) return;
event.preventDefault();
setRailWidth(next);
});
}

async function loadFile(file) {
Expand Down
37 changes: 34 additions & 3 deletions web-demo/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
</div>
</header>

<main class="workspace">
<aside class="control-rail" aria-label="Workbook controls">
<main id="workspace" class="workspace">
<aside id="controlRail" class="control-rail" aria-label="Workbook controls">
<section class="control-section source-section">
<div class="section-heading"><span>Workbook</span><span class="local-pill">Local only</span></div>
<div id="dropZone" class="drop-zone" role="button" tabindex="0">
Expand Down Expand Up @@ -167,9 +167,40 @@
</section>
</aside>

<div
id="railSplitter"
class="rail-splitter"
role="separator"
aria-orientation="vertical"
aria-label="Resize workbook controls"
aria-controls="controlRail"
aria-valuemin="260"
aria-valuemax="720"
aria-valuenow="380"
data-testid="rail-splitter"
tabindex="0"
></div>

<section class="preview-panel" aria-label="Workbook results">
<div class="preview-toolbar">
<div class="preview-title"><span id="resultEyebrow" class="eyebrow">Selected data</span><h1 id="previewTitle">Workbook preview</h1></div>
<div class="preview-heading">
<button
id="railToggleButton"
class="rail-toggle"
type="button"
aria-controls="controlRail"
aria-expanded="true"
aria-label="Toggle workbook controls"
title="Hide workbook controls"
data-testid="rail-toggle"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="2.75" y="4.75" width="18.5" height="14.5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6" />
<path d="M9.75 5v14" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
<div class="preview-title"><span id="resultEyebrow" class="eyebrow">Selected data</span><h1 id="previewTitle">Workbook preview</h1></div>
</div>
<div class="metrics" aria-label="Result metrics">
<div><strong id="metricRows">0</strong><span id="metricRowsLabel">rows</span></div>
<div><strong id="metricColumns">0</strong><span id="metricColumnsLabel">columns</span></div>
Expand Down
92 changes: 90 additions & 2 deletions web-demo/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,13 @@ input:disabled {
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
grid-template-columns: var(--rail-width, 380px) 14px minmax(0, 1fr);
padding: 16px;
gap: 16px;
gap: 0;
}

.workspace.is-collapsed {
grid-template-columns: minmax(0, 1fr);
}

.control-rail,
Expand All @@ -237,6 +241,45 @@ input:disabled {
overflow-y: auto;
}

.rail-splitter {
position: relative;
align-self: stretch;
min-height: 0;
cursor: col-resize;
touch-action: none;
}

.rail-splitter::before {
content: "";
position: absolute;
inset-block: 0;
left: 50%;
width: 2px;
transform: translateX(-50%);
border-radius: 1px;
background: var(--line-strong);
opacity: 0.5;
transition: background-color 160ms ease, opacity 160ms ease;
}

.rail-splitter:hover::before,
.rail-splitter:focus-visible::before,
body.is-resizing .rail-splitter::before {
background: var(--green);
opacity: 1;
}

.rail-splitter:focus-visible {
outline: 3px solid rgb(46 100 138 / 35%);
outline-offset: -2px;
}

body.is-resizing,
body.is-resizing * {
cursor: col-resize !important;
user-select: none !important;
}

.control-section {
padding: 18px;
border-bottom: 1px solid var(--line);
Expand Down Expand Up @@ -643,6 +686,43 @@ input:disabled {
border-bottom: 1px solid var(--line);
}

.preview-heading {
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
}

.rail-toggle {
width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
cursor: pointer;
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
}

.rail-toggle:hover {
color: var(--green);
background: var(--green-soft);
border-color: var(--green);
}

.rail-toggle[aria-expanded="false"] {
color: var(--muted);
background: var(--surface-soft);
}

.rail-toggle svg {
width: 19px;
height: 19px;
}

.preview-title {
min-width: 0;
}
Expand Down Expand Up @@ -916,6 +996,10 @@ input:disabled {
gap: 10px;
}

.rail-splitter {
display: none;
}

.control-rail {
overflow: visible;
}
Expand Down Expand Up @@ -990,6 +1074,10 @@ input:disabled {
padding: 14px;
}

.preview-heading {
width: 100%;
}

.preview-title {
width: 100%;
}
Expand Down
Loading
Loading