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" }