From fe50ef12988067f580da2004754cb454bbd23054 Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Wed, 24 Jun 2026 15:58:31 +0900 Subject: [PATCH] fix: contain inline Mermaid "fit to frame" to the frame height (no vertical scroll) Inline "fit to frame" scaled the diagram to the frame width only while the frame height stayed at the diagram's 100% height, so the frame and content heights did not match: a tall diagram (e.g. a long sequenceDiagram) overflowed the 70vh frame and showed a vertical scroll, and a wide one had dead space. Fit now contains the diagram within both the frame width and the 70vh height cap and sizes the frame to that fitted height, so frame and content match. Also compensate for Obsidian's app-wide `box-sizing: border-box`, whose 1px viewport border had eaten into the content box and left a faint 1-2px vertical scroll that only reproduced under border-box (not in a content-box harness). Adds a diagram-fit E2E regression test under a border-box harness and bumps to 1.1.7 (manifest/versions/CHANGELOG). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 +++ docs/DEVELOPMENT.md | 18 +- manifest.json | 2 +- package.json | 4 +- src/main.ts | 59 +++++-- tests/e2e/diagram-fit.test.mjs | 163 +++++++++++++++++++ tests/e2e/fixtures/diagram-harness.html | 55 +++++++ tests/e2e/fixtures/diagram-tall-harness.html | 60 +++++++ versions.json | 3 +- 9 files changed, 359 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/diagram-fit.test.mjs create mode 100644 tests/e2e/fixtures/diagram-harness.html create mode 100644 tests/e2e/fixtures/diagram-tall-harness.html diff --git a/CHANGELOG.md b/CHANGELOG.md index dccd9e9..4709b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.7] - 2026-06-24 + +### Fixed + +- "Fit to frame" on an inline Mermaid diagram no longer leaves a vertical scroll + or dead space. Previously fit scaled the diagram to the frame **width** while + the frame height stayed at the diagram's 100% height, so the frame and the + content heights did not match: a tall diagram (e.g. a long `sequenceDiagram`) + overflowed and scrolled, and a wide one had dead space above/below. Fit now + contains the diagram within both the frame width and the 70vh height cap and + sizes the frame to that fitted height, so frame and content match exactly. The + default inline view stays at 100% (actual size); fit and full screen are how a + tall diagram is seen whole. +- The fitted frame height is now exact under Obsidian's app-wide + `box-sizing: border-box`. The viewport's 1px border had been eating into the + content box, leaving the diagram 1–2px taller than the frame — a faint + vertical scroll visible in Obsidian but not in a `content-box` test harness. + The frame now compensates for the border so the content box matches the + fitted diagram precisely. + ## [1.1.6] - 2026-06-24 ### Fixed diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 55ccdca..7c2e097 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -94,15 +94,21 @@ below). Beyond that, behavioural verification is manual in a vault. ### E2E tests `tests/e2e/` drives the **real bundled `main.js`** in headless Chromium under a -tiny `obsidian` stub, then asserts rendered behavior. `table-fullscreen.test.mjs` -guards a real regression: the full-screen table must inherit the same theme -styling and layout as the inline view (it lives outside the note's -`.markdown-rendered` context, so the clone is re-wrapped in one). +tiny `obsidian` stub, then asserts rendered behavior. Each case guards a real +regression: + +- `table-fullscreen.test.mjs` — the full-screen table must inherit the same + theme styling and layout as the inline view (it lives outside the note's + `.markdown-rendered` context, so the clone is re-wrapped in one). +- `diagram-fit.test.mjs` — "fit to frame" on an inline Mermaid diagram must size + the frame to the fitted content, so a tall diagram (e.g. a long + `sequenceDiagram`) shows whole with no vertical scroll, while the default view + stays at 100%. ```bash npx playwright install chromium # one-time (or set CHROMIUM_PATH to a binary) -npm run build # the test loads the built main.js -npm run test:e2e +npm run build # the tests load the built main.js +npm run test:e2e # both cases (test:e2e:table / test:e2e:diagram run one) ``` It is not part of the CI `validate` job (no browser there); run it locally when diff --git a/manifest.json b/manifest.json index 03cf03b..e4bce76 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "lookout", "name": "Lookout", - "version": "1.1.6", + "version": "1.1.7", "minAppVersion": "1.0.0", "description": "Survey wide content instead of scrolling sideways. Pan and zoom Mermaid diagrams (wheel, Ctrl+wheel, or buttons), fit them to the frame, and open diagrams or wide tables full-screen.", "author": "Post-Math", diff --git a/package.json b/package.json index 90cd4dc..0a0969c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "typecheck": "tsc --noEmit --skipLibCheck", "lint": "eslint \"src/**/*.ts\"", "validate": "node scripts/validate.mjs", - "test:e2e": "node tests/e2e/table-fullscreen.test.mjs" + "test:e2e": "npm run test:e2e:table && npm run test:e2e:diagram", + "test:e2e:table": "node tests/e2e/table-fullscreen.test.mjs", + "test:e2e:diagram": "node tests/e2e/diagram-fit.test.mjs" }, "keywords": [ "obsidian", diff --git a/src/main.ts b/src/main.ts index c36ee88..59225e9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ const MIN_SCALE = 0.1; const MAX_SCALE = 8; const ZOOM_STEP = 1.2; // per button press const INLINE_FLOOR = 56; // min inline frame height (px) — just enough for the toolbar +const INLINE_MAX_VH = 0.7; // inline frame caps at 70% of the viewport height /* ---- lucide-style icons, 1.75px stroke for a precise drafting feel ---- */ type IconChild = [tag: string, attrs: Record]; @@ -356,17 +357,35 @@ class DiagramView { } /* ---------- layout ---------- */ - // Frame height is based on the diagram's natural height (the 100% basis), - // so it stays stable whether the content is shown at 100% or fit-to-frame. - // The frame hugs the diagram's 100% height so a fully visible diagram has no - // dead space: shorter diagrams shrink the frame, taller ones are capped at - // 70vh and pan. INLINE_FLOOR only keeps the toolbar usable — a diagram - // shorter than it simply sits smaller than the frame (acceptable). - _setInlineHeight() { + // Frame height tracks the *displayed* diagram height (natural height × the + // current scale), rounded UP so the frame is never a sub-pixel shorter than + // the diagram — which would surface as a slight vertical scroll. At 100% + // (scale = 1) this is the natural height, capped at 70vh (taller diagrams + // then pan); in "fit" the scale is contained so the whole diagram sits inside + // the frame with the heights matching exactly. INLINE_FLOOR keeps the toolbar + // usable — a diagram shorter than it simply sits smaller (acceptable). + _setInlineHeight(scale = 1) { if (this.fs) return; - const maxH = Math.round(window.innerHeight * 0.7); - const h = clamp(this.nat.h, INLINE_FLOOR, maxH); - this.viewport.setCssStyles({ height: Math.round(h) + "px" }); + const target = Math.ceil( + clamp(this.nat.h * scale, INLINE_FLOOR, this._inlineMaxHeight()) + ); + this.viewport.setCssStyles({ height: target + "px" }); + // Obsidian applies `box-sizing: border-box` app-wide, so the viewport's 1px + // border eats into the content box: clientHeight comes back short and the + // diagram (sized against `target`) overflows by a pixel or two. Add the + // shortfall back so the *content* box is exactly `target`, regardless of the + // inherited box-sizing. + const shortfall = target - this.viewport.clientHeight; + if (shortfall > 0) { + this.viewport.setCssStyles({ height: target + shortfall + "px" }); + } + } + + // The inline frame's height ceiling (70vh) — a fixed fraction of the window, + // not the live viewport height, so `fit` can contain a tall diagram against + // it without the chicken-and-egg of resizing the frame it is measuring. + _inlineMaxHeight() { + return Math.round(window.innerHeight * INLINE_MAX_VH); } // Default / "100%" view: actual size, anchored top-left. @@ -383,23 +402,31 @@ class DiagramView { this._render(animate); } - // "Fit to frame": scale so the whole diagram fits, centered. + // "Fit to frame": scale so the whole diagram fits, centered. Inline, contain + // the diagram inside the frame width AND the 70vh height cap, then size the + // frame to that fitted height so the frame and the content match exactly — + // no vertical scroll, even for a tall (e.g. sequenceDiagram) shape. Full + // screen contains the diagram inside the overlay. fit(animate: boolean) { - this._setInlineHeight(); const vw = this.viewport.clientWidth; - const vh = this.viewport.clientHeight; - if (!vw || !vh) return; + if (!vw) return; let s: number; if (this.fs) { + const vh = this.viewport.clientHeight; + if (!vh) return; // contain inside the overlay, never upscale past 1:1 s = Math.min(vw / this.nat.w, vh / this.nat.h); } else { - // fit to width - s = vw / this.nat.w; + // contain inside the frame width and the 70vh height cap — the height + // term is what keeps a tall diagram from overflowing the frame. + s = Math.min(vw / this.nat.w, this._inlineMaxHeight() / this.nat.h); } s = clamp(Math.min(s, 1), this.minScale, this.maxScale); this.scale = s; + // Inline: size the frame to the fitted height (before centering, which + // reads the new height) so frame and content heights match. + if (!this.fs) this._setInlineHeight(s); this._center(); this.viewMode = "fit"; this.lastWidth = vw; diff --git a/tests/e2e/diagram-fit.test.mjs b/tests/e2e/diagram-fit.test.mjs new file mode 100644 index 0000000..fea165d --- /dev/null +++ b/tests/e2e/diagram-fit.test.mjs @@ -0,0 +1,163 @@ +/* + * E2E regression test for "fit to frame" on an inline Mermaid diagram (1.1.7). + * + * The default inline view opens at 100% (actual size, top-left); a tall diagram + * legitimately overflows and pans there. The "fit" toolbar button must show the + * WHOLE diagram with the frame height matching the fitted content — NO vertical + * scroll — for both overflow shapes: + * · wide-and-tall (diagram-harness.html, 1600×1200) — width-bound + * · tall-and-narrow (diagram-tall-harness.html, 600×1900) — a sequenceDiagram + * shape whose width already fits the column (the reported regression). + * + * It drives the REAL bundled plugin (main.js) in a headless browser with a tiny + * `obsidian` stub, exactly like table-fullscreen.test.mjs. + * + * Run: npm run build && npm run test:e2e + * Needs a Chromium once: npx playwright install chromium + * (or set CHROMIUM_PATH to an existing binary) + */ +import { chromium } from "playwright-core"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "..", ".."); +const MAIN = readFileSync(resolve(root, "main.js"), "utf8"); +const STYLES = readFileSync(resolve(root, "styles.css"), "utf8"); +const fixture = (name) => "file://" + resolve(here, "fixtures", name); +const WIDE_TALL = fixture("diagram-harness.html"); +const TALL_NARROW = fixture("diagram-tall-harness.html"); + +// Load the bundled plugin under a minimal `obsidian` stub and start it. +const bootstrap = ` +(function () { + // Obsidian runtime augmentations the bundle relies on (absent outside Obsidian). + globalThis.activeWindow = window; + globalThis.activeDocument = document; + const _setCssStyles = function (styles) { Object.assign(this.style, styles); }; + const _setCssProps = function (props) { for (const k in props) this.style.setProperty(k, props[k]); }; + HTMLElement.prototype.setCssStyles = _setCssStyles; + HTMLElement.prototype.setCssProps = _setCssProps; + SVGElement.prototype.setCssStyles = _setCssStyles; + SVGElement.prototype.setCssProps = _setCssProps; + Node.prototype.instanceOf = function (t) { return this instanceof t; }; + const __obsidian = { + Plugin: class { constructor(app){ this.app = app; } registerEvent(){} registerMarkdownPostProcessor(){} addCommand(){} }, + Notice: class { constructor(m){ this.message = m; } }, + }; + const module = { exports: {} }; + const exports = module.exports; + const require = (id) => { if (id === "obsidian") return __obsidian; throw new Error("unknown module: " + id); }; +${MAIN} + const LookoutPlugin = module.exports.default || module.exports; + const app = { workspace: { onLayoutReady: (cb) => cb(), on: () => ({}) } }; + const plugin = new LookoutPlugin(app); + plugin.onload(); +})(); +`; + +const failures = []; +const check = (name, cond, detail) => { + console.log(`${cond ? "PASS" : "FAIL"} ${name}${detail ? " " + detail : ""}`); + if (!cond) failures.push(name); +}; + +// Measure the inline viewport (the frame) against the stage (the transformed +// diagram). clientWidth/Height exclude the viewport's 1px border — the content +// box the diagram scrolls within — so comparisons stay border-agnostic. When +// the stage is taller than the content box, the diagram pans (vertical scroll). +const SAMPLE = `(() => { + const vp = document.querySelector(".lookout-viewport:not(.lookout-viewport--fs)"); + const stage = document.querySelector(".lookout-viewport:not(.lookout-viewport--fs) .lookout-stage"); + const num = document.querySelector(".lookout-viewport:not(.lookout-viewport--fs) .lookout-gauge-num"); + const stR = stage.getBoundingClientRect(); + return { + vpW: vp.clientWidth, vpH: vp.clientHeight, + stageW: stR.width, stageH: stR.height, + pct: parseInt(num.textContent, 10), + }; +})()`; + +const inline = (sel) => `.lookout-viewport:not(.lookout-viewport--fs) ${sel}`; + +// Open a fixture and wait until the inline view has wrapped the svg and applied +// its initial layout. The default view is 100% (identity transform, which +// computes to "none"), so we wait on the frame height being set instead. +async function open(page, harness) { + await page.goto(harness); + await page.addStyleTag({ content: STYLES }); + await page.addScriptTag({ content: bootstrap }); + await page.waitForSelector(inline(".lookout-stage")); + await page.waitForFunction(() => { + const vp = document.querySelector( + ".lookout-viewport:not(.lookout-viewport--fs)" + ); + return vp && vp.style.height && parseFloat(vp.style.height) > 0; + }); +} + +// Click the "fit to frame" toolbar button and let its transition settle. +async function clickFit(page) { + await page.click(inline('[aria-label="프레임에 맞추기"]'), { force: true }); + await page.waitForTimeout(280); +} + +const exe = process.env.CHROMIUM_PATH; +const browser = await chromium.launch(exe ? { executablePath: exe } : {}); +try { + const page = await browser.newPage(); + await page.setViewportSize({ width: 1280, height: 800 }); + const cap = Math.round(800 * 0.7); // inline 70vh height cap at this window size + + // ===== Case A: wide-and-tall diagram ===== + await open(page, WIDE_TALL); + const aLoad = await page.evaluate(SAMPLE); + check("wide+tall: default inline view is 100%", aLoad.pct === 100, `gauge=${aLoad.pct}%`); + + await clickFit(page); + const aFit = await page.evaluate(SAMPLE); + check("wide+tall: fit scales below 100%", aFit.pct < 100, `gauge=${aFit.pct}%`); + check( + "wide+tall: fit frame height matches content (no vertical scroll)", + aFit.stageH <= aFit.vpH + 1, + `stageH=${aFit.stageH.toFixed(1)} vpH=${aFit.vpH.toFixed(1)}` + ); + check( + "wide+tall: fit fits the frame width too", + aFit.stageW <= aFit.vpW + 1, + `stageW=${aFit.stageW.toFixed(1)} vpW=${aFit.vpW.toFixed(1)}` + ); + + // ===== Case B: tall-and-narrow diagram (the reported regression) ===== + await open(page, TALL_NARROW); + const bLoad = await page.evaluate(SAMPLE); + check("tall+narrow: default inline view is 100%", bLoad.pct === 100, `gauge=${bLoad.pct}%`); + check( + "tall+narrow: at 100% the tall diagram overflows (pan available)", + bLoad.stageH > bLoad.vpH + 1, + `stageH=${bLoad.stageH.toFixed(1)} vpH=${bLoad.vpH.toFixed(1)}` + ); + + await clickFit(page); + const bFit = await page.evaluate(SAMPLE); + check("tall+narrow: fit scales below 100%", bFit.pct < 100, `gauge=${bFit.pct}%`); + check( + "tall+narrow: fit has NO vertical scroll despite tall shape (stage ≤ frame)", + bFit.stageH <= bFit.vpH + 1, + `stageH=${bFit.stageH.toFixed(1)} vpH=${bFit.vpH.toFixed(1)}` + ); + check( + "tall+narrow: fit frame stays within the 70vh cap", + bFit.vpH <= cap + 1, + `vpH=${bFit.vpH.toFixed(1)} cap=${cap}` + ); +} finally { + await browser.close(); +} + +if (failures.length) { + console.error(`\n${failures.length} check(s) failed.`); + process.exit(1); +} +console.log("\nAll E2E checks passed."); diff --git a/tests/e2e/fixtures/diagram-harness.html b/tests/e2e/fixtures/diagram-harness.html new file mode 100644 index 0000000..9a25cbb --- /dev/null +++ b/tests/e2e/fixtures/diagram-harness.html @@ -0,0 +1,55 @@ + + + + + + + + +
+

Wide and tall Mermaid diagram below.

+
+ + + + + + +
+
+ + diff --git a/tests/e2e/fixtures/diagram-tall-harness.html b/tests/e2e/fixtures/diagram-tall-harness.html new file mode 100644 index 0000000..a74f61b --- /dev/null +++ b/tests/e2e/fixtures/diagram-tall-harness.html @@ -0,0 +1,60 @@ + + + + + + + + +
+

Tall, narrow sequence-diagram-shaped Mermaid below.

+
+ + + + + + + +
+
+ + diff --git a/versions.json b/versions.json index c5e77da..d95db98 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "1.1.3": "1.0.0", "1.1.4": "1.0.0", "1.1.5": "1.0.0", - "1.1.6": "1.0.0" + "1.1.6": "1.0.0", + "1.1.7": "1.0.0" }