Full Code of vslinko/obsidian-zoom for AI

main e5bbe79cbcb8 cached
67 files
92.3 KB
26.0k tokens
210 symbols
1 requests
Download .txt
Repository: vslinko/obsidian-zoom
Branch: main
Commit: e5bbe79cbcb8
Files: 67
Total size: 92.3 KB

Directory structure:
gitextract_snxu8gyu/

├── .eslintrc.js
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   └── bug_report.md
│   └── workflows/
│       └── build.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .prettierrc.json
├── LICENSE
├── README.md
├── babel.config.js
├── jest/
│   ├── global-setup.js
│   ├── global-teardown.js
│   ├── md-spec-transformer.js
│   ├── obsidian-environment.js
│   ├── obsidian-expect.js
│   └── test-globals.d.ts
├── jest.config.json
├── manifest.json
├── package.json
├── release.mjs
├── rollup.config.mjs
├── specs/
│   ├── LimitSelectionFeature.spec.md
│   ├── ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md
│   └── ZoomFeature.spec.md
├── src/
│   ├── ObsidianZoomPlugin.ts
│   ├── ObsidianZoomPluginWithTests.ts
│   ├── features/
│   │   ├── Feature.ts
│   │   ├── HeaderNavigationFeature.ts
│   │   ├── LimitSelectionFeature.ts
│   │   ├── ListsStylesFeature.ts
│   │   ├── ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts
│   │   ├── SettingsTabFeature.ts
│   │   ├── ZoomFeature.ts
│   │   ├── ZoomOnClickFeature.ts
│   │   └── utils/
│   │       ├── getDocumentTitle.ts
│   │       ├── getEditorViewFromEditorState.ts
│   │       └── isFoldingEnabled.ts
│   ├── logic/
│   │   ├── CalculateRangeForZooming.ts
│   │   ├── CollectBreadcrumbs.ts
│   │   ├── DetectClickOnBullet.ts
│   │   ├── DetectRangeBeforeVisibleRangeChanged.ts
│   │   ├── DetectVisibleContentBoundariesViolation.ts
│   │   ├── KeepOnlyZoomedContentVisible.ts
│   │   ├── LimitSelectionOnZoomingIn.ts
│   │   ├── LimitSelectionWhenZoomedIn.ts
│   │   ├── RenderNavigationHeader.ts
│   │   ├── __tests__/
│   │   │   ├── CalculateRangeForZooming.test.ts
│   │   │   ├── CollectBreadcrumbs.test.ts
│   │   │   └── DetectClickOnBullet.test.ts
│   │   └── utils/
│   │       ├── __tests__/
│   │       │   ├── calculateLimitedSelection.test.ts
│   │       │   ├── calculateVisibleContentBoundariesViolation.test.ts
│   │       │   ├── cleanTitle.test.ts
│   │       │   ├── rangeSetToArray.test.ts
│   │       │   └── renderHeader.test.ts
│   │       ├── calculateLimitedSelection.ts
│   │       ├── calculateVisibleContentBoundariesViolation.ts
│   │       ├── cleanTitle.ts
│   │       ├── effects.ts
│   │       ├── isBulletPoint.ts
│   │       ├── rangeSetToArray.ts
│   │       └── renderHeader.ts
│   ├── services/
│   │   ├── LoggerService.ts
│   │   └── SettingsService.ts
│   └── utils/
│       └── getEditorViewFromEditor.ts
├── styles.css
├── tsconfig.json
└── versions.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintrc.js
================================================
module.exports = {
  env: {
    browser: true,
    es2021: true,
  },
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier",
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 13,
    sourceType: "module",
  },
  plugins: ["@typescript-eslint"],
  rules: {
    "@typescript-eslint/no-empty-function": 0,
  },
};


================================================
FILE: .github/FUNDING.yml
================================================
custom: ["https://vslinko.cb.id/"]


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ""
---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:

1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Environment (please complete the following information):**
 - OS: [e.g. Desktop, iOS, Android]
 - Obsidian Version: [e.g. 1.1.16]
 - Plugin Version: [e.g. 1.1.1]

**Additional context**
Add any other context about the problem here.

================================================
FILE: .github/workflows/build.yml
================================================
name: Build

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  release:
    types:
      - created

env:
  OBSIDIAN_VERSION: "1.1.16"
  OBSIDIAN_FLATPAK_COMMIT: f885ddeab17171e10486bce93b83e84494614cf654e8c70a237f5294b05b4c55

jobs:
  lint:
    runs-on: ubuntu-22.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm ci
      - name: Lint
        run: |
          npm run lint
  test-on-linux:
    runs-on: ubuntu-22.04
    steps:
      - name: Install Obsidian
        run: |
          sudo apt update
          sudo apt install flatpak dbus-x11 xvfb
          flatpak remote-add --user flathub https://flathub.org/repo/flathub.flatpakrepo
          flatpak install --user -y flathub md.obsidian.Obsidian
          flatpak update --user -y --commit=$OBSIDIAN_FLATPAK_COMMIT md.obsidian.Obsidian
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm ci
      - name: Test
        run: |
          npm run build-with-tests
          Xvfb -ac :0 -screen 0 1280x1024x16 &
          export DISPLAY=:0
          export $(dbus-launch)
          npm test
  test-on-osx:
    runs-on: macos-12
    steps:
      - name: Install Obsidian
        run: |
          wget -q https://github.com/obsidianmd/obsidian-releases/releases/download/v$OBSIDIAN_VERSION/Obsidian-$OBSIDIAN_VERSION-universal.dmg
          sudo hdiutil attach Obsidian-$OBSIDIAN_VERSION-universal.dmg
          sudo cp -rf "/Volumes/Obsidian $OBSIDIAN_VERSION-universal/Obsidian.app" /Applications
          sudo hdiutil detach "/Volumes/Obsidian $OBSIDIAN_VERSION-universal"
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm ci
      - name: Test
        run: |
          npm run build-with-tests
          npm test
  release:
    if: ${{ github.event_name == 'release' }}
    runs-on: ubuntu-22.04
    needs: [lint, test-on-linux, test-on-osx]
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Upload main.js
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ github.event.release.upload_url }}
          asset_path: main.js
          asset_name: main.js
          asset_content_type: text/javascript
      - name: Upload manifest.json
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ github.event.release.upload_url }}
          asset_path: manifest.json
          asset_name: manifest.json
          asset_content_type: application/json
      - name: Upload styles.css
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ github.event.release.upload_url }}
          asset_path: styles.css
          asset_name: styles.css
          asset_content_type: text/css


================================================
FILE: .gitignore
================================================
/node_modules/
/vault/
/main.js


================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run lint


================================================
FILE: .prettierrc.json
================================================
{
  "importOrder": [
    "^obsidian$",
    "^@codemirror/.*$",
    "<THIRD_PARTY_MODULES>",
    "^\\./",
    "^\\.\\./"
  ],
  "importOrderSeparation": true,
  "importOrderSortSpecifiers": true
}


================================================
FILE: LICENSE
================================================
Copyright (c) 2021 Viacheslav Slinko

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Obsidian Zoom

![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vslinko/obsidian-zoom/release.yml?style=for-the-badge)
![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/vslinko/obsidian-zoom?style=for-the-badge&sort=semver)

**Zoom into heading and lists**

⁉️ [Discuss ideas or ask a question](https://github.com/vslinko/obsidian-zoom/discussions)<br>
⚙️ [Follow the development process](https://github.com/users/vslinko/projects/3/views/1)<br>
🐛 [Report issues](https://github.com/vslinko/obsidian-zoom/issues)

## Demo

![Demo](https://raw.githubusercontent.com/vslinko/obsidian-zoom/main/demo.gif)

## How to install

### From within Obsidian

You can activate this plugin within Obsidian by doing the following:

- Open Settings > Third-party plugin
- Make sure Safe mode is off
- Click Browse community plugins
- Search for "Zoom"
- Click Install
- Once installed, close the community plugins window and activate the newly installed plugin

### Manual installation

Download `main.js`, `manifest.json`, `styles.css` from the [latest release](https://github.com/vslinko/obsidian-zoom/releases/latest) and put them into `<vault>/.obsidian/plugins/obsidian-zoom` folder.

## Features

### Zoom in to a specific list or heading

Hide everything except the list/heading and its content.

| Command                      |       Default hotkey (Windows/Linux)        |             Default hotkey (MacOS)             |
| ---------------------------- | :-----------------------------------------: | :--------------------------------------------: |
| Zoom in                      |         <kbd>Ctrl</kbd><kbd>.</kbd>         |         <kbd>Command</kbd><kbd>.</kbd>         |
| Zoom out the entire document | <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>.</kbd> | <kbd>Command</kbd><kbd>Shift</kbd><kbd>.</kbd> |

| Setting                                | Default value |
| -------------------------------------- | :-----------: |
| Zooming in when clicking on the bullet |    `true`     |

### Debug mode

Open DevTools (Command+Option+I or Control+Shift+I) to copy the debug logs.

| Setting    | Default value |
| ---------- | :-----------: |
| Debug mode |    `false`    |

## Pricing

This plugin is free for everyone, however, if you would like to thank me
or help with further development, you can donate in one of the following ways:

- [Crypto](https://vslinko.cb.id)

### Patrons & Supporters

I want to say thank you to the people who support me, I really appreciate it!

- [Lucas D](https://twitter.com/lucasdreier)
- Philipp K.
- [Daniel B.](https://github.com/danieltomasz)
- Mat Rhein ([@mat_rhein7](http://twitter.com/mat_rhein7))
- [Ollie Lovell](https://www.ollielovell.com/)
- Faiz MK ([@faizkhuzaimah](https://twitter.com/faizkhuzaimah))
- more patrons and anonymous supporters


================================================
FILE: babel.config.js
================================================
module.exports = {
  presets: [
    ["@babel/preset-env", { targets: { node: "current" } }],
    "@babel/preset-typescript",
  ],
};


================================================
FILE: jest/global-setup.js
================================================
const cp = require("child_process");
const mkdirp = require("mkdirp");
const path = require("path");
const fs = require("fs");
const WebSocket = require("ws");
const debug = require("debug")("jest-obsidian");
const promisify = require("util").promisify;
const levelup = require("levelup");
const leveldown = require("leveldown");

const KILL_CMD =
  process.platform === "darwin"
    ? ["killall", "Obsidian"]
    : ["flatpak", "kill", "md.obsidian.Obsidian"];
const OBSIDIAN_CONFIG_DIR =
  process.platform === "darwin"
    ? process.env.HOME + "/Library/Application Support/obsidian"
    : process.env.HOME + "/.var/app/md.obsidian.Obsidian/config/obsidian";
const OBSIDIAN_CONFIG_PATH = OBSIDIAN_CONFIG_DIR + "/obsidian.json";
const OBSIDIAN_APP_CMD =
  process.platform === "darwin"
    ? ["/Applications/Obsidian.app/Contents/MacOS/Obsidian"]
    : ["flatpak", "run", "md.obsidian.Obsidian"];
const OBSIDIAN_LOCAL_STORAGE_PATH =
  process.platform === "darwin"
    ? process.env.HOME +
      "/Library/Application Support/obsidian/Local Storage/leveldb"
    : process.env.HOME +
      "/.var/app/md.obsidian.Obsidian/config/obsidian/Local Storage/leveldb";
const OBISDIAN_TEST_VAULT_ID = "5a15473126091111";
const VAULT_DIR = process.cwd() + "/vault";

global.originalObsidianConfig = null;
global.OBSIDIAN_CONFIG_PATH = OBSIDIAN_CONFIG_PATH;
global.KILL_CMD = KILL_CMD;

function wait(t) {
  return new Promise((resolve) => setTimeout(resolve, t));
}

function runForAWhile({ timeout, fileToCheck }) {
  return new Promise(async (resolve, reject) => {
    const start = Date.now();
    const obsidian = cp.spawn(OBSIDIAN_APP_CMD[0], OBSIDIAN_APP_CMD.slice(1));
    obsidian.on("error", reject);
    const i = setInterval(() => {
      if (fs.existsSync(fileToCheck)) {
        clearInterval(i);
        setTimeout(() => {
          cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));
          resolve();
        }, 1000);
        return;
      }
      const diff = Date.now() - start;
      if (diff > timeout) {
        clearInterval(i);
        cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));
        reject();
      }
    }, 1000);
  });
}

async function prepareObsidian() {
  debug(`Preparing Obsidian`);

  if (!fs.existsSync(OBSIDIAN_CONFIG_PATH)) {
    debug("  Running Obsidian for 90 seconds to setup");
    await runForAWhile({
      timeout: 90000,
      fileToCheck: OBSIDIAN_CONFIG_DIR,
    });
    await wait(2000);
    debug(`  Creating ${OBSIDIAN_CONFIG_PATH}`);
    fs.writeFileSync(OBSIDIAN_CONFIG_PATH, '{"vaults":{}}');
  }

  originalObsidianConfig = fs.readFileSync(OBSIDIAN_CONFIG_PATH, "utf-8");

  const obsidianConfig = JSON.parse(originalObsidianConfig);
  for (const key of Object.keys(obsidianConfig.vaults)) {
    debug(`  Closing vault ${obsidianConfig.vaults[key].path}`);
    obsidianConfig.vaults[key].open = false;
  }
  debug(`  Opening vault ${VAULT_DIR}`);
  obsidianConfig.vaults[OBISDIAN_TEST_VAULT_ID] = {
    path: VAULT_DIR,
    ts: Date.now(),
    open: true,
  };

  debug(`  Saving ${OBSIDIAN_CONFIG_PATH}`);
  fs.writeFileSync(OBSIDIAN_CONFIG_PATH, JSON.stringify(obsidianConfig));
}

async function prepareVault() {
  debug(`Prepare vault`);

  mkdirp.sync(VAULT_DIR);
  fs.writeFileSync(VAULT_DIR + "/test.md", "");

  const vaultConfigFilePath = `${VAULT_DIR}/.obsidian/app.json`;
  const vaultCommunityPluginsConfigFilePath = `${VAULT_DIR}/.obsidian/community-plugins.json`;
  const vaultPluginDir = `${VAULT_DIR}/.obsidian/plugins/obsidian-zoom`;

  if (!fs.existsSync(vaultConfigFilePath)) {
    debug("  Running Obsidian for 90 seconds to setup vault");
    await runForAWhile({ timeout: 90000, fileToCheck: vaultConfigFilePath });
    await wait(2000);
  }

  const vaultConfig = JSON.parse(fs.readFileSync(vaultConfigFilePath));
  const newVaultConfig = {
    ...vaultConfig,
    foldHeading: true,
    foldIndent: true,
    useTab: false,
    tabSize: 2,
    legacyEditor: false,
  };
  if (JSON.stringify(vaultConfig) !== JSON.stringify(newVaultConfig)) {
    debug(`  Saving ${vaultConfigFilePath}`);
    fs.writeFileSync(vaultConfigFilePath, JSON.stringify(newVaultConfig));
  }

  debug(`  Saving ${vaultCommunityPluginsConfigFilePath}`);
  fs.writeFileSync(
    vaultCommunityPluginsConfigFilePath,
    JSON.stringify(["obsidian-zoom"])
  );

  debug(`  Disabling Safe Mode`);
  mkdirp.sync(OBSIDIAN_LOCAL_STORAGE_PATH);
  const localStorage = levelup(leveldown(OBSIDIAN_LOCAL_STORAGE_PATH));
  const key = Buffer.from(
    "5f6170703a2f2f6f6273696469616e2e6d640001656e61626c652d706c7567696e2d35613135343733313236303931313131",
    "hex"
  );
  const value = Buffer.from("0174727565", "hex");
  await promisify(localStorage.put.bind(localStorage))(key, value);
  await promisify(localStorage.close.bind(localStorage))();

  mkdirp.sync(vaultPluginDir);

  debug(`  Copying ${vaultPluginDir}/main.js`);
  fs.copyFileSync("main.js", `${vaultPluginDir}/main.js`);
  debug(`  Copying ${vaultPluginDir}/manifest.json`);
  fs.copyFileSync("manifest.json", `${vaultPluginDir}/manifest.json`);
  debug(`  Copying ${vaultPluginDir}/styles.css`);
  fs.copyFileSync("styles.css", `${vaultPluginDir}/styles.css`);
}

module.exports = async () => {
  if (process.env.SKIP_OBSIDIAN) {
    return;
  }

  cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));
  await wait(2000);

  await prepareObsidian();
  await prepareVault();

  global.wss = new WebSocket.Server({
    port: 8080,
  });

  debug(`Running "${OBSIDIAN_APP_CMD[0]}"`);
  const obsidian = cp.exec(OBSIDIAN_APP_CMD.join(" "), {
    env: {
      ...process.env,
      TEST_PLATFORM: "1",
    },
  });
  obsidian.on("exit", (code) => {
    debug(`Obsidian exited with code ${code}`);
  });

  debug("Waiting for Obsidian WebSocket connection");
  const obsidianWs = await new Promise((resolve) => {
    wss.once("connection", (ws) => {
      debug("Waiting for Obsidian ready message");
      ws.once("message", (msg) => {
        if (msg.toString() === "ready") {
          resolve(ws);
        }
      });
    });
  });
  debug("Obsidian WebSocket ready");

  const callbacks = new Map();

  obsidianWs.on("message", (message) => {
    const { id, data, error } = JSON.parse(message);
    debug(`Response from Obsidian ${id}`);
    const cb = callbacks.get(id);
    if (cb) {
      callbacks.delete(id);
      cb(error, data);
    } else {
      debug(`Callback not found for ${id}`);
      process.exit(1);
    }
  });

  debug("Waiting for test environment connection");
  wss.on("connection", (ws) => {
    debug("Test environment connected");
    ws.on("message", (message) => {
      const { id, type, data } = JSON.parse(message);
      debug(`Request to Obsidian ${type} ${id}`);
      callbacks.set(id, (error, data) => {
        ws.send(JSON.stringify({ id, error, data }));
      });
      obsidianWs.send(JSON.stringify({ id, type, data }));
    });
  });
};


================================================
FILE: jest/global-teardown.js
================================================
const cp = require("child_process");
const fs = require("fs");
const debug = require("debug")("jest-obsidian");

module.exports = () => {
  if (global.wss) {
    global.wss.close();
  }

  cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));

  if (global.originalObsidianConfig) {
    debug(`Restoring ${OBSIDIAN_CONFIG_PATH}`);
    fs.writeFileSync(OBSIDIAN_CONFIG_PATH, originalObsidianConfig);
  }
};


================================================
FILE: jest/md-spec-transformer.js
================================================
function isHeader(line) {
  return line.startsWith("# ");
}

function isAction(line) {
  return line.startsWith("- ");
}

function isCodeBlock(line) {
  return line.startsWith("```");
}

function parseState(l) {
  if (!isCodeBlock(l.line)) {
    throw new Error(
      `parseState: Unexpected line "${l.line}", expected "\`\`\`"`
    );
  }

  const lines = [];

  while (true) {
    l.next();

    if (l.isEnded()) {
      throw new Error(`parseState: Unexpected EOF, expected "\`\`\`"`);
    } else if (isCodeBlock(l.line)) {
      l.nextNotEmpty();
      return {
        lines,
      };
    } else {
      lines.push(l.line);
    }
  }
}

function parseApplyState(l) {
  l.nextNotEmpty();

  return {
    type: "applyState",
    state: parseState(l),
  };
}

function parseAssertState(l) {
  l.nextNotEmpty();

  return {
    type: "assertState",
    state: parseState(l),
  };
}

function parseSimulateKeydown(l) {
  const key = l.line.replace(/- keydown: `([^`]+)`/, "$1");

  l.nextNotEmpty();

  return {
    type: "simulateKeydown",
    key,
  };
}

function parsePlatform(l) {
  const platform = l.line.replace(/- platform: `([^`]+)`/, "$1");

  l.nextNotEmpty();

  return {
    type: "platform",
    platform,
  };
}

function parseExecuteCommandById(l) {
  const command = l.line.replace(/- execute: `([^`]+)`/, "$1");

  l.nextNotEmpty();

  return {
    type: "executeCommandById",
    command,
  };
}

function parseReplaceSelection(l) {
  const char = l.line.replace(/- replaceSelection: `([^`]+)`/, "$1");

  l.nextNotEmpty();

  return {
    type: "replaceSelection",
    char,
  };
}

function parseAction(l) {
  if (!isAction(l.line)) {
    throw new Error(
      `parseAction: Unexpected line "${l.line}", expected ACTION`
    );
  }

  if (l.line.startsWith("- applyState:")) {
    return parseApplyState(l);
  } else if (l.line.startsWith("- keydown:")) {
    return parseSimulateKeydown(l);
  } else if (l.line.startsWith("- execute:")) {
    return parseExecuteCommandById(l);
  } else if (l.line.startsWith("- replaceSelection:")) {
    return parseReplaceSelection(l);
  } else if (l.line.startsWith("- assertState:")) {
    return parseAssertState(l);
  } else if (l.line.startsWith("- platform:")) {
    return parsePlatform(l);
  }

  throw new Error(`parseAction: Unknown action "${l.line}"`);
}

function parseTest(l) {
  if (!isHeader(l.line)) {
    throw new Error(`parseTest: Unexpected line "${l.line}", expected HEADER`);
  }

  const title = l.line.replace(/^# /, "").trim();
  const actions = [];

  l.nextNotEmpty();

  while (!l.isEnded() && !isHeader(l.line)) {
    actions.push(parseAction(l));
  }

  return {
    title,
    actions,
  };
}

function parseTests(l) {
  l.nextNotEmpty();

  const tests = [];

  while (!l.isEnded()) {
    tests.push(parseTest(l));
  }

  return tests;
}

class LinesIterator {
  constructor(lines) {
    this.i = -1;
    this.lines = lines;
    this.len = this.lines.length;
  }

  get line() {
    return this.lines[this.i];
  }

  isEnded() {
    return this.i >= this.len;
  }

  nextNotEmpty() {
    do {
      this.i++;
    } while (!this.isEnded() && this.line.trim() === "");
  }

  next() {
    this.i++;
  }
}

module.exports.process = function process(sourceText, sourcePath, options) {
  const l = new LinesIterator(sourceText.split("\n"));
  const s = (v) => JSON.stringify(v);

  const name = sourcePath.replace(options.config.cwd + "/", "");

  let code = "";
  code += `describe(${s(name)}, () => {\n`;

  for (const test of parseTests(l)) {
    const platform = test.actions.find((a) => a.type === "platform");
    const testFn =
      platform && process.platform !== platform.platform ? "test.skip" : "test";

    code += `  ${testFn}(${s(test.title)}, async () => {\n`;

    for (const action of test.actions) {
      switch (action.type) {
        case "applyState":
          code += `    await applyState(${s(action.state.lines)});\n`;
          break;
        case "simulateKeydown":
          code += `    await simulateKeydown(${s(action.key)});\n`;
          break;
        case "executeCommandById":
          code += `    await executeCommandById(${s(action.command)});\n`;
          break;
        case "replaceSelection":
          code += `    await replaceSelection(${s(action.char)});\n`;
          break;
        case "assertState":
          code += `    // Waiting for all operations to be applied\n`;
          code += `    await new Promise((resolve) => setTimeout(resolve, 10));\n`;
          code += `    await expect(await getCurrentState()).toEqualEditorState(${s(
            action.state.lines
          )});\n`;
          break;
      }
    }

    code += `  });\n`;
  }

  code += `});\n`;

  return {
    code,
  };
};


================================================
FILE: jest/obsidian-environment.js
================================================
const { TestEnvironment } = require("jest-environment-node");
const WebSocket = require("ws");

let idSeq = 1;

module.exports = class CustomEnvironment extends TestEnvironment {
  async setup() {
    await super.setup();

    this.callbacks = new Map();

    this.createCommand("applyState");
    this.createCommand("simulateKeydown");
    this.createCommand("executeCommandById");
    this.createCommand("replaceSelection");
    this.createCommand("parseState");
    this.createCommand("getCurrentState");
  }

  createCommand(type) {
    this.global[type] = (data) => this.runCommand(type, data);
  }

  async initWs() {
    this.ws = new WebSocket("ws://127.0.0.1:8080");

    await new Promise((resolve) => this.ws.on("open", resolve));

    this.ws.on("message", (message) => {
      const { id, data, error } = JSON.parse(message);
      const cb = this.callbacks.get(id);
      if (cb) {
        this.callbacks.delete(id);
        cb(error, data);
      }
    });
  }

  async runCommand(type, data) {
    if (!this.ws) {
      await this.initWs();
    }

    return new Promise((resolve, reject) => {
      const id = String(idSeq++);

      this.callbacks.set(id, (error, data) => {
        if (error) {
          reject(new Error(error));
        } else {
          resolve(data);
        }
      });

      this.ws.send(JSON.stringify({ id, type, data }));
    });
  }

  async teardown() {
    if (this.ws) {
      this.ws.close();
    }
    await super.teardown();
  }
};


================================================
FILE: jest/obsidian-expect.js
================================================
const jestExpect = global.expect;

function stateToString(state) {
  const lines = state.value.split("\n");

  const sels = state.selections.reduce((acc, sel) => {
    acc.set(sel.anchor, "anchor");
    acc.set(sel.head, "head");
    return acc;
  }, new Map());

  const folds = state.folds.reduce((acc, sel) => {
    acc.set(sel.from, "from");
    acc.set(sel.to, "to");
    return acc;
  }, new Map());

  let res = "";
  let totalC = 0;

  for (let l = 0; l < lines.length; l++) {
    const line = lines[l];

    for (let c = 0; c <= line.length; c++) {
      if (sels.has(totalC)) {
        res += "|";
      }
      if (folds.has(totalC)) {
        res += folds.get(totalC) === "from" ? ">" : "<";
      }
      if (c < line.length) {
        res += line[c];
        totalC++;
      }
    }

    if (state.hidden.includes(l)) {
      res += " #hidden";
    }

    res += "\n";
    totalC++;
  }

  return res;
}

jestExpect.extend({
  async toEqualEditorState(receivedState, expectedState) {
    const options = {
      comment: "Obsidian editor state equality",
      isNot: this.isNot,
      promise: this.promise,
    };

    expectedState = await parseState(expectedState);

    const received = stateToString(receivedState);
    const expected = stateToString(expectedState);

    const pass = received === expected;

    const message = pass
      ? () =>
          this.utils.matcherHint(
            "toEqualEditorState",
            undefined,
            undefined,
            options
          ) +
          "\n\n" +
          `Expected: not ${this.utils.printExpected(expected)}\n` +
          `Received: ${this.utils.printReceived(received)}`
      : () => {
          const diffString = this.utils.diff(expected, received, {
            expand: this.expand,
          });
          return (
            this.utils.matcherHint(
              "toEqualEditorState",
              undefined,
              undefined,
              options
            ) +
            "\n\n" +
            (diffString && diffString.includes("- Expect")
              ? `Difference:\n\n${diffString}`
              : `Expected: ${this.utils.printExpected(expected)}\n` +
                `Received: ${this.utils.printReceived(received)}`)
          );
        };

    return {
      pass,
      message,
    };
  },
});


================================================
FILE: jest/test-globals.d.ts
================================================
declare namespace jest {
  interface Matchers<R> {
    toEqualEditorState(s: string): Promise<R>;
    toEqualEditorState(s: string[]): Promise<R>;
  }
}

interface IFold {
  from: number;
  to: number;
}

interface ISelection {
  anchor: number;
  head: number;
}

interface IState {
  hidden: number[];
  folds: IFold[];
  selections: ISelection[];
  value: string;
}

declare function applyState(state: string): Promise<void>;
declare function applyState(state: string[]): Promise<void>;
declare function parseState(state: string): Promise<IState>;
declare function parseState(state: string[]): Promise<IState>;
declare function simulateKeydown(keys: string): Promise<void>;
declare function replaceSelection(char: string): Promise<void>;
declare function executeCommandById(keys: string): Promise<void>;
declare function getCurrentState(): Promise<IState>;


================================================
FILE: jest.config.json
================================================
{
  "clearMocks": true,
  "transform": {
    "\\.ts$": "babel-jest",
    "\\.spec\\.md$": "./jest/md-spec-transformer.js"
  },
  "testRegex": ["/__tests__/.*\\.ts$", "\\.spec\\.md$"],
  "moduleFileExtensions": ["js", "ts", "md"],
  "globalSetup": "./jest/global-setup.js",
  "globalTeardown": "./jest/global-teardown.js",
  "setupFilesAfterEnv": ["./jest/obsidian-expect.js"],
  "testEnvironment": "./jest/obsidian-environment.js",
  "maxConcurrency": 1,
  "maxWorkers": 1
}


================================================
FILE: manifest.json
================================================
{
  "id": "obsidian-zoom",
  "name": "Zoom",
  "version": "1.1.2",
  "minAppVersion": "1.1.16",
  "description": "Zoom into heading and lists.",
  "author": "Viacheslav Slinko",
  "authorUrl": "https://github.com/vslinko",
  "isDesktopOnly": false
}


================================================
FILE: package.json
================================================
{
  "name": "obsidian-zoom",
  "version": "1.1.2",
  "description": "Zoom into heading and lists.",
  "main": "main.js",
  "scripts": {
    "dev": "rollup --config rollup.config.mjs -w --configWithTests",
    "build-with-tests": "rollup --config rollup.config.mjs --configWithTests",
    "build": "rollup --config rollup.config.mjs",
    "lint": "prettier --check src && eslint src",
    "test": "jest",
    "prepare": "husky install"
  },
  "author": "Viacheslav Slinko",
  "license": "MIT",
  "devDependencies": {
    "@babel/core": "^7.21.8",
    "@babel/preset-env": "^7.21.5",
    "@babel/preset-typescript": "^7.21.5",
    "@codemirror/language": "^6.6.0",
    "@codemirror/state": "^6.2.0",
    "@codemirror/view": "^6.11.0",
    "@rollup/plugin-commonjs": "^24.1.0",
    "@rollup/plugin-node-resolve": "^15.0.2",
    "@rollup/plugin-typescript": "^11.1.0",
    "@trivago/prettier-plugin-sort-imports": "^4.1.1",
    "@types/diff": "^5.0.3",
    "@types/jest": "^29.5.1",
    "@types/node": "^18.16.3",
    "@typescript-eslint/eslint-plugin": "^5.59.2",
    "@typescript-eslint/parser": "^5.59.2",
    "babel-jest": "^29.5.0",
    "debug": "^4.3.4",
    "eslint": "^8.39.0",
    "eslint-config-prettier": "^8.8.0",
    "husky": "^8.0.3",
    "inquirer": "^9.2.0",
    "jest": "^29.5.0",
    "jest-environment-jsdom": "^29.5.0",
    "jest-environment-node": "^29.5.0",
    "leveldown": "^6.1.1",
    "levelup": "^5.1.1",
    "mkdirp": "^3.0.1",
    "obsidian": "^1.2.8",
    "prettier": "^2.8.8",
    "rollup": "^3.21.4",
    "ts-node": "^10.9.1",
    "tslib": "^2.5.0",
    "typescript": "5.0.4",
    "ws": "^8.13.0"
  }
}


================================================
FILE: release.mjs
================================================
import inquirer from "inquirer";
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";

function increaseVersion(version, releaseType) {
  const v = version.split(".").map((p) => Number(p));

  if (releaseType === "major") {
    v[0]++;
    v[1] = 0;
    v[2] = 0;
  } else if (releaseType === "minor") {
    v[1]++;
    v[2] = 0;
  } else if (releaseType === "patch") {
    v[2]++;
  } else {
    throw new Error();
  }

  return v.join(".");
}

async function main() {
  const manifestFile = JSON.parse(readFileSync("manifest.json"));

  console.log(`Current version ${manifestFile.version}`);
  console.log(`Current minAppVersion ${manifestFile.minAppVersion}`);

  const { releaseType, minAppVersion } = await inquirer.prompt([
    {
      type: "list",
      name: "releaseType",
      message: "Release type:",
      choices: [
        {
          name: "major (Some major changes that have, or could lead to, breaking changes)",
          value: "major",
        },
        {
          name: "minor (Some notable changes without breaking changes)",
          value: "minor",
        },
        {
          name: "patch (Some changes, but without new features)",
          value: "patch",
        },
      ],
    },
    {
      type: "input",
      name: "minAppVersion",
      message: "Minimum supported version of Obsidian:",
      default: manifestFile.minAppVersion,
    },
  ]);

  const newVersion = increaseVersion(manifestFile.version, releaseType);

  manifestFile.version = newVersion;
  manifestFile.minAppVersion = minAppVersion;
  writeFileSync("manifest.json", JSON.stringify(manifestFile, null, 2) + "\n");

  const packageLockFile = JSON.parse(readFileSync("package-lock.json"));
  packageLockFile.version = newVersion;
  packageLockFile.packages[""].version = newVersion;
  writeFileSync(
    "package-lock.json",
    JSON.stringify(packageLockFile, null, 2) + "\n"
  );

  const packageFile = JSON.parse(readFileSync("package.json"));
  packageFile.version = newVersion;
  writeFileSync("package.json", JSON.stringify(packageFile, null, 2) + "\n");

  const versionsFile = JSON.parse(readFileSync("versions.json"));
  const newVersionsFile = {
    [newVersion]: minAppVersion,
    ...versionsFile,
  };
  writeFileSync(
    "versions.json",
    JSON.stringify(newVersionsFile, null, 2) + "\n"
  );

  spawnSync(
    "git",
    [
      "add",
      "manifest.json",
      "package-lock.json",
      "package.json",
      "versions.json",
    ],
    {
      stdio: "inherit",
    }
  );
  spawnSync("git", ["commit", "-m", newVersion], {
    stdio: "inherit",
  });
}

main();


================================================
FILE: rollup.config.mjs
================================================
import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";

export default (commandLineArgs) => ({
  input: commandLineArgs.configWithTests
    ? "src/ObsidianZoomPluginWithTests.ts"
    : "src/ObsidianZoomPlugin.ts",
  output: {
    file: "main.js",
    sourcemap: "inline",
    format: "cjs",
    exports: "default",
  },
  external: [
    "obsidian",
    "@codemirror/language",
    "@codemirror/state",
    "@codemirror/view",
  ],
  plugins: [typescript(), nodeResolve({ browser: true }), commonjs()],
});


================================================
FILE: specs/LimitSelectionFeature.spec.md
================================================
# Should limit selection on zooming in

- applyState:

```md
text

# 1|

text

## 1.1|

text

# 2

text
```

- execute: `obsidian-zoom:zoom-in`
- assertState:

```md
text #hidden
 #hidden
# 1 #hidden
 #hidden
text #hidden
 #hidden
|## 1.1|

text

# 2 #hidden
 #hidden
text #hidden
```

# Should limit selection when zoomed in

- platform: `darwin`
- applyState:

```md
text

# 1

text

## 1.1|

text

# 2

text
```

- execute: `obsidian-zoom:zoom-in`
- keydown: `Cmd-KeyA`
- assertState:

```md
text #hidden
 #hidden
# 1 #hidden
 #hidden
text #hidden
 #hidden
|## 1.1

text
|
# 2 #hidden
 #hidden
text #hidden
```

# Should limit selection when zoomed in

- platform: `linux`
- applyState:

```md
text

# 1

text

## 1.1|

text

# 2

text
```

- execute: `obsidian-zoom:zoom-in`
- keydown: `Ctrl-KeyA`
- assertState:

```md
text #hidden
 #hidden
# 1 #hidden
 #hidden
text #hidden
 #hidden
|## 1.1

text
|
# 2 #hidden
 #hidden
text #hidden
```

# Should not have bug #39

- applyState:

```md
# h1|

# h2
```

- execute: `obsidian-zoom:zoom-in`
- keydown: `ArrowDown`
- replaceSelection: `a`
- replaceSelection: `b`
- replaceSelection: `c`
- assertState:

```md
# h1
abc|
# h2 #hidden
```


================================================
FILE: specs/ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md
================================================
# Should reset zoom when first boundary of visible content is violated

- applyState:

```md
text

|# 1

text
```

- execute: `obsidian-zoom:zoom-in`
- keydown: `Backspace`
- assertState:

```md
text
|# 1

text
```

# Should reset zoom when second boundary of visible content is violated

- applyState:

```md
# 1|

text

# 2

text
```

- execute: `obsidian-zoom:zoom-in`
- keydown: `ArrowRight`
- keydown: `ArrowDown`
- keydown: `ArrowDown`
- assertState:

```md
# 1

text
|
# 2 #hidden
 #hidden
text #hidden
```

- keydown: `Delete`
- assertState:

```md
# 1

text
|# 2

text
```


================================================
FILE: specs/ZoomFeature.spec.md
================================================
# Should zoom in

- applyState:

```md
text

# 1

text

## 1.1|

text

# 2

text
```

- execute: `obsidian-zoom:zoom-in`
- assertState:

```md
text #hidden
 #hidden
# 1 #hidden
 #hidden
text #hidden
 #hidden
## 1.1|

text

# 2 #hidden
 #hidden
text #hidden
```

# Should zoom out

- applyState:

```md
text

# 1

text

## 1.1|

text

# 2

text
```

- execute: `obsidian-zoom:zoom-in`
- execute: `obsidian-zoom:zoom-out`
- assertState:

```md
text

# 1

text

## 1.1|

text

# 2

text
```


================================================
FILE: src/ObsidianZoomPlugin.ts
================================================
import { Editor, Plugin } from "obsidian";

import { Feature } from "./features/Feature";
import { HeaderNavigationFeature } from "./features/HeaderNavigationFeature";
import { LimitSelectionFeature } from "./features/LimitSelectionFeature";
import { ListsStylesFeature } from "./features/ListsStylesFeature";
import { ResetZoomWhenVisibleContentBoundariesViolatedFeature } from "./features/ResetZoomWhenVisibleContentBoundariesViolatedFeature";
import { SettingsTabFeature } from "./features/SettingsTabFeature";
import { ZoomFeature } from "./features/ZoomFeature";
import { ZoomOnClickFeature } from "./features/ZoomOnClickFeature";
import { LoggerService } from "./services/LoggerService";
import { SettingsService } from "./services/SettingsService";
import { getEditorViewFromEditor } from "./utils/getEditorViewFromEditor";

declare global {
  interface Window {
    ObsidianZoomPlugin?: ObsidianZoomPlugin;
  }
}

export default class ObsidianZoomPlugin extends Plugin {
  protected zoomFeature: ZoomFeature;
  protected features: Feature[];

  async onload() {
    console.log(`Loading obsidian-zoom`);

    window.ObsidianZoomPlugin = this;

    const settings = new SettingsService(this);
    await settings.load();

    const logger = new LoggerService(settings);

    const settingsTabFeature = new SettingsTabFeature(this, settings);
    this.zoomFeature = new ZoomFeature(this, logger);
    const limitSelectionFeature = new LimitSelectionFeature(
      this,
      logger,
      this.zoomFeature
    );
    const resetZoomWhenVisibleContentBoundariesViolatedFeature =
      new ResetZoomWhenVisibleContentBoundariesViolatedFeature(
        this,
        logger,
        this.zoomFeature,
        this.zoomFeature
      );
    const headerNavigationFeature = new HeaderNavigationFeature(
      this,
      logger,
      this.zoomFeature,
      this.zoomFeature,
      this.zoomFeature,
      this.zoomFeature,
      this.zoomFeature,
      this.zoomFeature
    );
    const zoomOnClickFeature = new ZoomOnClickFeature(
      this,
      settings,
      this.zoomFeature
    );
    const listsStylesFeature = new ListsStylesFeature(settings);

    this.features = [
      settingsTabFeature,
      this.zoomFeature,
      limitSelectionFeature,
      resetZoomWhenVisibleContentBoundariesViolatedFeature,
      headerNavigationFeature,
      zoomOnClickFeature,
      listsStylesFeature,
    ];

    for (const feature of this.features) {
      await feature.load();
    }
  }

  async onunload() {
    console.log(`Unloading obsidian-zoom`);

    delete window.ObsidianZoomPlugin;

    for (const feature of this.features) {
      await feature.unload();
    }
  }

  public getZoomRange(editor: Editor) {
    const cm = getEditorViewFromEditor(editor);
    const range = this.zoomFeature.calculateVisibleContentRange(cm.state);

    if (!range) {
      return null;
    }

    const from = cm.state.doc.lineAt(range.from);
    const to = cm.state.doc.lineAt(range.to);

    return {
      from: {
        line: from.number - 1,
        ch: range.from - from.from,
      },
      to: {
        line: to.number - 1,
        ch: range.to - to.from,
      },
    };
  }

  public zoomOut(editor: Editor) {
    this.zoomFeature.zoomOut(getEditorViewFromEditor(editor));
  }

  public zoomIn(editor: Editor, line: number) {
    const cm = getEditorViewFromEditor(editor);
    const pos = cm.state.doc.line(line + 1).from;
    this.zoomFeature.zoomIn(cm, pos);
  }

  public refreshZoom(editor: Editor) {
    this.zoomFeature.refreshZoom(getEditorViewFromEditor(editor));
  }
}


================================================
FILE: src/ObsidianZoomPluginWithTests.ts
================================================
import { editorEditorField } from "obsidian";

import { foldEffect, foldedRanges } from "@codemirror/language";
import { EditorSelection, StateField } from "@codemirror/state";
import { EditorView, runScopeHandlers } from "@codemirror/view";

import ObsidianZoomPlugin from "./ObsidianZoomPlugin";
import { zoomOutEffect } from "./logic/utils/effects";

const keysMap: { [key: string]: number } = {
  Backspace: 8,
  Enter: 13,
  ArrowLeft: 37,
  ArrowUp: 38,
  ArrowRight: 39,
  ArrowDown: 40,
  Delete: 46,
  KeyA: 65,
};

export default class ObsidianZoomPluginWithTests extends ObsidianZoomPlugin {
  private editorView: EditorView;

  wait(time: number) {
    return new Promise((resolve) => setTimeout(resolve, time));
  }

  executeCommandById(id: string) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (this.app as any).commands.executeCommandById(id);
  }

  replaceSelection(char: string) {
    this.editorView.dispatch(this.editorView.state.replaceSelection(char));
  }

  simulateKeydown(keys: string) {
    const e = {
      type: "keydown",
      code: "",
      keyCode: 0,
      shiftKey: false,
      metaKey: false,
      altKey: false,
      ctrlKey: false,
      defaultPrevented: false,
      returnValue: true,
      cancelBubble: false,
      preventDefault: function () {
        e.defaultPrevented = true;
        e.returnValue = true;
      },
      stopPropagation: function () {
        e.cancelBubble = true;
      },
    };

    for (const key of keys.split("-")) {
      switch (key.toLowerCase()) {
        case "cmd":
          e.metaKey = true;
          break;
        case "ctrl":
          e.ctrlKey = true;
          break;
        case "alt":
          e.altKey = true;
          break;
        case "shift":
          e.shiftKey = true;
          break;
        default:
          e.code = key;
          break;
      }
    }

    if (e.code in keysMap) {
      e.keyCode = keysMap[e.code];
    }

    if (e.keyCode == 0) {
      throw new Error("Unknown key: " + e.code);
    }

    runScopeHandlers(this.editorView, e as KeyboardEvent, "editor");
  }

  async load() {
    await super.load();

    if (process.env.TEST_PLATFORM) {
      setImmediate(async () => {
        await this.wait(1000);
        this.connect();
      });
    }
  }

  async prepareForTests() {
    const filePath = `test.md`;
    let file = this.app.vault
      .getMarkdownFiles()
      .find((f) => f.path === filePath);
    if (!file) {
      file = await this.app.vault.create(filePath, "");
    }
    for (let i = 0; i < 10; i++) {
      await this.wait(1000);
      if (this.app.workspace.activeLeaf) {
        this.app.workspace.activeLeaf.openFile(file);
        break;
      }
    }
    await this.wait(1000);

    this.registerEditorExtension(
      StateField.define({
        create: (state) => {
          this.editorView = state.field(editorEditorField);
        },
        update: () => {},
      })
    );
  }

  async connect() {
    const ws = new WebSocket("ws://127.0.0.1:8080/");
    await this.prepareForTests();
    ws.send("ready");

    ws.addEventListener("message", (event) => {
      const { id, type, data } = JSON.parse(event.data);

      let result;
      let error;

      try {
        switch (type) {
          case "applyState":
            this.applyState(data);
            break;
          case "simulateKeydown":
            this.simulateKeydown(data);
            break;
          case "replaceSelection":
            this.replaceSelection(data);
            break;
          case "executeCommandById":
            this.executeCommandById(data);
            break;
          case "parseState":
            result = this.parseState(data);
            break;
          case "getCurrentState":
            result = this.getCurrentState();
            break;
        }
      } catch (e) {
        error = String(e);
        if (e.stack) {
          error += "\n" + e.stack;
        }
      }

      ws.send(JSON.stringify({ id, data: result, error }));
    });
  }

  applyState(state: string[]): void;
  applyState(state: string): void;
  applyState(state: IState): void;
  applyState(state: IState | string | string[]) {
    if (typeof state === "string") {
      state = state.split("\n");
    }

    if (Array.isArray(state)) {
      state = this.parseState(state);
    }

    this.editorView.dispatch({
      effects: [zoomOutEffect.of()],
    });
    this.editorView.dispatch({
      changes: [{ from: 0, to: this.editorView.state.doc.length, insert: "" }],
    });
    this.editorView.dispatch({
      changes: [{ from: 0, insert: state.value }],
    });
    this.editorView.dispatch({
      selection: EditorSelection.create(
        state.selections.map((s) => EditorSelection.range(s.anchor, s.head))
      ),
    });
    this.editorView.dispatch({
      effects: state.folds.map((f) =>
        foldEffect.of({ from: f.from, to: f.to })
      ),
    });
  }

  getCurrentState(): IState {
    const hidden: number[] = [];

    const hiddenRanges = this.zoomFeature.calculateHiddenContentRanges(
      this.editorView.state
    );
    for (const i of hiddenRanges) {
      const lineFrom = this.editorView.state.doc.lineAt(i.from).number - 1;
      const lineTo = this.editorView.state.doc.lineAt(i.to).number - 1;
      for (let lineNo = lineFrom; lineNo <= lineTo; lineNo++) {
        hidden.push(lineNo);
      }
    }

    const folds: IFold[] = [];
    const iter = foldedRanges(this.editorView.state).iter();
    while (iter.value !== null) {
      folds.push({ from: iter.from, to: iter.to });
      iter.next();
    }

    return {
      hidden,
      folds,
      selections: this.editorView.state.selection.ranges.map((r) => ({
        anchor: r.anchor,
        head: r.head,
      })),
      value: this.editorView.state.doc.sliceString(0),
    };
  }

  parseState(content: string[]): IState;
  parseState(content: string): IState;
  parseState(content: string | string[]): IState {
    if (typeof content === "string") {
      content = content.split("\n");
    }

    const acc = content.reduce(
      (acc, line, lineNo) => {
        if (acc.foldFrom === null) {
          const arrowIndex = line.indexOf(">");
          if (arrowIndex >= 0) {
            acc.foldFrom = acc.chars + arrowIndex;
            line =
              line.substring(0, arrowIndex) + line.substring(arrowIndex + 1);
          }
        } else {
          const arrowIndex = line.indexOf("<");
          if (arrowIndex >= 0) {
            acc.folds.push({ from: acc.foldFrom, to: acc.chars + arrowIndex });
            acc.foldFrom = null;
            line =
              line.substring(0, arrowIndex) + line.substring(arrowIndex + 1);
          }
        }

        if (line.includes("#hidden")) {
          line = line.replace("#hidden", "").trim();
          acc.hidden.push(lineNo);
        }

        if (acc.anchor === null) {
          const dashIndex = line.indexOf("|");
          if (dashIndex >= 0) {
            acc.anchor = acc.chars + dashIndex;
            line = line.substring(0, dashIndex) + line.substring(dashIndex + 1);
          }
        }

        if (acc.head === null) {
          const dashIndex = line.indexOf("|");
          if (dashIndex >= 0) {
            acc.head = acc.chars + dashIndex;
            line = line.substring(0, dashIndex) + line.substring(dashIndex + 1);
          }
        }

        acc.chars += line.length;
        acc.chars += 1;
        acc.lines.push(line);

        return acc;
      },
      {
        lines: [] as string[],
        chars: 0,
        anchor: null as number | null,
        head: null as number | null,
        foldFrom: null as number | null,
        folds: [] as IFold[],
        hidden: [] as number[],
      }
    );
    if (acc.anchor === null) {
      acc.anchor = 0;
    }
    if (acc.head === null) {
      acc.head = acc.anchor;
    }

    return {
      hidden: acc.hidden,
      folds: acc.folds,
      selections: [{ anchor: acc.anchor, head: acc.head }],
      value: acc.lines.join("\n"),
    };
  }
}


================================================
FILE: src/features/Feature.ts
================================================
export interface Feature {
  load(): Promise<void>;
  unload(): Promise<void>;
}


================================================
FILE: src/features/HeaderNavigationFeature.ts
================================================
import { Plugin } from "obsidian";

import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

import { Feature } from "./Feature";
import { getDocumentTitle } from "./utils/getDocumentTitle";
import { getEditorViewFromEditorState } from "./utils/getEditorViewFromEditorState";

import { CollectBreadcrumbs } from "../logic/CollectBreadcrumbs";
import { DetectRangeBeforeVisibleRangeChanged } from "../logic/DetectRangeBeforeVisibleRangeChanged";
import { RenderNavigationHeader } from "../logic/RenderNavigationHeader";
import { LoggerService } from "../services/LoggerService";

export interface ZoomIn {
  zoomIn(view: EditorView, pos: number): void;
}

export interface ZoomOut {
  zoomOut(view: EditorView): void;
}

export interface NotifyAfterZoomIn {
  notifyAfterZoomIn(cb: (view: EditorView, pos: number) => void): void;
}

export interface NotifyAfterZoomOut {
  notifyAfterZoomOut(cb: (view: EditorView) => void): void;
}

export interface CalculateHiddenContentRanges {
  calculateHiddenContentRanges(
    state: EditorState
  ): { from: number; to: number }[] | null;
}

export interface CalculateVisibleContentRange {
  calculateVisibleContentRange(
    state: EditorState
  ): { from: number; to: number } | null;
}

class ShowHeaderAfterZoomIn implements Feature {
  constructor(
    private notifyAfterZoomIn: NotifyAfterZoomIn,
    private collectBreadcrumbs: CollectBreadcrumbs,
    private renderNavigationHeader: RenderNavigationHeader
  ) {}

  async load() {
    this.notifyAfterZoomIn.notifyAfterZoomIn((view, pos) => {
      const breadcrumbs = this.collectBreadcrumbs.collectBreadcrumbs(
        view.state,
        pos
      );
      this.renderNavigationHeader.showHeader(view, breadcrumbs);
    });
  }

  async unload() {}
}

class HideHeaderAfterZoomOut implements Feature {
  constructor(
    private notifyAfterZoomOut: NotifyAfterZoomOut,
    private renderNavigationHeader: RenderNavigationHeader
  ) {}

  async load() {
    this.notifyAfterZoomOut.notifyAfterZoomOut((view) => {
      this.renderNavigationHeader.hideHeader(view);
    });
  }

  async unload() {}
}

class UpdateHeaderAfterRangeBeforeVisibleRangeChanged implements Feature {
  private detectRangeBeforeVisibleRangeChanged =
    new DetectRangeBeforeVisibleRangeChanged(
      this.calculateHiddenContentRanges,
      {
        rangeBeforeVisibleRangeChanged: (state) =>
          this.rangeBeforeVisibleRangeChanged(state),
      }
    );

  constructor(
    private plugin: Plugin,
    private calculateHiddenContentRanges: CalculateHiddenContentRanges,
    private calculateVisibleContentRange: CalculateVisibleContentRange,
    private collectBreadcrumbs: CollectBreadcrumbs,
    private renderNavigationHeader: RenderNavigationHeader
  ) {}

  async load() {
    this.plugin.registerEditorExtension(
      this.detectRangeBeforeVisibleRangeChanged.getExtension()
    );
  }

  async unload() {}

  private rangeBeforeVisibleRangeChanged(state: EditorState) {
    const view = getEditorViewFromEditorState(state);

    const pos =
      this.calculateVisibleContentRange.calculateVisibleContentRange(
        state
      ).from;

    const breadcrumbs = this.collectBreadcrumbs.collectBreadcrumbs(state, pos);

    this.renderNavigationHeader.showHeader(view, breadcrumbs);
  }
}

export class HeaderNavigationFeature implements Feature {
  private collectBreadcrumbs = new CollectBreadcrumbs({
    getDocumentTitle: getDocumentTitle,
  });

  private renderNavigationHeader = new RenderNavigationHeader(
    this.logger,
    this.zoomIn,
    this.zoomOut
  );

  private showHeaderAfterZoomIn = new ShowHeaderAfterZoomIn(
    this.notifyAfterZoomIn,
    this.collectBreadcrumbs,
    this.renderNavigationHeader
  );

  private hideHeaderAfterZoomOut = new HideHeaderAfterZoomOut(
    this.notifyAfterZoomOut,
    this.renderNavigationHeader
  );

  private updateHeaderAfterRangeBeforeVisibleRangeChanged =
    new UpdateHeaderAfterRangeBeforeVisibleRangeChanged(
      this.plugin,
      this.calculateHiddenContentRanges,
      this.calculateVisibleContentRange,
      this.collectBreadcrumbs,
      this.renderNavigationHeader
    );

  constructor(
    private plugin: Plugin,
    private logger: LoggerService,
    private calculateHiddenContentRanges: CalculateHiddenContentRanges,
    private calculateVisibleContentRange: CalculateVisibleContentRange,
    private zoomIn: ZoomIn,
    private zoomOut: ZoomOut,
    private notifyAfterZoomIn: NotifyAfterZoomIn,
    private notifyAfterZoomOut: NotifyAfterZoomOut
  ) {}

  async load() {
    this.plugin.registerEditorExtension(
      this.renderNavigationHeader.getExtension()
    );

    this.showHeaderAfterZoomIn.load();
    this.hideHeaderAfterZoomOut.load();
    this.updateHeaderAfterRangeBeforeVisibleRangeChanged.load();
  }

  async unload() {
    this.showHeaderAfterZoomIn.unload();
    this.hideHeaderAfterZoomOut.unload();
    this.updateHeaderAfterRangeBeforeVisibleRangeChanged.unload();
  }
}


================================================
FILE: src/features/LimitSelectionFeature.ts
================================================
import { Plugin } from "obsidian";

import { EditorState } from "@codemirror/state";

import { Feature } from "./Feature";

import { LimitSelectionOnZoomingIn } from "../logic/LimitSelectionOnZoomingIn";
import { LimitSelectionWhenZoomedIn } from "../logic/LimitSelectionWhenZoomedIn";
import { LoggerService } from "../services/LoggerService";

export interface CalculateVisibleContentRange {
  calculateVisibleContentRange(
    state: EditorState
  ): { from: number; to: number } | null;
}

export class LimitSelectionFeature implements Feature {
  private limitSelectionOnZoomingIn = new LimitSelectionOnZoomingIn(
    this.logger
  );
  private limitSelectionWhenZoomedIn = new LimitSelectionWhenZoomedIn(
    this.logger,
    this.calculateVisibleContentRange
  );

  constructor(
    private plugin: Plugin,
    private logger: LoggerService,
    private calculateVisibleContentRange: CalculateVisibleContentRange
  ) {}

  async load() {
    this.plugin.registerEditorExtension(
      this.limitSelectionOnZoomingIn.getExtension()
    );

    this.plugin.registerEditorExtension(
      this.limitSelectionWhenZoomedIn.getExtension()
    );
  }

  async unload() {}
}


================================================
FILE: src/features/ListsStylesFeature.ts
================================================
import { Feature } from "./Feature";

import { SettingsService } from "../services/SettingsService";

export class ListsStylesFeature implements Feature {
  constructor(private settings: SettingsService) {}

  async load() {
    if (this.settings.zoomOnClick) {
      this.addZoomStyles();
    }

    this.settings.onChange("zoomOnClick", this.onZoomOnClickSettingChange);
  }

  async unload() {
    this.settings.removeCallback(
      "zoomOnClick",
      this.onZoomOnClickSettingChange
    );

    this.removeZoomStyles();
  }

  private onZoomOnClickSettingChange = (zoomOnClick: boolean) => {
    if (zoomOnClick) {
      this.addZoomStyles();
    } else {
      this.removeZoomStyles();
    }
  };

  private addZoomStyles() {
    document.body.classList.add("zoom-plugin-bls-zoom");
  }

  private removeZoomStyles() {
    document.body.classList.remove("zoom-plugin-bls-zoom");
  }
}


================================================
FILE: src/features/ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts
================================================
import { Plugin } from "obsidian";

import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

import { Feature } from "./Feature";
import { getEditorViewFromEditorState } from "./utils/getEditorViewFromEditorState";

import { DetectVisibleContentBoundariesViolation } from "../logic/DetectVisibleContentBoundariesViolation";
import { LoggerService } from "../services/LoggerService";

export interface CalculateHiddenContentRanges {
  calculateHiddenContentRanges(
    state: EditorState
  ): { from: number; to: number }[] | null;
}

export interface ZoomOut {
  zoomOut(view: EditorView): void;
}

export class ResetZoomWhenVisibleContentBoundariesViolatedFeature
  implements Feature
{
  private detectVisibleContentBoundariesViolation =
    new DetectVisibleContentBoundariesViolation(
      this.calculateHiddenContentRanges,
      {
        visibleContentBoundariesViolated: (state) =>
          this.visibleContentBoundariesViolated(state),
      }
    );

  constructor(
    private plugin: Plugin,
    private logger: LoggerService,
    private calculateHiddenContentRanges: CalculateHiddenContentRanges,
    private zoomOut: ZoomOut
  ) {}

  async load() {
    this.plugin.registerEditorExtension(
      this.detectVisibleContentBoundariesViolation.getExtension()
    );
  }

  async unload() {}

  private visibleContentBoundariesViolated(state: EditorState) {
    const l = this.logger.bind(
      "ResetZoomWhenVisibleContentBoundariesViolatedFeature:visibleContentBoundariesViolated"
    );
    l("visible content boundaries violated, zooming out");
    this.zoomOut.zoomOut(getEditorViewFromEditorState(state));
  }
}


================================================
FILE: src/features/SettingsTabFeature.ts
================================================
import { App, Plugin, PluginSettingTab, Setting } from "obsidian";

import { Feature } from "./Feature";

import { SettingsService } from "../services/SettingsService";

class ObsidianZoomPluginSettingTab extends PluginSettingTab {
  constructor(app: App, plugin: Plugin, private settings: SettingsService) {
    super(app, plugin);
  }

  display(): void {
    const { containerEl } = this;

    containerEl.empty();

    new Setting(containerEl)
      .setName("Zooming in when clicking on the bullet")
      .addToggle((toggle) => {
        toggle.setValue(this.settings.zoomOnClick).onChange(async (value) => {
          this.settings.zoomOnClick = value;
          await this.settings.save();
        });
      });

    new Setting(containerEl)
      .setName("Debug mode")
      .setDesc(
        "Open DevTools (Command+Option+I or Control+Shift+I) to copy the debug logs."
      )
      .addToggle((toggle) => {
        toggle.setValue(this.settings.debug).onChange(async (value) => {
          this.settings.debug = value;
          await this.settings.save();
        });
      });
  }
}

export class SettingsTabFeature implements Feature {
  constructor(private plugin: Plugin, private settings: SettingsService) {}

  async load() {
    this.plugin.addSettingTab(
      new ObsidianZoomPluginSettingTab(
        this.plugin.app,
        this.plugin,
        this.settings
      )
    );
  }

  async unload() {}
}


================================================
FILE: src/features/ZoomFeature.ts
================================================
import { Notice, Plugin } from "obsidian";

import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

import { Feature } from "./Feature";
import { isFoldingEnabled } from "./utils/isFoldingEnabled";

import { CalculateRangeForZooming } from "../logic/CalculateRangeForZooming";
import { KeepOnlyZoomedContentVisible } from "../logic/KeepOnlyZoomedContentVisible";
import { LoggerService } from "../services/LoggerService";
import { getEditorViewFromEditor } from "../utils/getEditorViewFromEditor";

export type ZoomInCallback = (view: EditorView, pos: number) => void;
export type ZoomOutCallback = (view: EditorView) => void;

export class ZoomFeature implements Feature {
  private zoomInCallbacks: ZoomInCallback[] = [];
  private zoomOutCallbacks: ZoomOutCallback[] = [];

  private keepOnlyZoomedContentVisible = new KeepOnlyZoomedContentVisible(
    this.logger
  );

  private calculateRangeForZooming = new CalculateRangeForZooming();

  constructor(private plugin: Plugin, private logger: LoggerService) {}

  public calculateVisibleContentRange(state: EditorState) {
    return this.keepOnlyZoomedContentVisible.calculateVisibleContentRange(
      state
    );
  }

  public calculateHiddenContentRanges(state: EditorState) {
    return this.keepOnlyZoomedContentVisible.calculateHiddenContentRanges(
      state
    );
  }

  public notifyAfterZoomIn(cb: ZoomInCallback) {
    this.zoomInCallbacks.push(cb);
  }

  public notifyAfterZoomOut(cb: ZoomOutCallback) {
    this.zoomOutCallbacks.push(cb);
  }

  public refreshZoom(view: EditorView) {
    const prevRange =
      this.keepOnlyZoomedContentVisible.calculateVisibleContentRange(
        view.state
      );

    if (!prevRange) {
      return;
    }

    const newRange = this.calculateRangeForZooming.calculateRangeForZooming(
      view.state,
      prevRange.from
    );

    if (!newRange) {
      return;
    }

    this.keepOnlyZoomedContentVisible.keepOnlyZoomedContentVisible(
      view,
      newRange.from,
      newRange.to,
      { scrollIntoView: false }
    );
  }

  public zoomIn(view: EditorView, pos: number) {
    const l = this.logger.bind("ZoomFeature:zoomIn");
    l("zooming in");

    if (!isFoldingEnabled(this.plugin.app)) {
      new Notice(
        `In order to zoom, you must first enable "Fold heading" and "Fold indent" under Settings -> Editor`
      );
      return;
    }

    const range = this.calculateRangeForZooming.calculateRangeForZooming(
      view.state,
      pos
    );

    if (!range) {
      l("unable to calculate range for zooming");
      return;
    }

    this.keepOnlyZoomedContentVisible.keepOnlyZoomedContentVisible(
      view,
      range.from,
      range.to
    );

    for (const cb of this.zoomInCallbacks) {
      cb(view, pos);
    }
  }

  public zoomOut(view: EditorView) {
    const l = this.logger.bind("ZoomFeature:zoomIn");
    l("zooming out");

    this.keepOnlyZoomedContentVisible.showAllContent(view);

    for (const cb of this.zoomOutCallbacks) {
      cb(view);
    }
  }

  async load() {
    this.plugin.registerEditorExtension(
      this.keepOnlyZoomedContentVisible.getExtension()
    );

    this.plugin.addCommand({
      id: "zoom-in",
      name: "Zoom in",
      icon: "zoom-in",
      editorCallback: (editor) => {
        const view = getEditorViewFromEditor(editor);
        this.zoomIn(view, view.state.selection.main.head);
      },
      hotkeys: [
        {
          modifiers: ["Mod"],
          key: ".",
        },
      ],
    });

    this.plugin.addCommand({
      id: "zoom-out",
      name: "Zoom out the entire document",
      icon: "zoom-out",
      editorCallback: (editor) => this.zoomOut(getEditorViewFromEditor(editor)),
      hotkeys: [
        {
          modifiers: ["Mod", "Shift"],
          key: ".",
        },
      ],
    });
  }

  async unload() {}
}


================================================
FILE: src/features/ZoomOnClickFeature.ts
================================================
import { Plugin } from "obsidian";

import { EditorView } from "@codemirror/view";

import { Feature } from "./Feature";

import { DetectClickOnBullet } from "../logic/DetectClickOnBullet";
import { SettingsService } from "../services/SettingsService";

export interface ZoomIn {
  zoomIn(view: EditorView, pos: number): void;
}

export class ZoomOnClickFeature implements Feature {
  private detectClickOnBullet = new DetectClickOnBullet(this.settings, {
    clickOnBullet: (view, pos) => this.clickOnBullet(view, pos),
  });

  constructor(
    private plugin: Plugin,
    private settings: SettingsService,
    private zoomIn: ZoomIn
  ) {}

  async load() {
    this.plugin.registerEditorExtension(
      this.detectClickOnBullet.getExtension()
    );
  }

  async unload() {}

  private clickOnBullet(view: EditorView, pos: number) {
    this.detectClickOnBullet.moveCursorToLineEnd(view, pos);
    this.zoomIn.zoomIn(view, pos);
  }
}


================================================
FILE: src/features/utils/getDocumentTitle.ts
================================================
import { editorViewField } from "obsidian";

import { EditorState } from "@codemirror/state";

export function getDocumentTitle(state: EditorState) {
  return state.field(editorViewField).getDisplayText();
}


================================================
FILE: src/features/utils/getEditorViewFromEditorState.ts
================================================
import { editorEditorField } from "obsidian";

import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

export function getEditorViewFromEditorState(state: EditorState): EditorView {
  return state.field(editorEditorField);
}


================================================
FILE: src/features/utils/isFoldingEnabled.ts
================================================
import { App } from "obsidian";

export function isFoldingEnabled(app: App) {
  const config: {
    foldHeading: boolean;
    foldIndent: boolean;
  } = {
    foldHeading: true,
    foldIndent: true,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    ...(app.vault as any).config,
  };

  return config.foldHeading && config.foldIndent;
}


================================================
FILE: src/logic/CalculateRangeForZooming.ts
================================================
import { foldable } from "@codemirror/language";
import { EditorState } from "@codemirror/state";

export class CalculateRangeForZooming {
  public calculateRangeForZooming(state: EditorState, pos: number) {
    const line = state.doc.lineAt(pos);
    const foldRange = foldable(state, line.from, line.to);

    if (!foldRange && /^\s*([-*+]|\d+\.)\s+/.test(line.text)) {
      return { from: line.from, to: line.to };
    }

    if (!foldRange) {
      return null;
    }

    return { from: line.from, to: foldRange.to };
  }
}


================================================
FILE: src/logic/CollectBreadcrumbs.ts
================================================
import { foldable } from "@codemirror/language";
import { EditorState } from "@codemirror/state";

import { cleanTitle } from "./utils/cleanTitle";

export interface Breadcrumb {
  title: string;
  pos: number | null;
}

export interface GetDocumentTitle {
  getDocumentTitle(state: EditorState): string;
}

export class CollectBreadcrumbs {
  constructor(private getDocumentTitle: GetDocumentTitle) {}

  public collectBreadcrumbs(state: EditorState, pos: number) {
    const breadcrumbs: Breadcrumb[] = [
      { title: this.getDocumentTitle.getDocumentTitle(state), pos: null },
    ];

    const posLine = state.doc.lineAt(pos);

    for (let i = 1; i < posLine.number; i++) {
      const line = state.doc.line(i);
      const f = foldable(state, line.from, line.to);
      if (f && f.to > posLine.from) {
        breadcrumbs.push({ title: cleanTitle(line.text), pos: line.from });
      }
    }

    breadcrumbs.push({
      title: cleanTitle(posLine.text),
      pos: posLine.from,
    });

    return breadcrumbs;
  }
}


================================================
FILE: src/logic/DetectClickOnBullet.ts
================================================
import { EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

import { isBulletPoint } from "./utils/isBulletPoint";

import { SettingsService } from "../services/SettingsService";

export interface ClickOnBullet {
  clickOnBullet(view: EditorView, pos: number): void;
}

export class DetectClickOnBullet {
  constructor(
    private settings: SettingsService,
    private clickOnBullet: ClickOnBullet
  ) {}

  getExtension() {
    return EditorView.domEventHandlers({
      click: this.detectClickOnBullet,
    });
  }

  public moveCursorToLineEnd(view: EditorView, pos: number) {
    const line = view.state.doc.lineAt(pos);

    view.dispatch({
      selection: EditorSelection.cursor(line.to),
    });
  }

  private detectClickOnBullet = (e: MouseEvent, view: EditorView) => {
    if (
      !this.settings.zoomOnClick ||
      !(e.target instanceof HTMLElement) ||
      !isBulletPoint(e.target)
    ) {
      return;
    }

    const pos = view.posAtDOM(e.target);
    this.clickOnBullet.clickOnBullet(view, pos);
  };
}


================================================
FILE: src/logic/DetectRangeBeforeVisibleRangeChanged.ts
================================================
import { EditorState, Transaction } from "@codemirror/state";

import { calculateVisibleContentBoundariesViolation } from "./utils/calculateVisibleContentBoundariesViolation";

export interface RangeBeforeVisibleRangeChanged {
  rangeBeforeVisibleRangeChanged(state: EditorState): void;
}

export interface CalculateHiddenContentRanges {
  calculateHiddenContentRanges(
    state: EditorState
  ): { from: number; to: number }[] | null;
}

export class DetectRangeBeforeVisibleRangeChanged {
  constructor(
    private calculateHiddenContentRanges: CalculateHiddenContentRanges,
    private rangeBeforeVisibleRangeChanged: RangeBeforeVisibleRangeChanged
  ) {}

  getExtension() {
    return EditorState.transactionExtender.of(
      this.detectVisibleContentBoundariesViolation
    );
  }

  private detectVisibleContentBoundariesViolation = (tr: Transaction): null => {
    const hiddenRanges =
      this.calculateHiddenContentRanges.calculateHiddenContentRanges(
        tr.startState
      );

    const { touchedBefore, touchedInside } =
      calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

    if (touchedBefore && !touchedInside) {
      setImmediate(() => {
        this.rangeBeforeVisibleRangeChanged.rangeBeforeVisibleRangeChanged(
          tr.state
        );
      });
    }

    return null;
  };
}


================================================
FILE: src/logic/DetectVisibleContentBoundariesViolation.ts
================================================
import { EditorState, Transaction } from "@codemirror/state";

import { calculateVisibleContentBoundariesViolation } from "./utils/calculateVisibleContentBoundariesViolation";

export interface VisibleContentBoundariesViolated {
  visibleContentBoundariesViolated(state: EditorState): void;
}

export interface CalculateHiddenContentRanges {
  calculateHiddenContentRanges(
    state: EditorState
  ): { from: number; to: number }[] | null;
}

export class DetectVisibleContentBoundariesViolation {
  constructor(
    private calculateHiddenContentRanges: CalculateHiddenContentRanges,
    private visibleContentBoundariesViolated: VisibleContentBoundariesViolated
  ) {}

  getExtension() {
    return EditorState.transactionExtender.of(
      this.detectVisibleContentBoundariesViolation
    );
  }

  private detectVisibleContentBoundariesViolation = (tr: Transaction): null => {
    const hiddenRanges =
      this.calculateHiddenContentRanges.calculateHiddenContentRanges(
        tr.startState
      );

    const { touchedOutside, touchedInside } =
      calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

    if (touchedOutside && touchedInside) {
      setImmediate(() => {
        this.visibleContentBoundariesViolated.visibleContentBoundariesViolated(
          tr.state
        );
      });
    }

    return null;
  };
}


================================================
FILE: src/logic/KeepOnlyZoomedContentVisible.ts
================================================
import { EditorState, Extension, StateField } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";

import { zoomInEffect, zoomOutEffect } from "./utils/effects";
import { rangeSetToArray } from "./utils/rangeSetToArray";

import { LoggerService } from "../services/LoggerService";

const zoomMarkHidden = Decoration.replace({ block: true });

const zoomStateField = StateField.define<DecorationSet>({
  create: () => {
    return Decoration.none;
  },

  update: (value, tr) => {
    value = value.map(tr.changes);

    for (const e of tr.effects) {
      if (e.is(zoomInEffect)) {
        value = value.update({ filter: () => false });

        if (e.value.from > 0) {
          value = value.update({
            add: [zoomMarkHidden.range(0, e.value.from - 1)],
          });
        }

        if (e.value.to < tr.newDoc.length) {
          value = value.update({
            add: [zoomMarkHidden.range(e.value.to + 1, tr.newDoc.length)],
          });
        }
      }

      if (e.is(zoomOutEffect)) {
        value = value.update({ filter: () => false });
      }
    }

    return value;
  },

  provide: (zoomStateField) => EditorView.decorations.from(zoomStateField),
});

export class KeepOnlyZoomedContentVisible {
  constructor(private logger: LoggerService) {}

  public getExtension(): Extension {
    return zoomStateField;
  }

  public calculateHiddenContentRanges(state: EditorState) {
    return rangeSetToArray(state.field(zoomStateField));
  }

  public calculateVisibleContentRange(state: EditorState) {
    const hidden = this.calculateHiddenContentRanges(state);

    if (hidden.length === 1) {
      const [a] = hidden;

      if (a.from === 0) {
        return { from: a.to + 1, to: state.doc.length };
      } else {
        return { from: 0, to: a.from - 1 };
      }
    }

    if (hidden.length === 2) {
      const [a, b] = hidden;

      return { from: a.to + 1, to: b.from - 1 };
    }

    return null;
  }

  public keepOnlyZoomedContentVisible(
    view: EditorView,
    from: number,
    to: number,
    options: { scrollIntoView?: boolean } = {}
  ) {
    const { scrollIntoView } = { ...{ scrollIntoView: true }, ...options };

    const effect = zoomInEffect.of({ from, to });

    this.logger.log(
      "KeepOnlyZoomedContent:keepOnlyZoomedContentVisible",
      "keep only zoomed content visible",
      effect.value.from,
      effect.value.to
    );

    view.dispatch({
      effects: [effect],
    });

    if (scrollIntoView) {
      view.dispatch({
        effects: [
          EditorView.scrollIntoView(view.state.selection.main, {
            y: "start",
          }),
        ],
      });
    }
  }

  public showAllContent(view: EditorView) {
    this.logger.log("KeepOnlyZoomedContent:showAllContent", "show all content");

    view.dispatch({ effects: [zoomOutEffect.of()] });
    view.dispatch({
      effects: [
        EditorView.scrollIntoView(view.state.selection.main, {
          y: "center",
        }),
      ],
    });
  }
}


================================================
FILE: src/logic/LimitSelectionOnZoomingIn.ts
================================================
import { EditorState, Transaction } from "@codemirror/state";

import { calculateLimitedSelection } from "./utils/calculateLimitedSelection";
import { ZoomInStateEffect, isZoomInEffect } from "./utils/effects";

import { LoggerService } from "../services/LoggerService";

export class LimitSelectionOnZoomingIn {
  constructor(private logger: LoggerService) {}

  getExtension() {
    return EditorState.transactionFilter.of(this.limitSelectionOnZoomingIn);
  }

  private limitSelectionOnZoomingIn = (tr: Transaction) => {
    const e = tr.effects.find<ZoomInStateEffect>(isZoomInEffect);

    if (!e) {
      return tr;
    }

    const newSelection = calculateLimitedSelection(
      tr.newSelection,
      e.value.from,
      e.value.to
    );

    if (!newSelection) {
      return tr;
    }

    this.logger.log(
      "LimitSelectionOnZoomingIn:limitSelectionOnZoomingIn",
      "limiting selection",
      newSelection.toJSON()
    );

    return [tr, { selection: newSelection }];
  };
}


================================================
FILE: src/logic/LimitSelectionWhenZoomedIn.ts
================================================
import { EditorState, Transaction } from "@codemirror/state";

import { calculateLimitedSelection } from "./utils/calculateLimitedSelection";

import { LoggerService } from "../services/LoggerService";

export interface CalculateVisibleContentRange {
  calculateVisibleContentRange(
    state: EditorState
  ): { from: number; to: number } | null;
}

export class LimitSelectionWhenZoomedIn {
  constructor(
    private logger: LoggerService,
    private calculateVisibleContentRange: CalculateVisibleContentRange
  ) {}

  public getExtension() {
    return EditorState.transactionFilter.of(this.limitSelectionWhenZoomedIn);
  }

  private limitSelectionWhenZoomedIn = (tr: Transaction) => {
    if (!tr.selection || !tr.isUserEvent("select")) {
      return tr;
    }

    const range =
      this.calculateVisibleContentRange.calculateVisibleContentRange(tr.state);

    if (!range) {
      return tr;
    }

    const newSelection = calculateLimitedSelection(
      tr.newSelection,
      range.from,
      range.to
    );

    if (!newSelection) {
      return tr;
    }

    this.logger.log(
      "LimitSelectionWhenZoomedIn:limitSelectionWhenZoomedIn",
      "limiting selection",
      newSelection.toJSON()
    );

    return [tr, { selection: newSelection }];
  };
}


================================================
FILE: src/logic/RenderNavigationHeader.ts
================================================
import { StateEffect, StateField } from "@codemirror/state";
import { EditorView, showPanel } from "@codemirror/view";

import { renderHeader } from "./utils/renderHeader";

import { LoggerService } from "../services/LoggerService";

export interface Breadcrumb {
  title: string;
  pos: number | null;
}

export interface ZoomIn {
  zoomIn(view: EditorView, pos: number): void;
}

export interface ZoomOut {
  zoomOut(view: EditorView): void;
}

interface HeaderState {
  breadcrumbs: Breadcrumb[];
  onClick: (view: EditorView, pos: number | null) => void;
}

const showHeaderEffect = StateEffect.define<HeaderState>();
const hideHeaderEffect = StateEffect.define<void>();

const headerState = StateField.define<HeaderState | null>({
  create: () => null,
  update: (value, tr) => {
    for (const e of tr.effects) {
      if (e.is(showHeaderEffect)) {
        value = e.value;
      }
      if (e.is(hideHeaderEffect)) {
        value = null;
      }
    }
    return value;
  },
  provide: (f) =>
    showPanel.from(f, (state) => {
      if (!state) {
        return null;
      }

      return (view) => ({
        top: true,
        dom: renderHeader(view.dom.ownerDocument, {
          breadcrumbs: state.breadcrumbs,
          onClick: (pos) => state.onClick(view, pos),
        }),
      });
    }),
});

export class RenderNavigationHeader {
  getExtension() {
    return headerState;
  }

  constructor(
    private logger: LoggerService,
    private zoomIn: ZoomIn,
    private zoomOut: ZoomOut
  ) {}

  public showHeader(view: EditorView, breadcrumbs: Breadcrumb[]) {
    const l = this.logger.bind("ToggleNavigationHeaderLogic:showHeader");
    l("show header");

    view.dispatch({
      effects: [
        showHeaderEffect.of({
          breadcrumbs,
          onClick: this.onClick,
        }),
      ],
    });
  }

  public hideHeader(view: EditorView) {
    const l = this.logger.bind("ToggleNavigationHeaderLogic:hideHeader");
    l("hide header");

    view.dispatch({
      effects: [hideHeaderEffect.of()],
    });
  }

  private onClick = (view: EditorView, pos: number | null) => {
    if (pos === null) {
      this.zoomOut.zoomOut(view);
    } else {
      this.zoomIn.zoomIn(view, pos);
    }
  };
}


================================================
FILE: src/logic/__tests__/CalculateRangeForZooming.test.ts
================================================
import { EditorState } from "@codemirror/state";

import { CalculateRangeForZooming } from "../CalculateRangeForZooming";

jest.mock("@codemirror/language", () => {
  return {
    foldable: jest.fn(),
  };
});

const foldable: jest.Mock = jest.requireMock("@codemirror/language").foldable;

beforeEach(() => {
  foldable.mockReturnValue(null);
});

test("should return nothing if block is unfoldable", () => {
  foldable.mockReturnValue(null);
  const state = EditorState.create({
    doc: "# header\n\nline1\n",
  });
  const calculateRangeForZooming = new CalculateRangeForZooming();

  const x = calculateRangeForZooming.calculateRangeForZooming(state, 1);

  expect(x).toBeNull();
});

test("should return range from line start if block is foldable", () => {
  foldable.mockReturnValue({ from: 8, to: 16 });
  const state = EditorState.create({
    doc: "# header\n\nline1\n",
  });
  const calculateRangeForZooming = new CalculateRangeForZooming();

  const x = calculateRangeForZooming.calculateRangeForZooming(state, 1);

  expect(x).toStrictEqual({ from: 0, to: 16 });
});

test("should return range of current line if block is unfoldable but line is list item", () => {
  foldable.mockReturnValue(null);
  const state = EditorState.create({
    doc: "line\n\n- list\n\nline",
  });
  const calculateRangeForZooming = new CalculateRangeForZooming();

  const x = calculateRangeForZooming.calculateRangeForZooming(state, 8);

  expect(x).toStrictEqual({ from: 6, to: 12 });
});


================================================
FILE: src/logic/__tests__/CollectBreadcrumbs.test.ts
================================================
import { EditorState } from "@codemirror/state";

import { CollectBreadcrumbs } from "../CollectBreadcrumbs";

jest.mock("@codemirror/language", () => {
  return {
    foldable: jest.fn(),
  };
});

const getDocumentTitle = { getDocumentTitle: () => "Document" };
const foldable: jest.Mock = jest.requireMock("@codemirror/language").foldable;

test("should return breadcrumbs based on folable zones that should include input position", () => {
  const state = EditorState.create({
    doc: "# a\n\n# b\n\n## c\n\n- 1\n\t- 2\n\t\t- 3\n\n### d\n\n# e\n\nf",
    //    0123 4 5678 9 01234 5 6789 0 1234 5 6 7890 1 234567 8 9012 3 45
    //                  1            2             3             4
  });
  foldable.mockImplementation((state, from) => {
    if (from === 0) return { from: 0, to: 4 };
    if (from === 5) return { from: 5, to: 38 };
    if (from === 10) return { from: 10, to: 38 };
    if (from === 16) return { from: 16, to: 29 };
    if (from === 20) return { from: 20, to: 29 };
    if (from === 32) return { from: 32, to: 38 };
    if (from === 39) return { from: 39, to: 44 };
    return null;
  });

  const collectBreadcrumbs = new CollectBreadcrumbs(getDocumentTitle);

  const b = collectBreadcrumbs.collectBreadcrumbs(state, 28);

  expect(b).toStrictEqual([
    { title: "Document", pos: null },
    { title: "b", pos: 5 },
    { title: "c", pos: 10 },
    { title: "1", pos: 16 },
    { title: "2", pos: 20 },
    { title: "3", pos: 25 },
  ]);
});


================================================
FILE: src/logic/__tests__/DetectClickOnBullet.test.ts
================================================
/**
 * @jest-environment jsdom
 */
import { EditorState } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";

import { DetectClickOnBullet } from "../DetectClickOnBullet";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const settings: any = { zoomOnClick: true };
const clickOnBullet = { clickOnBullet: jest.fn() };
const detectClickOnBullet = new DetectClickOnBullet(settings, clickOnBullet);
const decs = Decoration.set([
  Decoration.mark({ class: "list-bullet" }).range(0, 1),
  Decoration.mark({ class: "cm-formatting-list" }).range(6, 7),
  Decoration.mark({ class: "other" }).range(8, 9),
]);
const view = new EditorView({
  state: EditorState.create({
    doc: "- 1\n  - 2",
    extensions: [
      detectClickOnBullet.getExtension(),
      EditorView.decorations.of(decs),
    ],
  }),
  parent: document.body,
});

test("should detect click on span.list-bullet", () => {
  view.dom.querySelector<HTMLSpanElement>(".list-bullet").click();

  expect(clickOnBullet.clickOnBullet).toBeCalled();
});

test("should detect click on span.cm-formatting-list", () => {
  view.dom.querySelector<HTMLSpanElement>(".cm-formatting-list").click();

  expect(clickOnBullet.clickOnBullet).toBeCalled();
});

test("should not detect click on other elements", () => {
  view.dom.querySelector<HTMLSpanElement>(".other").click();

  expect(clickOnBullet.clickOnBullet).not.toBeCalled();
});


================================================
FILE: src/logic/utils/__tests__/calculateLimitedSelection.test.ts
================================================
import { EditorSelection } from "@codemirror/state";

import { calculateLimitedSelection } from "../calculateLimitedSelection";

test("should limit selection if visible area is smaller", () => {
  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);
  const visibleArea = [12, 18];

  const newSelection = calculateLimitedSelection(
    selection,
    visibleArea[0],
    visibleArea[1]
  );

  expect(newSelection.from).toBe(12);
  expect(newSelection.to).toBe(18);
});

test("should limit selection if visible area ends before selection", () => {
  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);
  const visibleArea = [1, 18];

  const newSelection = calculateLimitedSelection(
    selection,
    visibleArea[0],
    visibleArea[1]
  );

  expect(newSelection.from).toBe(10);
  expect(newSelection.to).toBe(18);
});

test("should limit selection if visible area starts after selection", () => {
  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);
  const visibleArea = [12, 30];

  const newSelection = calculateLimitedSelection(
    selection,
    visibleArea[0],
    visibleArea[1]
  );

  expect(newSelection.from).toBe(12);
  expect(newSelection.to).toBe(20);
});

test("should not limit selection if visible area is bigger", () => {
  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);
  const visibleArea = [1, 30];

  const newSelection = calculateLimitedSelection(
    selection,
    visibleArea[0],
    visibleArea[1]
  );

  expect(newSelection).toBeNull();
});


================================================
FILE: src/logic/utils/__tests__/calculateVisibleContentBoundariesViolation.test.ts
================================================
import { EditorState } from "@codemirror/state";

import { calculateVisibleContentBoundariesViolation } from "../calculateVisibleContentBoundariesViolation";

const state = EditorState.create({ doc: "line1\nline2\nline3" });
const hiddenRanges = [
  { from: 0, to: 5 },
  { from: 12, to: 17 },
];

test("should calculate correctly when changes are touching area before visible content", () => {
  const tr = state.update({ changes: { from: 0, to: 1, insert: "X" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeTruthy();
  expect(res.touchedAfter).toBeFalsy();
  expect(res.touchedOutside).toBeTruthy();
  expect(res.touchedInside).toBeFalsy();
});

test("should calculate correctly when changes are touching area after visible content", () => {
  const tr = state.update({ changes: { from: 12, to: 13, insert: "X" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeFalsy();
  expect(res.touchedAfter).toBeTruthy();
  expect(res.touchedOutside).toBeTruthy();
  expect(res.touchedInside).toBeFalsy();
});

test("should calculate correctly when changes are touching visible content", () => {
  const tr = state.update({ changes: { from: 6, to: 7, insert: "X" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeFalsy();
  expect(res.touchedAfter).toBeFalsy();
  expect(res.touchedOutside).toBeFalsy();
  expect(res.touchedInside).toBeTruthy();
});

test("should calculate correctly when changes are crossing first boundary of visible content", () => {
  const tr = state.update({ changes: { from: 4, to: 7, insert: "X" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeTruthy();
  expect(res.touchedAfter).toBeFalsy();
  expect(res.touchedOutside).toBeTruthy();
  expect(res.touchedInside).toBeTruthy();
});

test("should calculate correctly when changes are crossing second boundary of visible content", () => {
  const tr = state.update({ changes: { from: 8, to: 13, insert: "X" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeFalsy();
  expect(res.touchedAfter).toBeTruthy();
  expect(res.touchedOutside).toBeTruthy();
  expect(res.touchedInside).toBeTruthy();
});

test("should calculate correctly when changes are removing newline just before first boundary of visible content", () => {
  const tr = state.update({ changes: { from: 5, to: 6, insert: "" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeTruthy();
  expect(res.touchedAfter).toBeFalsy();
  expect(res.touchedOutside).toBeTruthy();
  expect(res.touchedInside).toBeTruthy();
});

test("should calculate correctly when changes are removing newline just after second boundary of visible content", () => {
  const tr = state.update({ changes: { from: 11, to: 12, insert: "" } });

  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);

  expect(res.touchedBefore).toBeFalsy();
  expect(res.touchedAfter).toBeTruthy();
  expect(res.touchedOutside).toBeTruthy();
  expect(res.touchedInside).toBeTruthy();
});


================================================
FILE: src/logic/utils/__tests__/cleanTitle.test.ts
================================================
import { cleanTitle } from "../cleanTitle";

test("should clean title", () => {
  expect(cleanTitle(" Text with spaces ")).toBe("Text with spaces");
  expect(cleanTitle("# Some header")).toBe("Some header");
  expect(cleanTitle("## Some header")).toBe("Some header");
  expect(cleanTitle("### Some header")).toBe("Some header");
  expect(cleanTitle("#### Some header")).toBe("Some header");
  expect(cleanTitle("#\tSome header")).toBe("Some header");
  expect(cleanTitle("#Some invalid header")).toBe("#Some invalid header");
  expect(cleanTitle("- Some bullet")).toBe("Some bullet");
  expect(cleanTitle("+ Some bullet")).toBe("Some bullet");
  expect(cleanTitle("* Some bullet")).toBe("Some bullet");
  expect(cleanTitle("  * Some bullet  ")).toBe("Some bullet");
  expect(cleanTitle("\t*\tSome bullet  ")).toBe("Some bullet");
  expect(cleanTitle("\t*Some invalid bullet  ")).toBe("*Some invalid bullet");
});


================================================
FILE: src/logic/utils/__tests__/rangeSetToArray.test.ts
================================================
import { RangeSetBuilder } from "@codemirror/state";
import { Decoration } from "@codemirror/view";

import { rangeSetToArray } from "../rangeSetToArray";

test("should return array of ranges", () => {
  const dec = Decoration.replace({});
  const rsb = new RangeSetBuilder();
  rsb.add(1, 2, dec);
  rsb.add(10, 20, dec);
  rsb.add(30, 40, dec);
  const rs = rsb.finish();

  const ranges = rangeSetToArray(rs);

  expect(ranges).toStrictEqual([
    { from: 1, to: 2 },
    { from: 10, to: 20 },
    { from: 30, to: 40 },
  ]);
});


================================================
FILE: src/logic/utils/__tests__/renderHeader.test.ts
================================================
/**
 * @jest-environment jsdom
 */
import { renderHeader } from "../renderHeader";

test("should render html", () => {
  const h = renderHeader(document, {
    breadcrumbs: [
      { title: "Document", pos: null },
      { title: "header 1", pos: 10 },
    ],
    onClick: () => {},
  });

  expect(h.outerHTML).toBe(
    `<div class="zoom-plugin-header"><a class="zoom-plugin-title" data-pos="null">Document</a><span class="zoom-plugin-delimiter"></span><a class="zoom-plugin-title" data-pos="10">header 1</a></div>`
  );
});

test("should handle click on document link", () => {
  const onClick = jest.fn();
  const h = renderHeader(document, {
    breadcrumbs: [
      { title: "Document", pos: null },
      { title: "header 1", pos: 10 },
    ],
    onClick,
  });

  h.querySelectorAll<HTMLSpanElement>(".zoom-plugin-title")[0].click();

  expect(onClick).toHaveBeenCalledWith(null);
});

test("should handle click on header link", () => {
  const onClick = jest.fn();
  const h = renderHeader(document, {
    breadcrumbs: [
      { title: "Document", pos: null },
      { title: "header 1", pos: 10 },
    ],
    onClick,
  });

  h.querySelectorAll<HTMLSpanElement>(".zoom-plugin-title")[1].click();

  expect(onClick).toHaveBeenCalledWith(10);
});


================================================
FILE: src/logic/utils/calculateLimitedSelection.ts
================================================
import { EditorSelection } from "@codemirror/state";

export function calculateLimitedSelection(
  selection: EditorSelection,
  from: number,
  to: number
) {
  const mainSelection = selection.main;

  const newSelection = EditorSelection.range(
    Math.min(Math.max(mainSelection.anchor, from), to),
    Math.min(Math.max(mainSelection.head, from), to),
    mainSelection.goalColumn
  );

  const shouldUpdate =
    selection.ranges.length > 1 ||
    newSelection.anchor !== mainSelection.anchor ||
    newSelection.head !== mainSelection.head;

  return shouldUpdate ? newSelection : null;
}


================================================
FILE: src/logic/utils/calculateVisibleContentBoundariesViolation.ts
================================================
import { Transaction } from "@codemirror/state";

export function calculateVisibleContentBoundariesViolation(
  tr: Transaction,
  hiddenRanges: Array<{ from: number; to: number }>
) {
  let touchedBefore = false;
  let touchedAfter = false;
  let touchedInside = false;

  const t = (f: number, t: number) => Boolean(tr.changes.touchesRange(f, t));

  if (hiddenRanges.length === 2) {
    const [a, b] = hiddenRanges;

    touchedBefore = t(a.from, a.to);
    touchedInside = t(a.to + 1, b.from - 1);
    touchedAfter = t(b.from, b.to);
  }

  if (hiddenRanges.length === 1) {
    const [a] = hiddenRanges;

    if (a.from === 0) {
      touchedBefore = t(a.from, a.to);
      touchedInside = t(a.to + 1, tr.newDoc.length);
    } else {
      touchedInside = t(0, a.from - 1);
      touchedAfter = t(a.from, a.to);
    }
  }

  const touchedOutside = touchedBefore || touchedAfter;

  const res = {
    touchedOutside,
    touchedBefore,
    touchedAfter,
    touchedInside,
  };

  return res;
}


================================================
FILE: src/logic/utils/cleanTitle.ts
================================================
export function cleanTitle(title: string) {
  return title
    .trim()
    .replace(/^#+(\s)/, "$1")
    .replace(/^([-+*]|\d+\.)(\s)/, "$2")
    .trim();
}


================================================
FILE: src/logic/utils/effects.ts
================================================
import { StateEffect } from "@codemirror/state";

export interface ZoomInRange {
  from: number;
  to: number;
}

export type ZoomInStateEffect = StateEffect<ZoomInRange>;

export const zoomInEffect = StateEffect.define<ZoomInRange>();

export const zoomOutEffect = StateEffect.define<void>();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isZoomInEffect(e: StateEffect<any>): e is ZoomInStateEffect {
  return e.is(zoomInEffect);
}


================================================
FILE: src/logic/utils/isBulletPoint.ts
================================================
export function isBulletPoint(e: HTMLElement) {
  return (
    e instanceof HTMLSpanElement &&
    (e.classList.contains("list-bullet") ||
      e.classList.contains("cm-formatting-list"))
  );
}


================================================
FILE: src/logic/utils/rangeSetToArray.ts
================================================
import { RangeSet, RangeValue } from "@codemirror/state";

export function rangeSetToArray<T extends RangeValue>(
  rs: RangeSet<T>
): Array<{ from: number; to: number }> {
  const res = [];
  const i = rs.iter();
  while (i.value !== null) {
    res.push({ from: i.from, to: i.to });
    i.next();
  }
  return res;
}


================================================
FILE: src/logic/utils/renderHeader.ts
================================================
export function renderHeader(
  doc: Document,
  ctx: {
    breadcrumbs: Array<{ title: string; pos: number | null }>;
    onClick: (pos: number | null) => void;
  }
) {
  const { breadcrumbs, onClick } = ctx;

  const h = doc.createElement("div");
  h.classList.add("zoom-plugin-header");

  for (let i = 0; i < breadcrumbs.length; i++) {
    if (i > 0) {
      const d = doc.createElement("span");
      d.classList.add("zoom-plugin-delimiter");
      d.innerText = ">";
      h.append(d);
    }

    const breadcrumb = breadcrumbs[i];
    const b = doc.createElement("a");
    b.classList.add("zoom-plugin-title");
    b.dataset.pos = String(breadcrumb.pos);
    b.appendChild(doc.createTextNode(breadcrumb.title));
    b.addEventListener("click", (e) => {
      e.preventDefault();
      const t = e.target as HTMLAnchorElement;
      const pos = t.dataset.pos;
      onClick(pos === "null" ? null : Number(pos));
    });
    h.appendChild(b);
  }

  return h;
}


================================================
FILE: src/services/LoggerService.ts
================================================
import { SettingsService } from "./SettingsService";

export class LoggerService {
  constructor(private settings: SettingsService) {}

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  log(method: string, ...args: any[]) {
    if (!this.settings.debug) {
      return;
    }

    console.info(method, ...args);
  }

  bind(method: string) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return (...args: any[]) => this.log(method, ...args);
  }
}


================================================
FILE: src/services/SettingsService.ts
================================================
import { Platform } from "obsidian";

export interface ObsidianZoomPluginSettings {
  debug: boolean;
  zoomOnClick: boolean;
}

interface ObsidianZoomPluginSettingsJson {
  debug: boolean;
  zoomOnClick: boolean;
  zoomOnClickMobile: boolean;
}

const DEFAULT_SETTINGS: ObsidianZoomPluginSettingsJson = {
  debug: false,
  zoomOnClick: true,
  zoomOnClickMobile: false,
};

export interface Storage {
  loadData(): Promise<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
  saveData(settigns: any): Promise<void>; // eslint-disable-line @typescript-eslint/no-explicit-any
}

type K = keyof ObsidianZoomPluginSettings;
type V<T extends K> = ObsidianZoomPluginSettings[T];
type Callback<T extends K> = (cb: V<T>) => void;

const zoomOnClickProp = Platform.isDesktop
  ? "zoomOnClick"
  : "zoomOnClickMobile";

const mappingToJson = {
  zoomOnClick: zoomOnClickProp,
  debug: "debug",
} as {
  [key in keyof ObsidianZoomPluginSettings]: keyof ObsidianZoomPluginSettingsJson;
};

export class SettingsService implements ObsidianZoomPluginSettings {
  private storage: Storage;
  private values: ObsidianZoomPluginSettingsJson;
  private handlers: Map<K, Set<Callback<K>>>;

  constructor(storage: Storage) {
    this.storage = storage;
    this.handlers = new Map();
  }

  get debug() {
    return this.values.debug;
  }
  set debug(value: boolean) {
    this.set("debug", value);
  }

  get zoomOnClick() {
    return this.values[mappingToJson.zoomOnClick];
  }
  set zoomOnClick(value: boolean) {
    this.set("zoomOnClick", value);
  }

  onChange<T extends K>(key: T, cb: Callback<T>) {
    if (!this.handlers.has(key)) {
      this.handlers.set(key, new Set());
    }

    this.handlers.get(key).add(cb);
  }

  removeCallback<T extends K>(key: T, cb: Callback<T>): void {
    const handlers = this.handlers.get(key);

    if (handlers) {
      handlers.delete(cb);
    }
  }

  async load() {
    this.values = Object.assign(
      {},
      DEFAULT_SETTINGS,
      await this.storage.loadData()
    );
  }

  async save() {
    await this.storage.saveData(this.values);
  }

  private set<T extends K>(key: T, value: V<K>): void {
    this.values[mappingToJson[key]] = value;
    const callbacks = this.handlers.get(key);

    if (!callbacks) {
      return;
    }

    for (const cb of callbacks.values()) {
      cb(value);
    }
  }
}


================================================
FILE: src/utils/getEditorViewFromEditor.ts
================================================
import { Editor } from "obsidian";

import { EditorView } from "@codemirror/view";

export function getEditorViewFromEditor(editor: Editor): EditorView {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  return (editor as any).cm;
}


================================================
FILE: styles.css
================================================
.zoom-plugin-header {
  display: flex;
  flex-wrap: wrap;
  margin: var(--file-margins);
  margin-top: var(--size-4-2);
  margin-bottom: var(--size-4-2);
}

.zoom-plugin-title {
  text-overflow: ellipsis;
  white-space: nowrap;
}

.zoom-plugin-delimiter {
  display: inline-block;
  padding: 0 var(--size-4-2);
}

.zoom-plugin-bls-zoom .cm-editor .cm-formatting-list-ol,
.zoom-plugin-bls-zoom .cm-editor .cm-formatting-list-ul {
  cursor: pointer;
}

.zoom-plugin-bls-zoom
  .markdown-source-view.mod-cm6
  .cm-fold-indicator
  .collapse-indicator {
  margin-right: 6px;
  padding-right: 0;
}

.zoom-plugin-bls-zoom
  .markdown-source-view.mod-cm6
  .cm-line:not(.cm-active):not(.HyperMD-header):not(.HyperMD-task-line)
  .cm-fold-indicator
  .collapse-indicator {
  margin-right: 18px;
  padding-right: 0;
}

.markdown-source-view.mod-cm6 .cm-panels {
  border-bottom-color: var(--background-modifier-border);
}


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "baseUrl": ".",
    "inlineSourceMap": true,
    "inlineSources": true,
    "module": "ESNext",
    "target": "es6",
    "allowJs": true,
    "noImplicitAny": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "lib": ["dom", "es5", "scripthost", "es2015"]
  },
  "include": ["**/*.ts"]
}


================================================
FILE: versions.json
================================================
{
  "1.1.2": "1.1.16",
  "1.1.1": "1.0.0",
  "1.1.0": "1.0.0",
  "1.0.2": "1.0.0",
  "1.0.1": "0.15.9",
  "1.0.0": "0.15.9",
  "0.3.0": "0.15.9",
  "0.2.8": "0.13.19",
  "0.2.7": "0.13.14",
  "0.2.6": "0.13.14",
  "0.2.5": "0.13.14",
  "0.2.4": "0.13.14",
  "0.2.3": "0.13.10",
  "0.2.2": "0.13.10",
  "0.2.1": "0.13.10",
  "0.2.0": "0.13.10",
  "0.1.2": "0.12.3",
  "0.1.1": "0.12.3",
  "0.1.0": "0.12.3"
}
Download .txt
gitextract_snxu8gyu/

├── .eslintrc.js
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   └── bug_report.md
│   └── workflows/
│       └── build.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .prettierrc.json
├── LICENSE
├── README.md
├── babel.config.js
├── jest/
│   ├── global-setup.js
│   ├── global-teardown.js
│   ├── md-spec-transformer.js
│   ├── obsidian-environment.js
│   ├── obsidian-expect.js
│   └── test-globals.d.ts
├── jest.config.json
├── manifest.json
├── package.json
├── release.mjs
├── rollup.config.mjs
├── specs/
│   ├── LimitSelectionFeature.spec.md
│   ├── ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md
│   └── ZoomFeature.spec.md
├── src/
│   ├── ObsidianZoomPlugin.ts
│   ├── ObsidianZoomPluginWithTests.ts
│   ├── features/
│   │   ├── Feature.ts
│   │   ├── HeaderNavigationFeature.ts
│   │   ├── LimitSelectionFeature.ts
│   │   ├── ListsStylesFeature.ts
│   │   ├── ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts
│   │   ├── SettingsTabFeature.ts
│   │   ├── ZoomFeature.ts
│   │   ├── ZoomOnClickFeature.ts
│   │   └── utils/
│   │       ├── getDocumentTitle.ts
│   │       ├── getEditorViewFromEditorState.ts
│   │       └── isFoldingEnabled.ts
│   ├── logic/
│   │   ├── CalculateRangeForZooming.ts
│   │   ├── CollectBreadcrumbs.ts
│   │   ├── DetectClickOnBullet.ts
│   │   ├── DetectRangeBeforeVisibleRangeChanged.ts
│   │   ├── DetectVisibleContentBoundariesViolation.ts
│   │   ├── KeepOnlyZoomedContentVisible.ts
│   │   ├── LimitSelectionOnZoomingIn.ts
│   │   ├── LimitSelectionWhenZoomedIn.ts
│   │   ├── RenderNavigationHeader.ts
│   │   ├── __tests__/
│   │   │   ├── CalculateRangeForZooming.test.ts
│   │   │   ├── CollectBreadcrumbs.test.ts
│   │   │   └── DetectClickOnBullet.test.ts
│   │   └── utils/
│   │       ├── __tests__/
│   │       │   ├── calculateLimitedSelection.test.ts
│   │       │   ├── calculateVisibleContentBoundariesViolation.test.ts
│   │       │   ├── cleanTitle.test.ts
│   │       │   ├── rangeSetToArray.test.ts
│   │       │   └── renderHeader.test.ts
│   │       ├── calculateLimitedSelection.ts
│   │       ├── calculateVisibleContentBoundariesViolation.ts
│   │       ├── cleanTitle.ts
│   │       ├── effects.ts
│   │       ├── isBulletPoint.ts
│   │       ├── rangeSetToArray.ts
│   │       └── renderHeader.ts
│   ├── services/
│   │   ├── LoggerService.ts
│   │   └── SettingsService.ts
│   └── utils/
│       └── getEditorViewFromEditor.ts
├── styles.css
├── tsconfig.json
└── versions.json
Download .txt
SYMBOL INDEX (210 symbols across 38 files)

FILE: jest/global-setup.js
  constant KILL_CMD (line 11) | const KILL_CMD =
  constant OBSIDIAN_CONFIG_DIR (line 15) | const OBSIDIAN_CONFIG_DIR =
  constant OBSIDIAN_CONFIG_PATH (line 19) | const OBSIDIAN_CONFIG_PATH = OBSIDIAN_CONFIG_DIR + "/obsidian.json";
  constant OBSIDIAN_APP_CMD (line 20) | const OBSIDIAN_APP_CMD =
  constant OBSIDIAN_LOCAL_STORAGE_PATH (line 24) | const OBSIDIAN_LOCAL_STORAGE_PATH =
  constant OBISDIAN_TEST_VAULT_ID (line 30) | const OBISDIAN_TEST_VAULT_ID = "5a15473126091111";
  constant VAULT_DIR (line 31) | const VAULT_DIR = process.cwd() + "/vault";
  function wait (line 37) | function wait(t) {
  function runForAWhile (line 41) | function runForAWhile({ timeout, fileToCheck }) {
  function prepareObsidian (line 65) | async function prepareObsidian() {
  function prepareVault (line 97) | async function prepareVault() {

FILE: jest/md-spec-transformer.js
  function isHeader (line 1) | function isHeader(line) {
  function isAction (line 5) | function isAction(line) {
  function isCodeBlock (line 9) | function isCodeBlock(line) {
  function parseState (line 13) | function parseState(l) {
  function parseApplyState (line 38) | function parseApplyState(l) {
  function parseAssertState (line 47) | function parseAssertState(l) {
  function parseSimulateKeydown (line 56) | function parseSimulateKeydown(l) {
  function parsePlatform (line 67) | function parsePlatform(l) {
  function parseExecuteCommandById (line 78) | function parseExecuteCommandById(l) {
  function parseReplaceSelection (line 89) | function parseReplaceSelection(l) {
  function parseAction (line 100) | function parseAction(l) {
  function parseTest (line 124) | function parseTest(l) {
  function parseTests (line 144) | function parseTests(l) {
  class LinesIterator (line 156) | class LinesIterator {
    method constructor (line 157) | constructor(lines) {
    method line (line 163) | get line() {
    method isEnded (line 167) | isEnded() {
    method nextNotEmpty (line 171) | nextNotEmpty() {
    method next (line 177) | next() {

FILE: jest/obsidian-environment.js
  method setup (line 7) | async setup() {
  method createCommand (line 20) | createCommand(type) {
  method initWs (line 24) | async initWs() {
  method runCommand (line 39) | async runCommand(type, data) {
  method teardown (line 59) | async teardown() {

FILE: jest/obsidian-expect.js
  function stateToString (line 3) | function stateToString(state) {
  method toEqualEditorState (line 49) | async toEqualEditorState(receivedState, expectedState) {

FILE: jest/test-globals.d.ts
  type Matchers (line 2) | interface Matchers<R> {
  type IFold (line 8) | interface IFold {
  type ISelection (line 13) | interface ISelection {
  type IState (line 18) | interface IState {

FILE: release.mjs
  function increaseVersion (line 5) | function increaseVersion(version, releaseType) {
  function main (line 24) | async function main() {

FILE: src/ObsidianZoomPlugin.ts
  type Window (line 16) | interface Window {
  class ObsidianZoomPlugin (line 21) | class ObsidianZoomPlugin extends Plugin {
    method onload (line 25) | async onload() {
    method onunload (line 81) | async onunload() {
    method getZoomRange (line 91) | public getZoomRange(editor: Editor) {
    method zoomOut (line 114) | public zoomOut(editor: Editor) {
    method zoomIn (line 118) | public zoomIn(editor: Editor, line: number) {
    method refreshZoom (line 124) | public refreshZoom(editor: Editor) {

FILE: src/ObsidianZoomPluginWithTests.ts
  class ObsidianZoomPluginWithTests (line 21) | class ObsidianZoomPluginWithTests extends ObsidianZoomPlugin {
    method wait (line 24) | wait(time: number) {
    method executeCommandById (line 28) | executeCommandById(id: string) {
    method replaceSelection (line 33) | replaceSelection(char: string) {
    method simulateKeydown (line 37) | simulateKeydown(keys: string) {
    method load (line 89) | async load() {
    method prepareForTests (line 100) | async prepareForTests() {
    method connect (line 127) | async connect() {
    method applyState (line 173) | applyState(state: IState | string | string[]) {
    method getCurrentState (line 203) | getCurrentState(): IState {
    method parseState (line 237) | parseState(content: string | string[]): IState {

FILE: src/features/Feature.ts
  type Feature (line 1) | interface Feature {

FILE: src/features/HeaderNavigationFeature.ts
  type ZoomIn (line 15) | interface ZoomIn {
  type ZoomOut (line 19) | interface ZoomOut {
  type NotifyAfterZoomIn (line 23) | interface NotifyAfterZoomIn {
  type NotifyAfterZoomOut (line 27) | interface NotifyAfterZoomOut {
  type CalculateHiddenContentRanges (line 31) | interface CalculateHiddenContentRanges {
  type CalculateVisibleContentRange (line 37) | interface CalculateVisibleContentRange {
  class ShowHeaderAfterZoomIn (line 43) | class ShowHeaderAfterZoomIn implements Feature {
    method constructor (line 44) | constructor(
    method load (line 50) | async load() {
    method unload (line 60) | async unload() {}
  class HideHeaderAfterZoomOut (line 63) | class HideHeaderAfterZoomOut implements Feature {
    method constructor (line 64) | constructor(
    method load (line 69) | async load() {
    method unload (line 75) | async unload() {}
  class UpdateHeaderAfterRangeBeforeVisibleRangeChanged (line 78) | class UpdateHeaderAfterRangeBeforeVisibleRangeChanged implements Feature {
    method constructor (line 88) | constructor(
    method load (line 96) | async load() {
    method unload (line 102) | async unload() {}
    method rangeBeforeVisibleRangeChanged (line 104) | private rangeBeforeVisibleRangeChanged(state: EditorState) {
  class HeaderNavigationFeature (line 118) | class HeaderNavigationFeature implements Feature {
    method constructor (line 149) | constructor(
    method load (line 160) | async load() {
    method unload (line 170) | async unload() {

FILE: src/features/LimitSelectionFeature.ts
  type CalculateVisibleContentRange (line 11) | interface CalculateVisibleContentRange {
  class LimitSelectionFeature (line 17) | class LimitSelectionFeature implements Feature {
    method constructor (line 26) | constructor(
    method load (line 32) | async load() {
    method unload (line 42) | async unload() {}

FILE: src/features/ListsStylesFeature.ts
  class ListsStylesFeature (line 5) | class ListsStylesFeature implements Feature {
    method constructor (line 6) | constructor(private settings: SettingsService) {}
    method load (line 8) | async load() {
    method unload (line 16) | async unload() {
    method addZoomStyles (line 33) | private addZoomStyles() {
    method removeZoomStyles (line 37) | private removeZoomStyles() {

FILE: src/features/ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts
  type CalculateHiddenContentRanges (line 12) | interface CalculateHiddenContentRanges {
  type ZoomOut (line 18) | interface ZoomOut {
  class ResetZoomWhenVisibleContentBoundariesViolatedFeature (line 22) | class ResetZoomWhenVisibleContentBoundariesViolatedFeature
    method constructor (line 34) | constructor(
    method load (line 41) | async load() {
    method unload (line 47) | async unload() {}
    method visibleContentBoundariesViolated (line 49) | private visibleContentBoundariesViolated(state: EditorState) {

FILE: src/features/SettingsTabFeature.ts
  class ObsidianZoomPluginSettingTab (line 7) | class ObsidianZoomPluginSettingTab extends PluginSettingTab {
    method constructor (line 8) | constructor(app: App, plugin: Plugin, private settings: SettingsServic...
    method display (line 12) | display(): void {
  class SettingsTabFeature (line 40) | class SettingsTabFeature implements Feature {
    method constructor (line 41) | constructor(private plugin: Plugin, private settings: SettingsService) {}
    method load (line 43) | async load() {
    method unload (line 53) | async unload() {}

FILE: src/features/ZoomFeature.ts
  type ZoomInCallback (line 14) | type ZoomInCallback = (view: EditorView, pos: number) => void;
  type ZoomOutCallback (line 15) | type ZoomOutCallback = (view: EditorView) => void;
  class ZoomFeature (line 17) | class ZoomFeature implements Feature {
    method constructor (line 27) | constructor(private plugin: Plugin, private logger: LoggerService) {}
    method calculateVisibleContentRange (line 29) | public calculateVisibleContentRange(state: EditorState) {
    method calculateHiddenContentRanges (line 35) | public calculateHiddenContentRanges(state: EditorState) {
    method notifyAfterZoomIn (line 41) | public notifyAfterZoomIn(cb: ZoomInCallback) {
    method notifyAfterZoomOut (line 45) | public notifyAfterZoomOut(cb: ZoomOutCallback) {
    method refreshZoom (line 49) | public refreshZoom(view: EditorView) {
    method zoomIn (line 76) | public zoomIn(view: EditorView, pos: number) {
    method zoomOut (line 108) | public zoomOut(view: EditorView) {
    method load (line 119) | async load() {
    method unload (line 154) | async unload() {}

FILE: src/features/ZoomOnClickFeature.ts
  type ZoomIn (line 10) | interface ZoomIn {
  class ZoomOnClickFeature (line 14) | class ZoomOnClickFeature implements Feature {
    method constructor (line 19) | constructor(
    method load (line 25) | async load() {
    method unload (line 31) | async unload() {}
    method clickOnBullet (line 33) | private clickOnBullet(view: EditorView, pos: number) {

FILE: src/features/utils/getDocumentTitle.ts
  function getDocumentTitle (line 5) | function getDocumentTitle(state: EditorState) {

FILE: src/features/utils/getEditorViewFromEditorState.ts
  function getEditorViewFromEditorState (line 6) | function getEditorViewFromEditorState(state: EditorState): EditorView {

FILE: src/features/utils/isFoldingEnabled.ts
  function isFoldingEnabled (line 3) | function isFoldingEnabled(app: App) {

FILE: src/logic/CalculateRangeForZooming.ts
  class CalculateRangeForZooming (line 4) | class CalculateRangeForZooming {
    method calculateRangeForZooming (line 5) | public calculateRangeForZooming(state: EditorState, pos: number) {

FILE: src/logic/CollectBreadcrumbs.ts
  type Breadcrumb (line 6) | interface Breadcrumb {
  type GetDocumentTitle (line 11) | interface GetDocumentTitle {
  class CollectBreadcrumbs (line 15) | class CollectBreadcrumbs {
    method constructor (line 16) | constructor(private getDocumentTitle: GetDocumentTitle) {}
    method collectBreadcrumbs (line 18) | public collectBreadcrumbs(state: EditorState, pos: number) {

FILE: src/logic/DetectClickOnBullet.ts
  type ClickOnBullet (line 8) | interface ClickOnBullet {
  class DetectClickOnBullet (line 12) | class DetectClickOnBullet {
    method constructor (line 13) | constructor(
    method getExtension (line 18) | getExtension() {
    method moveCursorToLineEnd (line 24) | public moveCursorToLineEnd(view: EditorView, pos: number) {

FILE: src/logic/DetectRangeBeforeVisibleRangeChanged.ts
  type RangeBeforeVisibleRangeChanged (line 5) | interface RangeBeforeVisibleRangeChanged {
  type CalculateHiddenContentRanges (line 9) | interface CalculateHiddenContentRanges {
  class DetectRangeBeforeVisibleRangeChanged (line 15) | class DetectRangeBeforeVisibleRangeChanged {
    method constructor (line 16) | constructor(
    method getExtension (line 21) | getExtension() {

FILE: src/logic/DetectVisibleContentBoundariesViolation.ts
  type VisibleContentBoundariesViolated (line 5) | interface VisibleContentBoundariesViolated {
  type CalculateHiddenContentRanges (line 9) | interface CalculateHiddenContentRanges {
  class DetectVisibleContentBoundariesViolation (line 15) | class DetectVisibleContentBoundariesViolation {
    method constructor (line 16) | constructor(
    method getExtension (line 21) | getExtension() {

FILE: src/logic/KeepOnlyZoomedContentVisible.ts
  class KeepOnlyZoomedContentVisible (line 47) | class KeepOnlyZoomedContentVisible {
    method constructor (line 48) | constructor(private logger: LoggerService) {}
    method getExtension (line 50) | public getExtension(): Extension {
    method calculateHiddenContentRanges (line 54) | public calculateHiddenContentRanges(state: EditorState) {
    method calculateVisibleContentRange (line 58) | public calculateVisibleContentRange(state: EditorState) {
    method keepOnlyZoomedContentVisible (line 80) | public keepOnlyZoomedContentVisible(
    method showAllContent (line 112) | public showAllContent(view: EditorView) {

FILE: src/logic/LimitSelectionOnZoomingIn.ts
  class LimitSelectionOnZoomingIn (line 8) | class LimitSelectionOnZoomingIn {
    method constructor (line 9) | constructor(private logger: LoggerService) {}
    method getExtension (line 11) | getExtension() {

FILE: src/logic/LimitSelectionWhenZoomedIn.ts
  type CalculateVisibleContentRange (line 7) | interface CalculateVisibleContentRange {
  class LimitSelectionWhenZoomedIn (line 13) | class LimitSelectionWhenZoomedIn {
    method constructor (line 14) | constructor(
    method getExtension (line 19) | public getExtension() {

FILE: src/logic/RenderNavigationHeader.ts
  type Breadcrumb (line 8) | interface Breadcrumb {
  type ZoomIn (line 13) | interface ZoomIn {
  type ZoomOut (line 17) | interface ZoomOut {
  type HeaderState (line 21) | interface HeaderState {
  class RenderNavigationHeader (line 58) | class RenderNavigationHeader {
    method getExtension (line 59) | getExtension() {
    method constructor (line 63) | constructor(
    method showHeader (line 69) | public showHeader(view: EditorView, breadcrumbs: Breadcrumb[]) {
    method hideHeader (line 83) | public hideHeader(view: EditorView) {

FILE: src/logic/utils/calculateLimitedSelection.ts
  function calculateLimitedSelection (line 3) | function calculateLimitedSelection(

FILE: src/logic/utils/calculateVisibleContentBoundariesViolation.ts
  function calculateVisibleContentBoundariesViolation (line 3) | function calculateVisibleContentBoundariesViolation(

FILE: src/logic/utils/cleanTitle.ts
  function cleanTitle (line 1) | function cleanTitle(title: string) {

FILE: src/logic/utils/effects.ts
  type ZoomInRange (line 3) | interface ZoomInRange {
  type ZoomInStateEffect (line 8) | type ZoomInStateEffect = StateEffect<ZoomInRange>;
  function isZoomInEffect (line 15) | function isZoomInEffect(e: StateEffect<any>): e is ZoomInStateEffect {

FILE: src/logic/utils/isBulletPoint.ts
  function isBulletPoint (line 1) | function isBulletPoint(e: HTMLElement) {

FILE: src/logic/utils/rangeSetToArray.ts
  function rangeSetToArray (line 3) | function rangeSetToArray<T extends RangeValue>(

FILE: src/logic/utils/renderHeader.ts
  function renderHeader (line 1) | function renderHeader(

FILE: src/services/LoggerService.ts
  class LoggerService (line 3) | class LoggerService {
    method constructor (line 4) | constructor(private settings: SettingsService) {}
    method log (line 7) | log(method: string, ...args: any[]) {
    method bind (line 15) | bind(method: string) {

FILE: src/services/SettingsService.ts
  type ObsidianZoomPluginSettings (line 3) | interface ObsidianZoomPluginSettings {
  type ObsidianZoomPluginSettingsJson (line 8) | interface ObsidianZoomPluginSettingsJson {
  constant DEFAULT_SETTINGS (line 14) | const DEFAULT_SETTINGS: ObsidianZoomPluginSettingsJson = {
  type Storage (line 20) | interface Storage {
  type K (line 25) | type K = keyof ObsidianZoomPluginSettings;
  type V (line 26) | type V<T extends K> = ObsidianZoomPluginSettings[T];
  type Callback (line 27) | type Callback<T extends K> = (cb: V<T>) => void;
  class SettingsService (line 40) | class SettingsService implements ObsidianZoomPluginSettings {
    method constructor (line 45) | constructor(storage: Storage) {
    method debug (line 50) | get debug() {
    method debug (line 53) | set debug(value: boolean) {
    method zoomOnClick (line 57) | get zoomOnClick() {
    method zoomOnClick (line 60) | set zoomOnClick(value: boolean) {
    method onChange (line 64) | onChange<T extends K>(key: T, cb: Callback<T>) {
    method removeCallback (line 72) | removeCallback<T extends K>(key: T, cb: Callback<T>): void {
    method load (line 80) | async load() {
    method save (line 88) | async save() {
    method set (line 92) | private set<T extends K>(key: T, value: V<K>): void {

FILE: src/utils/getEditorViewFromEditor.ts
  function getEditorViewFromEditor (line 5) | function getEditorViewFromEditor(editor: Editor): EditorView {
Condensed preview — 67 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (104K chars).
[
  {
    "path": ".eslintrc.js",
    "chars": 386,
    "preview": "module.exports = {\n  env: {\n    browser: true,\n    es2021: true,\n  },\n  extends: [\n    \"eslint:recommended\",\n    \"plugin"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 35,
    "preview": "custom: [\"https://vslinko.cb.id/\"]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 705,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG]\"\nlabels: bug\nassignees: \"\"\n---\n\n**Describe "
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 3594,
    "preview": "name: Build\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  release:\n    types:\n   "
  },
  {
    "path": ".gitignore",
    "chars": 35,
    "preview": "/node_modules/\r\n/vault/\r\n/main.js\r\n"
  },
  {
    "path": ".husky/pre-commit",
    "chars": 55,
    "preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpm run lint\n"
  },
  {
    "path": ".prettierrc.json",
    "chars": 196,
    "preview": "{\n  \"importOrder\": [\n    \"^obsidian$\",\n    \"^@codemirror/.*$\",\n    \"<THIRD_PARTY_MODULES>\",\n    \"^\\\\./\",\n    \"^\\\\.\\\\./\"\n"
  },
  {
    "path": "LICENSE",
    "chars": 1061,
    "preview": "Copyright (c) 2021 Viacheslav Slinko\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof th"
  },
  {
    "path": "README.md",
    "chars": 2917,
    "preview": "# Obsidian Zoom\r\n\r\n![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vslinko/obsidian-zoom"
  },
  {
    "path": "babel.config.js",
    "chars": 133,
    "preview": "module.exports = {\n  presets: [\n    [\"@babel/preset-env\", { targets: { node: \"current\" } }],\n    \"@babel/preset-typescri"
  },
  {
    "path": "jest/global-setup.js",
    "chars": 6876,
    "preview": "const cp = require(\"child_process\");\nconst mkdirp = require(\"mkdirp\");\nconst path = require(\"path\");\nconst fs = require("
  },
  {
    "path": "jest/global-teardown.js",
    "chars": 398,
    "preview": "const cp = require(\"child_process\");\nconst fs = require(\"fs\");\nconst debug = require(\"debug\")(\"jest-obsidian\");\n\nmodule."
  },
  {
    "path": "jest/md-spec-transformer.js",
    "chars": 4744,
    "preview": "function isHeader(line) {\n  return line.startsWith(\"# \");\n}\n\nfunction isAction(line) {\n  return line.startsWith(\"- \");\n}"
  },
  {
    "path": "jest/obsidian-environment.js",
    "chars": 1486,
    "preview": "const { TestEnvironment } = require(\"jest-environment-node\");\nconst WebSocket = require(\"ws\");\n\nlet idSeq = 1;\n\nmodule.e"
  },
  {
    "path": "jest/obsidian-expect.js",
    "chars": 2317,
    "preview": "const jestExpect = global.expect;\n\nfunction stateToString(state) {\n  const lines = state.value.split(\"\\n\");\n\n  const sel"
  },
  {
    "path": "jest/test-globals.d.ts",
    "chars": 860,
    "preview": "declare namespace jest {\n  interface Matchers<R> {\n    toEqualEditorState(s: string): Promise<R>;\n    toEqualEditorState"
  },
  {
    "path": "jest.config.json",
    "chars": 475,
    "preview": "{\n  \"clearMocks\": true,\n  \"transform\": {\n    \"\\\\.ts$\": \"babel-jest\",\n    \"\\\\.spec\\\\.md$\": \"./jest/md-spec-transformer.js"
  },
  {
    "path": "manifest.json",
    "chars": 250,
    "preview": "{\n  \"id\": \"obsidian-zoom\",\n  \"name\": \"Zoom\",\n  \"version\": \"1.1.2\",\n  \"minAppVersion\": \"1.1.16\",\n  \"description\": \"Zoom i"
  },
  {
    "path": "package.json",
    "chars": 1630,
    "preview": "{\n  \"name\": \"obsidian-zoom\",\n  \"version\": \"1.1.2\",\n  \"description\": \"Zoom into heading and lists.\",\n  \"main\": \"main.js\","
  },
  {
    "path": "release.mjs",
    "chars": 2649,
    "preview": "import inquirer from \"inquirer\";\nimport { spawnSync } from \"node:child_process\";\nimport { readFileSync, writeFileSync } "
  },
  {
    "path": "rollup.config.mjs",
    "chars": 611,
    "preview": "import commonjs from \"@rollup/plugin-commonjs\";\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\nimport typesc"
  },
  {
    "path": "specs/LimitSelectionFeature.spec.md",
    "chars": 1188,
    "preview": "# Should limit selection on zooming in\n\n- applyState:\n\n```md\ntext\n\n# 1|\n\ntext\n\n## 1.1|\n\ntext\n\n# 2\n\ntext\n```\n\n- execute: "
  },
  {
    "path": "specs/ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md",
    "chars": 582,
    "preview": "# Should reset zoom when first boundary of visible content is violated\n\n- applyState:\n\n```md\ntext\n\n|# 1\n\ntext\n```\n\n- exe"
  },
  {
    "path": "specs/ZoomFeature.spec.md",
    "chars": 488,
    "preview": "# Should zoom in\n\n- applyState:\n\n```md\ntext\n\n# 1\n\ntext\n\n## 1.1|\n\ntext\n\n# 2\n\ntext\n```\n\n- execute: `obsidian-zoom:zoom-in`"
  },
  {
    "path": "src/ObsidianZoomPlugin.ts",
    "chars": 3587,
    "preview": "import { Editor, Plugin } from \"obsidian\";\n\nimport { Feature } from \"./features/Feature\";\nimport { HeaderNavigationFeatu"
  },
  {
    "path": "src/ObsidianZoomPluginWithTests.ts",
    "chars": 8069,
    "preview": "import { editorEditorField } from \"obsidian\";\n\nimport { foldEffect, foldedRanges } from \"@codemirror/language\";\nimport {"
  },
  {
    "path": "src/features/Feature.ts",
    "chars": 81,
    "preview": "export interface Feature {\n  load(): Promise<void>;\n  unload(): Promise<void>;\n}\n"
  },
  {
    "path": "src/features/HeaderNavigationFeature.ts",
    "chars": 5013,
    "preview": "import { Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from \"@codemi"
  },
  {
    "path": "src/features/LimitSelectionFeature.ts",
    "chars": 1175,
    "preview": "import { Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\n\nimport { Feature } from \"./Feature"
  },
  {
    "path": "src/features/ListsStylesFeature.ts",
    "chars": 893,
    "preview": "import { Feature } from \"./Feature\";\n\nimport { SettingsService } from \"../services/SettingsService\";\n\nexport class Lists"
  },
  {
    "path": "src/features/ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts",
    "chars": 1673,
    "preview": "import { Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from \"@codemi"
  },
  {
    "path": "src/features/SettingsTabFeature.ts",
    "chars": 1427,
    "preview": "import { App, Plugin, PluginSettingTab, Setting } from \"obsidian\";\n\nimport { Feature } from \"./Feature\";\n\nimport { Setti"
  },
  {
    "path": "src/features/ZoomFeature.ts",
    "chars": 3886,
    "preview": "import { Notice, Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from "
  },
  {
    "path": "src/features/ZoomOnClickFeature.ts",
    "chars": 941,
    "preview": "import { Plugin } from \"obsidian\";\n\nimport { EditorView } from \"@codemirror/view\";\n\nimport { Feature } from \"./Feature\";"
  },
  {
    "path": "src/features/utils/getDocumentTitle.ts",
    "chars": 208,
    "preview": "import { editorViewField } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\n\nexport function getDocume"
  },
  {
    "path": "src/features/utils/getEditorViewFromEditorState.ts",
    "chars": 266,
    "preview": "import { editorEditorField } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } fr"
  },
  {
    "path": "src/features/utils/isFoldingEnabled.ts",
    "chars": 359,
    "preview": "import { App } from \"obsidian\";\n\nexport function isFoldingEnabled(app: App) {\n  const config: {\n    foldHeading: boolean"
  },
  {
    "path": "src/logic/CalculateRangeForZooming.ts",
    "chars": 530,
    "preview": "import { foldable } from \"@codemirror/language\";\nimport { EditorState } from \"@codemirror/state\";\n\nexport class Calculat"
  },
  {
    "path": "src/logic/CollectBreadcrumbs.ts",
    "chars": 1027,
    "preview": "import { foldable } from \"@codemirror/language\";\nimport { EditorState } from \"@codemirror/state\";\n\nimport { cleanTitle }"
  },
  {
    "path": "src/logic/DetectClickOnBullet.ts",
    "chars": 1069,
    "preview": "import { EditorSelection } from \"@codemirror/state\";\nimport { EditorView } from \"@codemirror/view\";\n\nimport { isBulletPo"
  },
  {
    "path": "src/logic/DetectRangeBeforeVisibleRangeChanged.ts",
    "chars": 1330,
    "preview": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateVisibleContentBoundariesViolation } fro"
  },
  {
    "path": "src/logic/DetectVisibleContentBoundariesViolation.ts",
    "chars": 1346,
    "preview": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateVisibleContentBoundariesViolation } fro"
  },
  {
    "path": "src/logic/KeepOnlyZoomedContentVisible.ts",
    "chars": 3040,
    "preview": "import { EditorState, Extension, StateField } from \"@codemirror/state\";\nimport { Decoration, DecorationSet, EditorView }"
  },
  {
    "path": "src/logic/LimitSelectionOnZoomingIn.ts",
    "chars": 997,
    "preview": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateLimitedSelection } from \"./utils/calcul"
  },
  {
    "path": "src/logic/LimitSelectionWhenZoomedIn.ts",
    "chars": 1278,
    "preview": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateLimitedSelection } from \"./utils/calcul"
  },
  {
    "path": "src/logic/RenderNavigationHeader.ts",
    "chars": 2231,
    "preview": "import { StateEffect, StateField } from \"@codemirror/state\";\nimport { EditorView, showPanel } from \"@codemirror/view\";\n\n"
  },
  {
    "path": "src/logic/__tests__/CalculateRangeForZooming.test.ts",
    "chars": 1485,
    "preview": "import { EditorState } from \"@codemirror/state\";\n\nimport { CalculateRangeForZooming } from \"../CalculateRangeForZooming\""
  },
  {
    "path": "src/logic/__tests__/CollectBreadcrumbs.test.ts",
    "chars": 1476,
    "preview": "import { EditorState } from \"@codemirror/state\";\n\nimport { CollectBreadcrumbs } from \"../CollectBreadcrumbs\";\n\njest.mock"
  },
  {
    "path": "src/logic/__tests__/DetectClickOnBullet.test.ts",
    "chars": 1432,
    "preview": "/**\n * @jest-environment jsdom\n */\nimport { EditorState } from \"@codemirror/state\";\nimport { Decoration, EditorView } fr"
  },
  {
    "path": "src/logic/utils/__tests__/calculateLimitedSelection.test.ts",
    "chars": 1573,
    "preview": "import { EditorSelection } from \"@codemirror/state\";\n\nimport { calculateLimitedSelection } from \"../calculateLimitedSele"
  },
  {
    "path": "src/logic/utils/__tests__/calculateVisibleContentBoundariesViolation.test.ts",
    "chars": 3288,
    "preview": "import { EditorState } from \"@codemirror/state\";\n\nimport { calculateVisibleContentBoundariesViolation } from \"../calcula"
  },
  {
    "path": "src/logic/utils/__tests__/cleanTitle.test.ts",
    "chars": 913,
    "preview": "import { cleanTitle } from \"../cleanTitle\";\n\ntest(\"should clean title\", () => {\n  expect(cleanTitle(\" Text with spaces \""
  },
  {
    "path": "src/logic/utils/__tests__/rangeSetToArray.test.ts",
    "chars": 533,
    "preview": "import { RangeSetBuilder } from \"@codemirror/state\";\nimport { Decoration } from \"@codemirror/view\";\n\nimport { rangeSetTo"
  },
  {
    "path": "src/logic/utils/__tests__/renderHeader.test.ts",
    "chars": 1257,
    "preview": "/**\n * @jest-environment jsdom\n */\nimport { renderHeader } from \"../renderHeader\";\n\ntest(\"should render html\", () => {\n "
  },
  {
    "path": "src/logic/utils/calculateLimitedSelection.ts",
    "chars": 596,
    "preview": "import { EditorSelection } from \"@codemirror/state\";\n\nexport function calculateLimitedSelection(\n  selection: EditorSele"
  },
  {
    "path": "src/logic/utils/calculateVisibleContentBoundariesViolation.ts",
    "chars": 998,
    "preview": "import { Transaction } from \"@codemirror/state\";\n\nexport function calculateVisibleContentBoundariesViolation(\n  tr: Tran"
  },
  {
    "path": "src/logic/utils/cleanTitle.ts",
    "chars": 157,
    "preview": "export function cleanTitle(title: string) {\n  return title\n    .trim()\n    .replace(/^#+(\\s)/, \"$1\")\n    .replace(/^([-+"
  },
  {
    "path": "src/logic/utils/effects.ts",
    "chars": 467,
    "preview": "import { StateEffect } from \"@codemirror/state\";\n\nexport interface ZoomInRange {\n  from: number;\n  to: number;\n}\n\nexport"
  },
  {
    "path": "src/logic/utils/isBulletPoint.ts",
    "chars": 196,
    "preview": "export function isBulletPoint(e: HTMLElement) {\n  return (\n    e instanceof HTMLSpanElement &&\n    (e.classList.contains"
  },
  {
    "path": "src/logic/utils/rangeSetToArray.ts",
    "chars": 319,
    "preview": "import { RangeSet, RangeValue } from \"@codemirror/state\";\n\nexport function rangeSetToArray<T extends RangeValue>(\n  rs: "
  },
  {
    "path": "src/logic/utils/renderHeader.ts",
    "chars": 967,
    "preview": "export function renderHeader(\n  doc: Document,\n  ctx: {\n    breadcrumbs: Array<{ title: string; pos: number | null }>;\n "
  },
  {
    "path": "src/services/LoggerService.ts",
    "chars": 490,
    "preview": "import { SettingsService } from \"./SettingsService\";\n\nexport class LoggerService {\n  constructor(private settings: Setti"
  },
  {
    "path": "src/services/SettingsService.ts",
    "chars": 2358,
    "preview": "import { Platform } from \"obsidian\";\n\nexport interface ObsidianZoomPluginSettings {\n  debug: boolean;\n  zoomOnClick: boo"
  },
  {
    "path": "src/utils/getEditorViewFromEditor.ts",
    "chars": 250,
    "preview": "import { Editor } from \"obsidian\";\n\nimport { EditorView } from \"@codemirror/view\";\n\nexport function getEditorViewFromEdi"
  },
  {
    "path": "styles.css",
    "chars": 913,
    "preview": ".zoom-plugin-header {\n  display: flex;\n  flex-wrap: wrap;\n  margin: var(--file-margins);\n  margin-top: var(--size-4-2);\n"
  },
  {
    "path": "tsconfig.json",
    "chars": 335,
    "preview": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"inlineSourceMap\": true,\n    \"inlineSources\": true,\n    \"module\": \"ESNe"
  },
  {
    "path": "versions.json",
    "chars": 408,
    "preview": "{\n  \"1.1.2\": \"1.1.16\",\n  \"1.1.1\": \"1.0.0\",\n  \"1.1.0\": \"1.0.0\",\n  \"1.0.2\": \"1.0.0\",\n  \"1.0.1\": \"0.15.9\",\n  \"1.0.0\": \"0.15"
  }
]

About this extraction

This page contains the full source code of the vslinko/obsidian-zoom GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 67 files (92.3 KB), approximately 26.0k tokens, and a symbol index with 210 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!