From 78b3b115816809eab35e0dcd71b626b6a173848f Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Wed, 24 Jun 2026 10:27:02 +0900 Subject: [PATCH] Release 1.1.4 (#15) --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 7 + .gitignore | 8 +- CHANGELOG.md | 29 ++ CLAUDE.md | 70 +++ CONTRIBUTING.md | 22 +- README.md | 107 ++-- docs/DEVELOPMENT.md | 158 ++++++ esbuild.config.mjs | 33 ++ manifest.json | 2 +- package-lock.json | 679 ++++++++++++++++++++++++++ package.json | 32 ++ scripts/validate.mjs | 3 +- main.js => src/main.ts | 301 +++++++++--- styles.css | 33 +- tests/e2e/fixtures/table-harness.html | 52 ++ tests/e2e/table-fullscreen.test.mjs | 107 ++++ tsconfig.json | 24 + versions.json | 3 +- 19 files changed, 1528 insertions(+), 151 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 esbuild.config.mjs create mode 100644 package-lock.json create mode 100644 package.json rename main.js => src/main.ts (73%) create mode 100644 tests/e2e/fixtures/table-harness.html create mode 100644 tests/e2e/table-fullscreen.test.mjs create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e22f4..49145c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,15 @@ jobs: uses: actions/setup-node@v6 with: node-version: "20" + cache: "npm" - - name: Syntax-check plugin source + - name: Install dependencies + run: npm ci + + - name: Type-check & build (tsc --noEmit + esbuild) + run: npm run build + + - name: Syntax-check bundled output run: node --check main.js - name: Validate manifest, versions & required files diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 228d470..52520c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,13 @@ jobs: uses: actions/setup-node@v6 with: node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Type-check & build (tsc --noEmit + esbuild) + run: npm run build - name: Validate run: | diff --git a/.gitignore b/.gitignore index b889856..f3b9b30 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,15 @@ .idea/ .vscode/ -# Node (only relevant if a build/tooling step is ever added) +# Node / build tooling node_modules/ npm-debug.log* +*.tsbuildinfo + +# Build output — main.js is bundled from src/ by esbuild (not committed). +# It is rebuilt in CI and attached to releases. Run `npm run build` locally. +main.js +main.js.map # Obsidian test vault artifacts data.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 48261f3..d0de329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 _Nothing yet._ +## [1.1.4] - 2026-06-24 + +### Fixed + +- Full-screen tables now match the inline view's styling. The clone is opened + outside the note's `.markdown-rendered` context, which dropped the theme's + table borders/padding/header background; it is now re-wrapped in that context + (with `display: contents` so the centering/scroll layout is unchanged). +- Diagram host overflow is now neutralized on the actual `.mermaid` wrapper (via + `closest('.mermaid')`), so the override applies even when the diagram svg is + nested or matched only by its `mermaid-*` id. No user-visible change in the + common case; removes a latent edge case. + +### Internal + +- Removed the remaining `!important` declarations in `styles.css`, overriding + Obsidian's styles by selector specificity instead. +- Migrated the project to **TypeScript + esbuild** (the official + `obsidian-sample-plugin` toolchain): source now lives in `src/main.ts` and is + bundled to `main.js` (a build artifact, no longer committed). CI and the + release workflow type-check (`tsc --noEmit`) and build before packaging. No + change to the shipped plugin's behavior. +- Followed Obsidian's code guidelines: icons are now built via the DOM + (`createElementNS`) instead of `innerHTML`. +- Added a headless-browser E2E test (`tests/e2e/`, `npm run test:e2e`) that + drives the bundled plugin and guards the full-screen table styling. +- Restructured docs: `README.md` is now user-facing; developer setup and + architecture moved to `docs/DEVELOPMENT.md`. + ## [1.1.3] - 2026-06-23 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5420e4c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Lookout is an **Obsidian plugin** for surveying wide content: pan/zoom Mermaid diagrams, fit-to-frame, and open diagrams or wide tables full-screen. It is written in **TypeScript** (`src/main.ts`) and bundled to `main.js` by **esbuild** (the official `obsidian-sample-plugin` toolchain). `main.js` is **build output** — gitignored, rebuilt in CI, attached to releases — so **never edit `main.js`; edit `src/main.ts` and `styles.css`.** Obsidian loads `main.js`/`manifest.json`/`styles.css` from the plugin folder **root**, so esbuild emits the bundle there and **there is no `dist/`**. `tsc` is used **only to type-check** (`--noEmit`); esbuild does the transpile/bundle. The only runtime dependency is `obsidian` (host-provided, marked `external`). + +## Commands + +```bash +npm ci # install dev deps (first time / CI) +npm run dev # esbuild watch: rebuild main.js on save (local dev) +npm run build # tsc --noEmit (type-check) + esbuild production bundle +npm run validate # manifest/versions consistency + required files +``` + +The full CI gate (run before every PR), on Node 20: + +```bash +npm ci +npm run build # type-check + bundle -> main.js +node --check main.js +node scripts/validate.mjs +``` + +There are no unit tests — behavioural verification is manual in a real vault (see below). `tsconfig.json` is `strict` with `strictPropertyInitialization` off (fields init in `_build()`); see `docs/DEVELOPMENT.md`. + +## Local development against a vault + +Obsidian runs the plugin from `/.obsidian/plugins/lookout/`. Symlink the repo there and run the watch build; reload the plugin (toggle off/on, or the *Reload app without saving* command) after each rebuild: + +```bash +ln -s "$(pwd)" /path/to/test-vault/.obsidian/plugins/lookout +npm run dev # rebuilds main.js on every save +``` + +A note containing a wide Mermaid diagram and a wide table exercises both features. **Always test in both Reading view and Live Preview** — they render content into different DOM containers and have historically diverged (see the table-processing guards in `src/main.ts`). + +## Architecture + +Everything lives in `src/main.ts` (~900 lines), three classes: + +- **`LookoutPlugin`** (the `default` export) — lifecycle and **discovery**. It finds rendered Mermaid ``s (`.mermaid svg, svg[id^="mermaid-"]`) and ``s anywhere in the document and wraps each in a view. Discovery is driven by several overlapping triggers because Mermaid renders asynchronously and Obsidian re-renders panes on navigation: `onLayoutReady`, workspace events (`layout-change`, `active-leaf-change`, `file-open`), a `registerMarkdownPostProcessor`, and a `MutationObserver` on `document.body`. All of these funnel through `queueScan()` (debounced ~120ms) → `scan()` → `scanWithin(root)` → `process()` / `processTable()`. +- **`DiagramView`** — one pan/zoom controller per diagram. The same class serves both the **inline** frame (wraps the live svg in place) and **full-screen** (wraps a *clone* in a fixed overlay). It owns the transform math: `_measure`, `fit`, `actualSize`, `zoomTo`/`zoomBy`, `_panBounds`/`_clampPan`, and `_render`. +- **`TableView`** — far simpler; tables keep native horizontal scroll and only gain a full-screen button. Full-screen clones the table into a maximized scroll overlay. + +### Idempotent processing (important invariant) + +Because discovery fires repeatedly on the same DOM, every processor must be **idempotent**. Each handled element is stamped with the `PROCESSED` attribute and re-skipped; processors also bail early if the element is already inside Lookout's own wrappers (`.lookout-viewport`, `.lookout-fs`, `.lookout-table-host`). When adding any new element discovery, preserve this stamp-and-skip pattern or scans will duplicate views. + +`processTable` carries hard-won guards: it enhances tables under `.markdown-rendered` **or** `.markdown-source-view` (Live Preview renders tables as a CM widget), but skips any table containing `[contenteditable="true"]` — that is the table the user is actively editing, whose DOM Obsidian owns. + +### DOM ownership and CSS specificity + +Lookout moves Obsidian-owned nodes (the svg, the table) into its own wrappers and must restore them on `destroy()`/`onunload`. When overriding Obsidian's built-in styles in `styles.css`, **win on selector specificity rather than `!important`** — e.g. qualify with the host class (`.mermaid.lookout-host`) or scope under a container. The codebase deliberately avoids `!important`. + +## Conventions + +- **Keep the dependency surface minimal.** `obsidian` is the only runtime dependency (host-provided, `external` — never bundled). Don't add runtime dependencies without a strong reason; dev dependencies stay limited to the TypeScript/esbuild toolchain. Type with the real Obsidian types, and keep `tsc --noEmit` (CI) green. +- **Follow Obsidian's code guidelines.** No `innerHTML`/`outerHTML` — build DOM nodes via the API (icons are constructed with `createElementNS` in `svgIcon()`). Clean up listeners/observers/timers on `destroy()`/`onunload()`. +- **Visual language is fixed:** quiet Obsidian theme surfaces (`var(--background-*)`, `var(--text-*)`) plus a single survey-cyan accent (`--lookout-accent`) reserved for focus and the active zoom gauge. Do not introduce new colors. Icons are inline lucide-style SVG (1.75px stroke) built via `svgIcon()`. +- User-facing strings (command names, `Notice` text) are in **Korean**. +- Respect `prefers-reduced-motion` (the `REDUCED_MOTION` flag / the reduced-motion media block) when adding animation. + +## Branching & releases + +- Default branch is **`dev`** — branch off it (`feat/*`, `fix/*`, `docs/*`, `chore/*`) and open PRs against `dev`, never `main`. Use Conventional Commits. +- `main` only receives merges from `dev` or `hotfix/*`. **Releases are automatic on merge to `main`**: the workflow reads `version` from `manifest.json` and pushes a bare-version tag (no `v` prefix — Obsidian convention). +- A version bump must touch three files in lockstep (validated by `scripts/validate.mjs`): `manifest.json` (`version`), `versions.json` (`"": ""`, which must equal `manifest.minAppVersion`), and `CHANGELOG.md` (move `Unreleased` notes into a dated section). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c48d13..ebd13bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,26 +67,31 @@ repository instead of a fork. ## Local development -Lookout ships as **plain CommonJS with no build step** — Obsidian loads -`main.js` directly. To develop against a real vault: +Lookout is written in **TypeScript** (`src/main.ts`) and bundled to `main.js` +by **esbuild**. To develop against a real vault: 1. Use a throwaway test vault (not your real notes). -2. Symlink (or copy) the repo into the vault's plugins folder: +2. Install dependencies: `npm ci`. +3. Symlink (or copy) the repo into the vault's plugins folder: ```bash ln -s "$(pwd)" /path/to/test-vault/.obsidian/plugins/lookout ``` -3. In Obsidian, enable **Lookout** under *Settings → Community plugins*. -4. Edit `main.js` / `styles.css`, then reload the plugin (toggle it off/on, or - use the *Reload app without saving* command) to see your changes. +4. Start the watch build: `npm run dev` (re-bundles `main.js` on every save). +5. In Obsidian, enable **Lookout** under *Settings → Community plugins*. +6. Edit `src/main.ts` / `styles.css`, then reload the plugin (toggle it off/on, + or use the *Reload app without saving* command) to see your changes. A note with a wide Mermaid diagram and a wide table is enough to exercise both -features in Reading view and Live Preview. +features in Reading view and Live Preview. See +[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full developer guide. ## Before you open a PR Run the same checks CI runs: ```bash +npm ci +npm run build # tsc --noEmit (type-check) + esbuild bundle node --check main.js node scripts/validate.mjs ``` @@ -95,7 +100,8 @@ Please also: - Keep the **drafting / survey-instrument** visual language (quiet Obsidian theme surfaces, a single survey-cyan accent). Avoid introducing new colors. -- Match the surrounding code style (plain CommonJS, no new dependencies). +- Follow Obsidian's code guidelines (no `innerHTML`; clean up on teardown) and + avoid adding runtime dependencies. - Update `README.md` / docs when behavior changes. ## Releasing (maintainers) diff --git a/README.md b/README.md index 129eb5c..18e158c 100644 --- a/README.md +++ b/README.md @@ -6,31 +6,47 @@ > Survey wide content instead of scrolling sideways. -Lookout is an [Obsidian](https://obsidian.md) plugin for reading **wide Mermaid diagrams and wide tables**. Instead of squinting through a note's narrow horizontal scrollbar, you can pan and zoom a diagram, fit it to the frame, or throw either a diagram or a table up full‑screen. +**Lookout** is an [Obsidian](https://obsidian.md) plugin for reading **wide +Mermaid diagrams and wide tables**. Instead of squinting through a note's narrow +horizontal scrollbar, pan and zoom a diagram, fit it to the frame, or throw +either a diagram or a table up full-screen. -The controls follow a quiet "drafting / survey instrument" visual language: they use Obsidian's own theme surfaces, with a single survey‑cyan accent reserved for focus and the active zoom gauge. +The controls follow a quiet *drafting / survey-instrument* visual language: they +borrow Obsidian's own theme surfaces, with a single survey-cyan accent reserved +for focus and the active zoom gauge. ## Features ### Mermaid diagrams -Each rendered Mermaid diagram gets an unobtrusive instrument toolbar (it fades in on hover): +Each rendered Mermaid diagram gets an unobtrusive instrument toolbar (it fades in +on hover): -- **Pan** — drag, or scroll with the mouse wheel (Shift maps a vertical wheel to horizontal). At an edge with nowhere to go, scrolling releases back to the page. -- **Zoom** — `Ctrl`/`Cmd` + wheel zooms toward the cursor, or use the `+` / `−` buttons. -- **Gauge** — a monospace readout shows the current zoom; click it to snap back to **100 %**, top‑left. +- **Pan** — drag, or scroll with the mouse wheel (Shift maps a vertical wheel to + horizontal). At an edge with nowhere to go, scrolling releases back to the page. +- **Zoom** — `Ctrl` / `Cmd` + wheel zooms toward the cursor, or use the `+` / `−` + buttons. +- **Gauge** — a monospace readout shows the current zoom; click it to snap back to + **100 %**, top-left. - **Fit to frame** — scales the whole diagram to the frame. -- **Full screen** — opens the diagram in a focused full‑screen canvas with the same controls. +- **Full screen** — opens the diagram in a focused full-screen canvas with the + same controls. -The inline frame hugs the diagram's natural (100 %) height, so a fully visible diagram has no dead space; tall diagrams are capped at 70 % of the window height and pan. +The inline frame hugs the diagram's natural (100 %) height, so a fully visible +diagram has no dead space; tall diagrams are capped at 70 % of the window height +and pan. ### Tables -Wide tables keep their normal horizontal scroll but gain a single **full‑screen** button (no zoom) pinned to the visible top‑right corner. Full screen shows the table in a maximized scroll area where its full width fits far better than in the note's narrow reading column. +Wide tables keep their normal horizontal scroll but gain a single **full-screen** +button (no zoom) pinned to the visible top-right corner. Full screen shows the +table in a maximized scroll area where its full width fits far better than in the +note's narrow reading column. -Tables are enhanced in both **Reading view** and **Live Preview**; the table you are actively editing is left untouched. +Tables are enhanced in both **Reading view** and **Live Preview**; the table you +are actively editing is left untouched. -## Keyboard +## Keyboard & commands When a diagram frame is focused (or in full screen): @@ -38,67 +54,46 @@ When a diagram frame is focused (or in full screen): | --- | --- | | `+` / `=` | Zoom in | | `−` / `_` | Zoom out | -| `0` | Reset to 100 %, top‑left | +| `0` | Reset to 100 %, top-left | | Arrow keys | Pan | | `F` | Open full screen (inline only) | | `Esc` | Close full screen | -A command, **"Open the active note's first Mermaid diagram full screen"**, is also available from the command palette and can be bound to a hotkey. +A command, **“Open the active note's first Mermaid diagram full screen”**, is +available from the command palette and can be bound to a hotkey. ## Installation +Lookout works on both desktop and mobile (Obsidian **1.0.0+**). + ### Manual -1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/Post-Math/Lookout/releases). -2. Create a folder `lookout` under your vault's `.obsidian/plugins/` directory and place the three files inside it. +1. Download `main.js`, `manifest.json`, and `styles.css` from the + [latest release](https://github.com/Post-Math/Lookout/releases). +2. Create a folder `lookout` under your vault's `.obsidian/plugins/` directory and + place the three files inside it. 3. Reload Obsidian and enable **Lookout** under *Settings → Community plugins*. -### BRAT - -Add `Post-Math/Lookout` as a beta plugin with the [BRAT](https://github.com/TfTHacker/obsidian42-brat) plugin. - -## Development - -Lookout ships as plain CommonJS with **no build step** — Obsidian loads `main.js` directly. To work on it, clone this repo (or symlink it) into a vault's `.obsidian/plugins/lookout/` folder and edit `main.js` / `styles.css` directly, then reload the plugin. - -``` -main.js # the plugin (DiagramView, TableView, LookoutPlugin) -styles.css # the drafting/survey-instrument UI -manifest.json # plugin metadata -versions.json # plugin version -> minimum Obsidian version -scripts/validate.mjs # CI checks (manifest/versions consistency, required files) -``` - -Run the same checks CI runs before opening a PR: - -```bash -node --check main.js -node scripts/validate.mjs -``` - -## Branching model - -Lookout uses a git-flow style model: - -- **`main`** — release / deployment. Always reflects the latest published release. Protected; only receives merges from `dev` (and `hotfix/*`). -- **`dev`** — integration branch and the **default branch**. The base for all day-to-day work. -- **`feat/*`, `fix/*`, `docs/*`, `chore/*`** — short-lived topic branches, branched off `dev` and merged back into `dev`. +### BRAT (beta) -``` -feat/my-change ──▶ dev ──▶ main ──▶ tag (e.g. 1.2.0) ──▶ Release -``` +Add `Post-Math/Lookout` as a beta plugin with the +[BRAT](https://github.com/TfTHacker/obsidian42-brat) plugin to track pre-release +builds. -Always branch off `dev` and open pull requests against `dev` — never `main`. +## Support -## Contributing +Found a bug or have an idea? Please use the +[issue tracker](https://github.com/Post-Math/Lookout/issues). For security +reports, see [SECURITY.md](SECURITY.md). -Contributions are welcome! External contributors work via a **fork** and open -pull requests against `dev`. A [code owner](.github/CODEOWNERS) review and a -green CI run are required before a PR can merge. +## Contributing & development -Please read **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full workflow, and our -**[Code of Conduct](CODE_OF_CONDUCT.md)**. For security issues, see -**[SECURITY.md](SECURITY.md)**. +Contributions are welcome! Start with **[CONTRIBUTING.md](CONTRIBUTING.md)** for +the workflow and our [Code of Conduct](CODE_OF_CONDUCT.md), and see +**[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)** for the local setup, the checks to +run, and an architecture overview. In short: Lookout is written in **TypeScript** +(`src/main.ts`) and bundled to `main.js` with **esbuild** — `npm ci`, then +`npm run dev` to watch-build while you edit. ## License diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..07eb8ad --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,158 @@ +# Development + +Technical guide for working on Lookout's code. For the contribution process +(branching, pull requests, releases) see **[CONTRIBUTING.md](../CONTRIBUTING.md)**; +for a deeper architectural orientation see **[CLAUDE.md](../CLAUDE.md)**. + +## Toolchain: TypeScript + esbuild + +Lookout is written in **TypeScript** and bundled to `main.js` by **esbuild**, +following the official `obsidian-sample-plugin` layout. The authored source lives +in `src/`; `main.js` is **build output** and is **not committed** (it is rebuilt +in CI and attached to releases). Obsidian loads `main.js`, `manifest.json`, and +`styles.css` directly from the plugin folder **root** — esbuild emits the bundle +there (`outfile: "main.js"`), so there is **no `dist/`**. + +`tsc` is used **only to type-check** (`--noEmit`); esbuild does the actual +transpile/bundle. The single runtime dependency is `obsidian`, which is provided +by the host and is marked `external` (never bundled). + +## Repository layout + +``` +src/main.ts # the entire plugin (DiagramView, TableView, LookoutPlugin) +styles.css # the drafting / survey-instrument UI +manifest.json # plugin metadata (id, version, minAppVersion, …) +versions.json # plugin version -> minimum Obsidian version +esbuild.config.mjs # bundles src/main.ts -> main.js (dev watch / production) +tsconfig.json # type-check config (strict, noEmit) +package.json # dev dependencies + scripts +scripts/validate.mjs # manifest/versions consistency + required-files check +tests/e2e/ # headless-browser E2E (drives the built main.js) +docs/ # developer docs +main.js # BUILD OUTPUT (gitignored) — do not edit +``` + +## Prerequisites + +- **Node 20+** (CI runs on Node 20). +- Install dev dependencies once: `npm ci` (or `npm install`). + +## Local development against a vault + +Obsidian runs the plugin from `/.obsidian/plugins/lookout/`. Symlink the +repo there, then start the watch build so edits to `src/` re-bundle `main.js`: + +```bash +ln -s "$(pwd)" /path/to/test-vault/.obsidian/plugins/lookout +npm run dev # esbuild watch: rebuilds main.js on every save +``` + +After each rebuild, reload the plugin in Obsidian (toggle it off/on, or run +*Reload app without saving*) to pick up the new `main.js`. Use a throwaway vault, +not your real notes. A note containing a **wide Mermaid diagram** and a **wide +table** exercises both features. Always test in **both Reading view and Live +Preview** — they render content into different DOM containers and have +historically diverged (see the table-processing guards in `src/main.ts`). + +## Scripts & the CI gate + +```bash +npm run dev # esbuild watch build (development) +npm run build # tsc --noEmit (type-check) + esbuild production bundle +npm run typecheck # tsc --noEmit only +npm run validate # manifest/versions consistency + required-files check +npm run test:e2e # headless browser E2E (see below) — run after `npm run build` +``` + +CI (and a pre-PR check) runs, on Node 20: + +```bash +npm ci +npm run build # type-check + bundle -> main.js +node --check main.js +node scripts/validate.mjs +``` + +There are no unit tests, but there is a headless-browser **E2E** check (see +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). + +```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 +``` + +It is not part of the CI `validate` job (no browser there); run it locally when +touching the view/teardown/DOM code or `styles.css`. + +### Type-checking + +`tsconfig.json` is `strict` (against the real `obsidian` types), with one +relaxation: **`strictPropertyInitialization` is off**, because the view classes +initialize their fields in `_build()` / `_buildToolbar()` rather than the +constructor body. `strictNullChecks`, `noImplicitAny`, etc. remain on. + +## Code guidelines (Obsidian) + +Keep changes within Obsidian's plugin guidelines: + +- **No `innerHTML` / `outerHTML`.** Build DOM nodes with the API. Icons are + constructed element-by-element in `svgIcon()` (`createElementNS`), not from + markup strings. +- **Clean up on teardown.** Plugin-level listeners go through `registerEvent`; + the view classes own their DOM listeners / observers / timers and remove them + in `destroy()`, which `onunload()` calls for every view. +- **Don't fight the theme.** Override Obsidian's styles by winning on selector + specificity, never `!important` (see `styles.css`). + +## Architecture (in brief) + +`src/main.ts` holds three classes: + +- **`LookoutPlugin`** (the `default` export) — lifecycle and **discovery**. + Finds rendered Mermaid ``s and `
`s and wraps each in a view. + Discovery is driven by several overlapping triggers (layout events, a markdown + post-processor, a `MutationObserver`) because Mermaid renders asynchronously; + they funnel through a debounced `queueScan() → scan() → process()/processTable()`. +- **`DiagramView`** — one pan/zoom controller per diagram; serves both the + inline frame and the full-screen overlay. Owns the transform math. +- **`TableView`** — thin; tables keep native scroll and only gain a full-screen + button. + +Two invariants to preserve when changing discovery: + +1. **Idempotent processing.** Discovery fires repeatedly on the same DOM. Each + handled element is stamped with a `PROCESSED` attribute and re-skipped, and + processors bail if the element is already inside Lookout's own wrappers. Keep + this stamp-and-skip pattern or scans will duplicate views. +2. **DOM ownership.** Lookout moves Obsidian-owned nodes (the svg, the table) + into its own wrappers and must restore them on `destroy()` / `onunload()`. + +See **[CLAUDE.md](../CLAUDE.md)** for the full version, including the +Live-Preview table guards and the CSS-specificity convention. + +## Branching & releases + +Summarised here; the authoritative version is in +**[CONTRIBUTING.md](../CONTRIBUTING.md)**. + +- Default branch is **`dev`** — branch off it (`feat/*`, `fix/*`, `docs/*`, + `chore/*`) and open PRs against `dev`, never `main`. Use Conventional Commits. +- `main` only receives merges from `dev` or `hotfix/*`. **Releases are automatic + on merge to `main`**: the workflow installs deps, builds `main.js`, reads + `version` from `manifest.json`, pushes a bare-version tag (no `v` prefix — + Obsidian convention), and uploads `main.js` / `manifest.json` / `styles.css`. +- A version bump touches three files in lockstep (checked by + `scripts/validate.mjs`): `manifest.json` (`version`) — keep `package.json`'s + `version` in sync — `versions.json` (`"": ""`, equal to + `manifest.minAppVersion`), and `CHANGELOG.md` (move `Unreleased` into a dated + section). diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..d10ceab --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,33 @@ +// esbuild bundles src/main.ts -> main.js at the repo root (Obsidian loads that +// file directly; there is no dist/). `node esbuild.config.mjs` watches for dev; +// `node esbuild.config.mjs production` does a one-shot minified build. +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = + "/*\n * Lookout — bundled output. Do not edit; edit the TypeScript source in\n * src/ and rebuild (see docs/DEVELOPMENT.md).\n */"; + +const production = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { js: banner }, + entryPoints: ["src/main.ts"], + bundle: true, + // Obsidian and Electron are provided by the host; never bundle them. + external: ["obsidian", "electron", ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: production ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: production, +}); + +if (production) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json index 9b6ac9c..9701346 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "lookout", "name": "Lookout", - "version": "1.1.3", + "version": "1.1.4", "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-lock.json b/package-lock.json new file mode 100644 index 0000000..28ba998 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,679 @@ +{ + "name": "lookout", + "version": "1.1.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lookout", + "version": "1.1.4", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.14.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.25.0", + "obsidian": "^1.6.0", + "playwright-core": "^1.48.0", + "tslib": "^2.6.0", + "typescript": "^5.6.3" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdb06cc --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "lookout", + "version": "1.1.4", + "description": "Survey wide content instead of scrolling sideways — pan/zoom Mermaid diagrams and open wide tables full-screen in Obsidian.", + "main": "main.js", + "private": true, + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production", + "typecheck": "tsc --noEmit --skipLibCheck", + "validate": "node scripts/validate.mjs", + "test:e2e": "node tests/e2e/table-fullscreen.test.mjs" + }, + "keywords": [ + "obsidian", + "obsidian-plugin", + "mermaid", + "diagram", + "table" + ], + "author": "Post-Math", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.14.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.25.0", + "obsidian": "^1.6.0", + "playwright-core": "^1.48.0", + "tslib": "^2.6.0", + "typescript": "^5.6.3" + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 45046a1..deefcde 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,6 +1,7 @@ // Lightweight, dependency-free repo validation for CI. // Checks required files exist, manifest.json / versions.json are valid and -// mutually consistent, and the version looks like x.y.z. No build step. +// mutually consistent, and the version looks like x.y.z. Run AFTER the build +// (`npm run build`) so the bundled main.js exists. import { readFileSync, existsSync } from "node:fs"; diff --git a/main.js b/src/main.ts similarity index 73% rename from main.js rename to src/main.ts index 1dd5760..87147ed 100644 --- a/main.js +++ b/src/main.ts @@ -5,68 +5,166 @@ * and full screen. Tables: a full-screen button so wide tables can be read * without squinting through the note's horizontal scrollbar (no zoom). * - * Plain CommonJS (no build step): Obsidian loads this file directly. - * The visual language is a "drafting / survey instrument": Obsidian theme - * surfaces with a single survey-cyan accent and a monospace zoom gauge. + * Authored in TypeScript; esbuild bundles this to main.js (see + * docs/DEVELOPMENT.md). The visual language is a "drafting / survey instrument": + * Obsidian theme surfaces with a single survey-cyan accent and a monospace gauge. */ -"use strict"; +import { Notice, Plugin } from "obsidian"; -const obsidian = require("obsidian"); - -const PAD = 24; // slack (px) so diagram edges can be panned just past the frame +const PAD = 24; // slack (px) so diagram edges can be panned just past the frame const MIN_SCALE = 0.1; const MAX_SCALE = 8; -const ZOOM_STEP = 1.2; // per button press +const ZOOM_STEP = 1.2; // per button press const INLINE_FLOOR = 56; // min inline frame height (px) — just enough for the toolbar /* ---- lucide-style icons, 1.75px stroke for a precise drafting feel ---- */ +type IconChild = [tag: string, attrs: Record]; + const ICONS = { - minus: '', - plus: '', - // "fit to frame": a frame with a horizontal double-arrow inside — distinct from the maximize glyph. - fit: '', + minus: [["line", { x1: "5", y1: "12", x2: "19", y2: "12" }]], + plus: [ + ["line", { x1: "5", y1: "12", x2: "19", y2: "12" }], + ["line", { x1: "12", y1: "5", x2: "12", y2: "19" }], + ], + // "fit to frame": a frame with a horizontal double-arrow inside. + fit: [ + ["rect", { x: "3", y: "5", width: "18", height: "14", rx: "2" }], + ["path", { d: "M7.5 12h9" }], + ["path", { d: "M10 9.5 7.5 12l2.5 2.5" }], + ["path", { d: "M14 9.5 16.5 12 14 14.5" }], + ], // "fullscreen": arrows pushing out to the four corners. - full: '', - close: '', -}; - -function svgIcon(name) { - return ( - '" - ); + full: [ + ["path", { d: "M8 3H5a2 2 0 0 0-2 2v3" }], + ["path", { d: "M21 8V5a2 2 0 0 0-2-2h-3" }], + ["path", { d: "M3 16v3a2 2 0 0 0 2 2h3" }], + ["path", { d: "M16 21h3a2 2 0 0 0 2-2v-3" }], + ], + close: [ + ["line", { x1: "18", y1: "6", x2: "6", y2: "18" }], + ["line", { x1: "6", y1: "6", x2: "18", y2: "18" }], + ], +} satisfies Record; + +type IconName = keyof typeof ICONS; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Build one of the {@link ICONS} glyphs as a sized, theme-coloured `` + * element. Constructed via the DOM (no `innerHTML`), per Obsidian's guidelines. + */ +function svgIcon(name: IconName): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + const attrs: Record = { + class: "lookout-ico", + viewBox: "0 0 24 24", + width: "16", + height: "16", + fill: "none", + stroke: "currentColor", + "stroke-width": "1.75", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }; + for (const [k, v] of Object.entries(attrs)) svg.setAttribute(k, v); + for (const [tag, childAttrs] of ICONS[name]) { + const child = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(childAttrs)) child.setAttribute(k, v); + svg.appendChild(child); + } + return svg; } -function el(tag, cls) { +/** Create an element with an optional class. Tag-typed so callers keep `.type`, `.disabled`, … */ +function el( + tag: K, + cls?: string +): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (cls) node.className = cls; return node; } -const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +/** Clamp `v` into the inclusive `[lo, hi]` range. */ +const clamp = (v: number, lo: number, hi: number): number => + Math.min(hi, Math.max(lo, v)); const REDUCED_MOTION = typeof window !== "undefined" && - window.matchMedia && + !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +type ViewMode = "actual" | "fit" | "free"; + +interface DragState { + id: number; + sx: number; + sy: number; + tx: number; + ty: number; +} + +interface DiagramViewOptions { + /** true for the full-screen clone, false/absent for the inline frame */ + fullscreen?: boolean; + /** called when a full-screen view closes */ + onClose?: (() => void) | null; + /** inline: the svg's original parent element */ + parent?: HTMLElement | null; + /** inline: node to insert the viewport before */ + anchor?: Node | null; +} + /** * One pan/zoom controller around a single Mermaid . * Used both inline (wraps the rendered svg in place) and in full-screen * (wraps a clone inside a fixed overlay). */ class DiagramView { - constructor(svg, options) { - options = options || {}; + svg: SVGSVGElement; + fs: boolean; + onClose: (() => void) | null; + parent: HTMLElement | null; + anchor: Node | null; + host: Element | null; + + scale: number; + tx: number; + ty: number; + minScale: number; + maxScale: number; + viewMode: ViewMode; + lastWidth: number; + + drag: DragState | null; + destroyed: boolean; + + nat!: { w: number; h: number }; + stage!: HTMLDivElement; + viewport!: HTMLDivElement; + overlay?: HTMLDivElement; + toolbar!: HTMLDivElement; + btnOut!: HTMLButtonElement; + btnIn!: HTMLButtonElement; + btnFit!: HTMLButtonElement; + gauge!: HTMLButtonElement; + gaugeNum!: HTMLSpanElement; + gaugeTrack!: HTMLSpanElement; + gaugeFill!: HTMLSpanElement; + ro?: ResizeObserver; + _animTimer?: number; + _fsView: DiagramView | null = null; + + constructor(svg: SVGSVGElement, options: DiagramViewOptions = {}) { this.svg = svg; this.fs = !!options.fullscreen; this.onClose = options.onClose || null; - this.parent = options.parent || null; // inline: original parent of the svg - this.anchor = options.anchor || null; // inline: node to insert the viewport before + this.parent = options.parent || null; // inline: original parent of the svg + this.anchor = options.anchor || null; // inline: node to insert the viewport before + this.host = null; // inline: the .mermaid wrapper we neutralize this.scale = 1; this.tx = 0; @@ -136,7 +234,10 @@ class DiagramView { ); this.viewport.tabIndex = 0; this.viewport.setAttribute("role", "group"); - this.viewport.setAttribute("aria-label", "Mermaid 다이어그램 — 드래그/스크롤로 이동, Ctrl+스크롤로 확대"); + this.viewport.setAttribute( + "aria-label", + "Mermaid 다이어그램 — 드래그/스크롤로 이동, Ctrl+스크롤로 확대" + ); this.stage.appendChild(this.svg); this.viewport.appendChild(this.stage); @@ -147,8 +248,15 @@ class DiagramView { document.body.appendChild(this.overlay); } else { // svg has been moved into the stage; place the viewport where it used to be. - this.parent.classList.add("lookout-host"); - this.parent.insertBefore(this.viewport, this.anchor); + // The clamp/center styles live on Obsidian's `.mermaid` wrapper, which is + // usually the svg's direct parent but can be an ancestor (the svg may be + // nested, or caught only by its `mermaid-*` id). Stamp whichever element + // actually carries `.mermaid` so `.mermaid.lookout-host` always matches; + // fall back to the parent when there is no `.mermaid` (nothing to clamp). + const parent = this.parent!; + this.host = parent.closest(".mermaid") || parent; + this.host.classList.add("lookout-host"); + parent.insertBefore(this.viewport, this.anchor); } this._buildToolbar(); @@ -159,13 +267,21 @@ class DiagramView { } _buildToolbar() { - const bar = el("div", "lookout-toolbar" + (this.fs ? " lookout-toolbar--fs" : "")); + const bar = el( + "div", + "lookout-toolbar" + (this.fs ? " lookout-toolbar--fs" : "") + ); bar.setAttribute("role", "toolbar"); - const mkBtn = (icon, label, handler, extraCls) => { + const mkBtn = ( + icon: IconName, + label: string, + handler: () => void, + extraCls?: string + ): HTMLButtonElement => { const b = el("button", "lookout-btn" + (extraCls ? " " + extraCls : "")); b.type = "button"; - b.innerHTML = svgIcon(icon); + b.appendChild(svgIcon(icon)); b.setAttribute("aria-label", label); b.title = label; b.addEventListener("click", (e) => { @@ -234,8 +350,7 @@ class DiagramView { } } - _scheduleInitialView(tries) { - tries = tries || 0; + _scheduleInitialView(tries = 0) { requestAnimationFrame(() => { if (this.destroyed) return; if (this.viewport.clientWidth > 0) { @@ -263,7 +378,7 @@ class DiagramView { } // Default / "100%" view: actual size, anchored top-left. - actualSize(animate) { + actualSize(animate: boolean) { this._setInlineHeight(); const vw = this.viewport.clientWidth; if (!vw) return; @@ -277,13 +392,13 @@ class DiagramView { } // "Fit to frame": scale so the whole diagram fits, centered. - fit(animate) { + fit(animate: boolean) { this._setInlineHeight(); const vw = this.viewport.clientWidth; const vh = this.viewport.clientHeight; if (!vw || !vh) return; - let s; + let s: number; if (this.fs) { // contain inside the overlay, never upscale past 1:1 s = Math.min(vw / this.nat.w, vh / this.nat.h); @@ -314,7 +429,7 @@ class DiagramView { const vh = this.viewport.clientHeight; const cw = this.nat.w * this.scale; const ch = this.nat.h * this.scale; - let txMin, txMax, tyMin, tyMax; + let txMin: number, txMax: number, tyMin: number, tyMax: number; if (cw <= vw) { txMin = txMax = (vw - cw) / 2; } else { @@ -337,7 +452,7 @@ class DiagramView { } /* ---------- zoom ---------- */ - zoomTo(newScale, cx, cy, animate) { + zoomTo(newScale: number, cx: number, cy: number, animate: boolean) { newScale = clamp(newScale, this.minScale, this.maxScale); const k = newScale / this.scale; this.tx = cx - (cx - this.tx) * k; @@ -347,7 +462,7 @@ class DiagramView { this._render(animate); } - zoomBy(factor) { + zoomBy(factor: number) { this.viewMode = "free"; this.zoomTo( this.scale * factor, @@ -358,7 +473,7 @@ class DiagramView { } /* ---------- input handlers ---------- */ - onWheel(e) { + onWheel(e: WheelEvent) { const rect = this.viewport.getBoundingClientRect(); if (e.ctrlKey || e.metaKey) { @@ -402,9 +517,10 @@ class DiagramView { } } - onPointerDown(e) { + onPointerDown(e: PointerEvent) { if (e.button !== 0) return; - if (e.target.closest && e.target.closest(".lookout-toolbar")) return; + const target = e.target as Element | null; + if (target && target.closest(".lookout-toolbar")) return; this.drag = { id: e.pointerId, sx: e.clientX, @@ -423,7 +539,7 @@ class DiagramView { this.viewport.addEventListener("pointercancel", this.onPointerUp); } - onPointerMove(e) { + onPointerMove(e: PointerEvent) { if (!this.drag || e.pointerId !== this.drag.id) return; this.viewMode = "free"; this.tx = this.drag.tx + (e.clientX - this.drag.sx); @@ -432,7 +548,7 @@ class DiagramView { this._render(false); } - onPointerUp(e) { + onPointerUp(e: PointerEvent) { if (!this.drag) return; this.viewport.classList.remove("is-dragging"); try { @@ -446,7 +562,7 @@ class DiagramView { this.drag = null; } - onKeyDown(e) { + onKeyDown(e: KeyboardEvent) { // Full-screen Esc is captured at document level. if (this.fs && e.key === "Escape") { e.preventDefault(); @@ -499,7 +615,7 @@ class DiagramView { } } - _nudge(dx, dy) { + _nudge(dx: number, dy: number) { this.viewMode = "free"; this.tx += dx; this.ty += dy; @@ -525,7 +641,7 @@ class DiagramView { } /* ---------- render ---------- */ - _render(animate) { + _render(animate: boolean) { if (animate && !REDUCED_MOTION) { this.stage.classList.add("is-animating"); window.clearTimeout(this._animTimer); @@ -553,7 +669,7 @@ class DiagramView { /* ---------- full-screen ---------- */ openFullscreen() { if (this._fsView) return; - const clone = this.svg.cloneNode(true); + const clone = this.svg.cloneNode(true) as SVGSVGElement; clone.style.width = ""; clone.style.height = ""; clone.style.maxWidth = ""; @@ -592,11 +708,18 @@ class DiagramView { if (this.parent && this.viewport.parentElement === this.parent) { this.parent.insertBefore(this.svg, this.viewport); this.viewport.remove(); - this.parent.classList.remove("lookout-host"); + if (this.host) this.host.classList.remove("lookout-host"); } } } +interface TableViewOptions { + /** the table's original parent element */ + parent: HTMLElement; + /** node to insert the host before */ + anchor: Node | null; +} + /** * Wide tables get a single full-screen button (no zoom). Inline, the table * keeps its normal horizontal scroll inside our own scroll wrapper so the @@ -605,8 +728,15 @@ class DiagramView { * than in the note's narrow reading column. */ class TableView { - constructor(table, options) { - options = options || {}; + table: HTMLTableElement; + parent: HTMLElement; + anchor: Node | null; + destroyed: boolean; + host!: HTMLDivElement; + scroll!: HTMLDivElement; + overlay: HTMLDivElement | null = null; + + constructor(table: HTMLTableElement, options: TableViewOptions) { this.table = table; this.parent = options.parent; this.anchor = options.anchor; @@ -626,7 +756,7 @@ class TableView { const btn = el("button", "lookout-btn lookout-table-btn"); btn.type = "button"; - btn.innerHTML = svgIcon("full"); + btn.appendChild(svgIcon("full")); btn.setAttribute("aria-label", "표를 전체 화면으로 보기"); btn.title = "전체 화면으로 보기"; btn.addEventListener("click", (e) => { @@ -639,33 +769,39 @@ class TableView { openFullscreen() { if (this.overlay) return; - this.overlay = el("div", "lookout-fs lookout-fs--table"); + const overlay = el("div", "lookout-fs lookout-fs--table"); + this.overlay = overlay; const scroll = el("div", "lookout-table-fs-scroll"); - const clone = this.table.cloneNode(true); + const clone = this.table.cloneNode(true) as HTMLTableElement; clone.classList.add("lookout-table-fs-table"); - scroll.appendChild(clone); - this.overlay.appendChild(scroll); + // The clone lives outside the note, so Obsidian's table styling (borders, + // padding, header) — which is scoped to `.markdown-rendered` — would not + // reach it. Wrap it in that context so full screen matches the inline view. + const mdContext = el("div", "markdown-rendered"); + mdContext.appendChild(clone); + scroll.appendChild(mdContext); + overlay.appendChild(scroll); const close = el("button", "lookout-btn lookout-fs-close"); close.type = "button"; - close.innerHTML = svgIcon("close"); + close.appendChild(svgIcon("close")); close.setAttribute("aria-label", "닫기"); close.title = "닫기 (Esc)"; close.addEventListener("click", this.onCloseFs); - this.overlay.appendChild(close); + overlay.appendChild(close); // Click on the empty backdrop (not the table) closes the view. - this.overlay.addEventListener("pointerdown", (e) => { - if (e.target === this.overlay || e.target === scroll) this.onCloseFs(); + overlay.addEventListener("pointerdown", (e) => { + if (e.target === overlay || e.target === scroll) this.onCloseFs(); }); - document.body.appendChild(this.overlay); + document.body.appendChild(overlay); document.addEventListener("keydown", this.onFsKeyDown, true); close.focus({ preventScroll: true }); } - onFsKeyDown(e) { + onFsKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault(); this.onCloseFs(); @@ -696,7 +832,11 @@ class TableView { * ===================================================================== */ const PROCESSED = "data-lookout"; -module.exports = class LookoutPlugin extends obsidian.Plugin { +export default class LookoutPlugin extends Plugin { + views!: Set; + observer?: MutationObserver; + private _scanQueued = false; + onload() { this.views = new Set(); this._scanQueued = false; @@ -715,7 +855,7 @@ module.exports = class LookoutPlugin extends obsidian.Plugin { // Mermaid renders asynchronously; catch svgs as they appear. this.observer = new MutationObserver((mutations) => { for (const m of mutations) { - for (const node of m.addedNodes) { + for (const node of Array.from(m.addedNodes)) { if (node instanceof HTMLElement || node instanceof SVGElement) { this.queueScan(); return; @@ -755,15 +895,17 @@ module.exports = class LookoutPlugin extends obsidian.Plugin { this.scanWithin(document.body); } - scanWithin(root) { + scanWithin(root: ParentNode | null) { if (!root || !root.querySelectorAll) return; - const svgs = root.querySelectorAll('.mermaid svg, svg[id^="mermaid-"]'); + const svgs = root.querySelectorAll( + '.mermaid svg, svg[id^="mermaid-"]' + ); svgs.forEach((svg) => this.process(svg)); const tables = root.querySelectorAll("table"); tables.forEach((table) => this.processTable(table)); } - process(svg) { + process(svg: SVGSVGElement) { if (svg.hasAttribute(PROCESSED)) return; if (svg.closest(".lookout-viewport") || svg.closest(".lookout-fs")) return; const parent = svg.parentElement; @@ -780,7 +922,7 @@ module.exports = class LookoutPlugin extends obsidian.Plugin { this.views.add(view); } - processTable(table) { + processTable(table: HTMLTableElement) { if (table.hasAttribute(PROCESSED)) return; if (table.closest(".lookout-table-host") || table.closest(".lookout-fs")) return; // Enhance rendered tables in a note — both reading view (.markdown-rendered) @@ -801,11 +943,16 @@ module.exports = class LookoutPlugin extends obsidian.Plugin { } fullscreenFirst() { - const view = [...this.views].find((v) => !v.fs && document.body.contains(v.viewport)); + const view = [...this.views].find( + (v): v is DiagramView => + v instanceof DiagramView && + !v.fs && + document.body.contains(v.viewport) + ); if (view) { view.openFullscreen(); } else { - new obsidian.Notice("이 노트에서 Mermaid 다이어그램을 찾지 못했습니다."); + new Notice("이 노트에서 Mermaid 다이어그램을 찾지 못했습니다."); } } -}; +} diff --git a/styles.css b/styles.css index 8021596..2462a4d 100644 --- a/styles.css +++ b/styles.css @@ -16,12 +16,21 @@ /* The host (Obsidian's .mermaid wrapper) should no longer clamp or scroll. */ .lookout-host { - overflow: visible !important; width: 100%; max-width: 100%; text-align: left; } +/* + * Obsidian's `.mermaid` wrapper sets `overflow` to clamp/scroll the diagram. + * The plugin stamps `.lookout-host` onto that `.mermaid` element (via + * `closest('.mermaid')`), so qualifying with `.mermaid` here adds our own class + * on top of Obsidian's `.mermaid` — enough specificity to win without `!important`. + */ +.mermaid.lookout-host { + overflow: visible; +} + /* ---------- viewport (the windowed frame over the diagram) ---------- */ .lookout-viewport { position: relative; @@ -299,10 +308,24 @@ justify-content: safe center; /* center small tables, scroll wide ones from the left */ } -.lookout-table-fs-table { - margin: 0 !important; - width: auto !important; - max-width: none !important; +/* + * The full-screen clone is wrapped in a `.markdown-rendered` context so it + * inherits the theme's table styling (matching the inline view). `display: + * contents` removes the wrapper's own box, keeping the centering/scroll layout + * on the table itself. + */ +.lookout-table-fs-scroll > .markdown-rendered { + display: contents; +} + +/* + * Scope under the scroll container so this two-class selector outranks + * Obsidian's single-class table styles — overriding them without !important. + */ +.lookout-table-fs-scroll .lookout-table-fs-table { + margin: 0; + width: auto; + max-width: none; height: max-content; } diff --git a/tests/e2e/fixtures/table-harness.html b/tests/e2e/fixtures/table-harness.html new file mode 100644 index 0000000..9cc2206 --- /dev/null +++ b/tests/e2e/fixtures/table-harness.html @@ -0,0 +1,52 @@ + + + + + + + + +
+
+ + + + + + + +
ABC
1twothree
4fivesix
+ + + diff --git a/tests/e2e/table-fullscreen.test.mjs b/tests/e2e/table-fullscreen.test.mjs new file mode 100644 index 0000000..8b0ffad --- /dev/null +++ b/tests/e2e/table-fullscreen.test.mjs @@ -0,0 +1,107 @@ +/* + * E2E regression test: the full-screen table must match the inline table's + * design (theme styling) and layout (centering / override). + * + * It drives the REAL bundled plugin (main.js) in a headless browser with a tiny + * `obsidian` stub, opens a table full screen, and compares computed styles + * against the inline table. + * + * 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 HARNESS = "file://" + resolve(here, "fixtures", "table-harness.html"); + +// Load the bundled plugin under a minimal `obsidian` stub and start it. +const bootstrap = ` +(function () { + 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); +}; + +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 }); + await page.goto(HARNESS); + await page.addStyleTag({ content: STYLES }); + await page.addScriptTag({ content: bootstrap }); + + // The plugin enhanced the table on load; open it full screen. + await page.waitForSelector(".lookout-table-btn"); + await page.click(".lookout-table-btn", { force: true }); + await page.waitForSelector(".lookout-table-fs-table"); + + const r = await page.evaluate(() => { + const g = (el, p) => (el ? getComputedStyle(el)[p] : null); + const cell = (el) => + el && { + borderTopWidth: g(el, "borderTopWidth"), + borderTopStyle: g(el, "borderTopStyle"), + paddingTop: g(el, "paddingTop"), + paddingLeft: g(el, "paddingLeft"), + }; + const inlineTd = document.querySelector(".lookout-table-scroll tbody td"); + const inlineTh = document.querySelector(".lookout-table-scroll thead th"); + const fsTd = document.querySelector(".lookout-table-fs-table tbody td"); + const fsTh = document.querySelector(".lookout-table-fs-table thead th"); + const fsTable = document.querySelector(".lookout-table-fs-table"); + const sc = document.querySelector(".lookout-table-fs-scroll"); + const tb = fsTable.getBoundingClientRect(); + const sb = sc.getBoundingClientRect(); + const cs = getComputedStyle(fsTable); + return { + inlineTd: cell(inlineTd), + fsTd: cell(fsTd), + inlineThBg: g(inlineTh, "backgroundColor"), + fsThBg: g(fsTh, "backgroundColor"), + overrideMargin: cs.marginTop, + overrideMaxWidth: cs.maxWidth, + tableVisible: tb.width > 0 && tb.height > 0, + leftGap: Math.round(tb.left - sb.left), + rightGap: Math.round(sb.right - tb.right), + }; + }); + + const sameCell = JSON.stringify(r.inlineTd) === JSON.stringify(r.fsTd); + check("cell border/padding matches inline", sameCell, `inline=${JSON.stringify(r.inlineTd)} fs=${JSON.stringify(r.fsTd)}`); + check("header background matches inline", r.inlineThBg === r.fsThBg, `inline=${r.inlineThBg} fs=${r.fsThBg}`); + check("override styles intact (margin 0, max-width none)", r.overrideMargin === "0px" && r.overrideMaxWidth === "none", `margin=${r.overrideMargin} maxWidth=${r.overrideMaxWidth}`); + check("full-screen table is visible", r.tableVisible); + check("small table stays centered", Math.abs(r.leftGap - r.rightGap) <= 2, `gaps ${r.leftGap}/${r.rightGap}`); +} 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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..071ebf0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + // Type-check only — esbuild does the actual transpile/bundle. `tsc --noEmit` + // (run in CI and `npm run build`) checks src/ against the real Obsidian types. + "compilerOptions": { + "baseUrl": ".", + "module": "ESNext", + "target": "ES2018", + "moduleResolution": "node", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["node"], + "importHelpers": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + + "strict": true, + // Fields are initialized in helper methods (_build/_buildToolbar), not the + // constructor body, so this would otherwise flag every one. Type safety + // (strictNullChecks, noImplicitAny, …) stays on. + "strictPropertyInitialization": false + }, + "include": ["src/**/*.ts"] +} diff --git a/versions.json b/versions.json index 3ca2977..9cb1104 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "1.1.1": "1.0.0", "1.1.2": "1.0.0", - "1.1.3": "1.0.0" + "1.1.3": "1.0.0", + "1.1.4": "1.0.0" }