Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 43 additions & 16 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>];
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down
163 changes: 163 additions & 0 deletions tests/e2e/diagram-fit.test.mjs
Original file line number Diff line number Diff line change
@@ -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.");
55 changes: 55 additions & 0 deletions tests/e2e/fixtures/diagram-harness.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!doctype html>
<!--
E2E fixture: a wide-AND-tall Mermaid <svg> (1600×1200) in a reading-column
container (Obsidian's `.markdown-rendered`). Used to verify that "fit to frame"
scales it down and the frame height matches the fitted content (no dead space,
no vertical scroll). The runner injects the real styles.css and the built
main.js (with an obsidian stub) on top of this.
-->
<html>
<head>
<meta charset="utf-8" />
<style>
/* Obsidian applies border-box app-wide; mirror it so the test exercises
the same box model the plugin runs under (the viewport's 1px border
must not eat into the content height the diagram is sized against). */
*, *::before, *::after { box-sizing: border-box; }
body {
--background-primary: #1e1e1f;
--background-secondary: #2a2a2c;
--background-modifier-border: #4a4a4c;
--background-modifier-hover: #333;
--text-normal: #dcddde;
--text-muted: #8a8f98;
--font-monospace: ui-monospace, monospace;
background: var(--background-primary);
color: var(--text-normal);
margin: 0;
}
.reading {
max-width: 700px;
}
.mermaid {
text-align: center;
overflow: auto;
}
</style>
</head>
<body>
<div class="markdown-rendered reading">
<p>Wide and tall Mermaid diagram below.</p>
<div class="mermaid">
<svg
id="mermaid-1"
viewBox="0 0 1600 1200"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="2" y="2" width="1596" height="1196" fill="#22325a" stroke="#88aaff" stroke-width="3" />
<line x1="0" y1="0" x2="1600" y2="1200" stroke="#88aaff" stroke-width="3" />
<line x1="1600" y1="0" x2="0" y2="1200" stroke="#88aaff" stroke-width="3" />
<circle cx="800" cy="600" r="120" fill="#0e9cc4" />
</svg>
</div>
</div>
</body>
</html>
Loading