[
  {
    "path": ".eslintrc.js",
    "content": "module.exports = {\n  env: {\n    browser: true,\n    es2021: true,\n  },\n  extends: [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"prettier\",\n  ],\n  parser: \"@typescript-eslint/parser\",\n  parserOptions: {\n    ecmaVersion: 13,\n    sourceType: \"module\",\n  },\n  plugins: [\"@typescript-eslint\"],\n  rules: {\n    \"@typescript-eslint/no-empty-function\": 0,\n  },\n};\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "custom: [\"https://vslinko.cb.id/\"]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG]\"\nlabels: bug\nassignees: \"\"\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Environment (please complete the following information):**\n - OS: [e.g. Desktop, iOS, Android]\n - Obsidian Version: [e.g. 1.1.16]\n - Plugin Version: [e.g. 1.1.1]\n\n**Additional context**\nAdd any other context about the problem here."
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  release:\n    types:\n      - created\n\nenv:\n  OBSIDIAN_VERSION: \"1.1.16\"\n  OBSIDIAN_FLATPAK_COMMIT: f885ddeab17171e10486bce93b83e84494614cf654e8c70a237f5294b05b4c55\n\njobs:\n  lint:\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Install dependencies\n        run: npm ci\n      - name: Lint\n        run: |\n          npm run lint\n  test-on-linux:\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Install Obsidian\n        run: |\n          sudo apt update\n          sudo apt install flatpak dbus-x11 xvfb\n          flatpak remote-add --user flathub https://flathub.org/repo/flathub.flatpakrepo\n          flatpak install --user -y flathub md.obsidian.Obsidian\n          flatpak update --user -y --commit=$OBSIDIAN_FLATPAK_COMMIT md.obsidian.Obsidian\n      - name: Checkout\n        uses: actions/checkout@v3\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Install dependencies\n        run: npm ci\n      - name: Test\n        run: |\n          npm run build-with-tests\n          Xvfb -ac :0 -screen 0 1280x1024x16 &\n          export DISPLAY=:0\n          export $(dbus-launch)\n          npm test\n  test-on-osx:\n    runs-on: macos-12\n    steps:\n      - name: Install Obsidian\n        run: |\n          wget -q https://github.com/obsidianmd/obsidian-releases/releases/download/v$OBSIDIAN_VERSION/Obsidian-$OBSIDIAN_VERSION-universal.dmg\n          sudo hdiutil attach Obsidian-$OBSIDIAN_VERSION-universal.dmg\n          sudo cp -rf \"/Volumes/Obsidian $OBSIDIAN_VERSION-universal/Obsidian.app\" /Applications\n          sudo hdiutil detach \"/Volumes/Obsidian $OBSIDIAN_VERSION-universal\"\n      - name: Checkout\n        uses: actions/checkout@v3\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Install dependencies\n        run: npm ci\n      - name: Test\n        run: |\n          npm run build-with-tests\n          npm test\n  release:\n    if: ${{ github.event_name == 'release' }}\n    runs-on: ubuntu-22.04\n    needs: [lint, test-on-linux, test-on-osx]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Install dependencies\n        run: npm ci\n      - name: Build\n        run: npm run build\n      - name: Upload main.js\n        uses: actions/upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ github.event.release.upload_url }}\n          asset_path: main.js\n          asset_name: main.js\n          asset_content_type: text/javascript\n      - name: Upload manifest.json\n        uses: actions/upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ github.event.release.upload_url }}\n          asset_path: manifest.json\n          asset_name: manifest.json\n          asset_content_type: application/json\n      - name: Upload styles.css\n        uses: actions/upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ github.event.release.upload_url }}\n          asset_path: styles.css\n          asset_name: styles.css\n          asset_content_type: text/css\n"
  },
  {
    "path": ".gitignore",
    "content": "/node_modules/\r\n/vault/\r\n/main.js\r\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpm run lint\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"importOrder\": [\n    \"^obsidian$\",\n    \"^@codemirror/.*$\",\n    \"<THIRD_PARTY_MODULES>\",\n    \"^\\\\./\",\n    \"^\\\\.\\\\./\"\n  ],\n  \"importOrderSeparation\": true,\n  \"importOrderSortSpecifiers\": true\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2021 Viacheslav Slinko\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Obsidian Zoom\r\n\r\n![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vslinko/obsidian-zoom/release.yml?style=for-the-badge)\r\n![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/vslinko/obsidian-zoom?style=for-the-badge&sort=semver)\r\n\r\n**Zoom into heading and lists**\r\n\r\n⁉️ [Discuss ideas or ask a question](https://github.com/vslinko/obsidian-zoom/discussions)<br>\r\n⚙️ [Follow the development process](https://github.com/users/vslinko/projects/3/views/1)<br>\r\n🐛 [Report issues](https://github.com/vslinko/obsidian-zoom/issues)\r\n\r\n## Demo\r\n\r\n![Demo](https://raw.githubusercontent.com/vslinko/obsidian-zoom/main/demo.gif)\r\n\r\n## How to install\r\n\r\n### From within Obsidian\r\n\r\nYou can activate this plugin within Obsidian by doing the following:\r\n\r\n- Open Settings > Third-party plugin\r\n- Make sure Safe mode is off\r\n- Click Browse community plugins\r\n- Search for \"Zoom\"\r\n- Click Install\r\n- Once installed, close the community plugins window and activate the newly installed plugin\r\n\r\n### Manual installation\r\n\r\nDownload `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.\r\n\r\n## Features\r\n\r\n### Zoom in to a specific list or heading\r\n\r\nHide everything except the list/heading and its content.\r\n\r\n| Command                      |       Default hotkey (Windows/Linux)        |             Default hotkey (MacOS)             |\r\n| ---------------------------- | :-----------------------------------------: | :--------------------------------------------: |\r\n| Zoom in                      |         <kbd>Ctrl</kbd><kbd>.</kbd>         |         <kbd>Command</kbd><kbd>.</kbd>         |\r\n| Zoom out the entire document | <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>.</kbd> | <kbd>Command</kbd><kbd>Shift</kbd><kbd>.</kbd> |\r\n\r\n| Setting                                | Default value |\r\n| -------------------------------------- | :-----------: |\r\n| Zooming in when clicking on the bullet |    `true`     |\r\n\r\n### Debug mode\r\n\r\nOpen DevTools (Command+Option+I or Control+Shift+I) to copy the debug logs.\r\n\r\n| Setting    | Default value |\r\n| ---------- | :-----------: |\r\n| Debug mode |    `false`    |\r\n\r\n## Pricing\r\n\r\nThis plugin is free for everyone, however, if you would like to thank me\r\nor help with further development, you can donate in one of the following ways:\r\n\r\n- [Crypto](https://vslinko.cb.id)\r\n\r\n### Patrons & Supporters\r\n\r\nI want to say thank you to the people who support me, I really appreciate it!\r\n\r\n- [Lucas D](https://twitter.com/lucasdreier)\r\n- Philipp K.\r\n- [Daniel B.](https://github.com/danieltomasz)\r\n- Mat Rhein ([@mat_rhein7](http://twitter.com/mat_rhein7))\r\n- [Ollie Lovell](https://www.ollielovell.com/)\r\n- Faiz MK ([@faizkhuzaimah](https://twitter.com/faizkhuzaimah))\r\n- more patrons and anonymous supporters\r\n"
  },
  {
    "path": "babel.config.js",
    "content": "module.exports = {\n  presets: [\n    [\"@babel/preset-env\", { targets: { node: \"current\" } }],\n    \"@babel/preset-typescript\",\n  ],\n};\n"
  },
  {
    "path": "jest/global-setup.js",
    "content": "const cp = require(\"child_process\");\nconst mkdirp = require(\"mkdirp\");\nconst path = require(\"path\");\nconst fs = require(\"fs\");\nconst WebSocket = require(\"ws\");\nconst debug = require(\"debug\")(\"jest-obsidian\");\nconst promisify = require(\"util\").promisify;\nconst levelup = require(\"levelup\");\nconst leveldown = require(\"leveldown\");\n\nconst KILL_CMD =\n  process.platform === \"darwin\"\n    ? [\"killall\", \"Obsidian\"]\n    : [\"flatpak\", \"kill\", \"md.obsidian.Obsidian\"];\nconst OBSIDIAN_CONFIG_DIR =\n  process.platform === \"darwin\"\n    ? process.env.HOME + \"/Library/Application Support/obsidian\"\n    : process.env.HOME + \"/.var/app/md.obsidian.Obsidian/config/obsidian\";\nconst OBSIDIAN_CONFIG_PATH = OBSIDIAN_CONFIG_DIR + \"/obsidian.json\";\nconst OBSIDIAN_APP_CMD =\n  process.platform === \"darwin\"\n    ? [\"/Applications/Obsidian.app/Contents/MacOS/Obsidian\"]\n    : [\"flatpak\", \"run\", \"md.obsidian.Obsidian\"];\nconst OBSIDIAN_LOCAL_STORAGE_PATH =\n  process.platform === \"darwin\"\n    ? process.env.HOME +\n      \"/Library/Application Support/obsidian/Local Storage/leveldb\"\n    : process.env.HOME +\n      \"/.var/app/md.obsidian.Obsidian/config/obsidian/Local Storage/leveldb\";\nconst OBISDIAN_TEST_VAULT_ID = \"5a15473126091111\";\nconst VAULT_DIR = process.cwd() + \"/vault\";\n\nglobal.originalObsidianConfig = null;\nglobal.OBSIDIAN_CONFIG_PATH = OBSIDIAN_CONFIG_PATH;\nglobal.KILL_CMD = KILL_CMD;\n\nfunction wait(t) {\n  return new Promise((resolve) => setTimeout(resolve, t));\n}\n\nfunction runForAWhile({ timeout, fileToCheck }) {\n  return new Promise(async (resolve, reject) => {\n    const start = Date.now();\n    const obsidian = cp.spawn(OBSIDIAN_APP_CMD[0], OBSIDIAN_APP_CMD.slice(1));\n    obsidian.on(\"error\", reject);\n    const i = setInterval(() => {\n      if (fs.existsSync(fileToCheck)) {\n        clearInterval(i);\n        setTimeout(() => {\n          cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));\n          resolve();\n        }, 1000);\n        return;\n      }\n      const diff = Date.now() - start;\n      if (diff > timeout) {\n        clearInterval(i);\n        cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));\n        reject();\n      }\n    }, 1000);\n  });\n}\n\nasync function prepareObsidian() {\n  debug(`Preparing Obsidian`);\n\n  if (!fs.existsSync(OBSIDIAN_CONFIG_PATH)) {\n    debug(\"  Running Obsidian for 90 seconds to setup\");\n    await runForAWhile({\n      timeout: 90000,\n      fileToCheck: OBSIDIAN_CONFIG_DIR,\n    });\n    await wait(2000);\n    debug(`  Creating ${OBSIDIAN_CONFIG_PATH}`);\n    fs.writeFileSync(OBSIDIAN_CONFIG_PATH, '{\"vaults\":{}}');\n  }\n\n  originalObsidianConfig = fs.readFileSync(OBSIDIAN_CONFIG_PATH, \"utf-8\");\n\n  const obsidianConfig = JSON.parse(originalObsidianConfig);\n  for (const key of Object.keys(obsidianConfig.vaults)) {\n    debug(`  Closing vault ${obsidianConfig.vaults[key].path}`);\n    obsidianConfig.vaults[key].open = false;\n  }\n  debug(`  Opening vault ${VAULT_DIR}`);\n  obsidianConfig.vaults[OBISDIAN_TEST_VAULT_ID] = {\n    path: VAULT_DIR,\n    ts: Date.now(),\n    open: true,\n  };\n\n  debug(`  Saving ${OBSIDIAN_CONFIG_PATH}`);\n  fs.writeFileSync(OBSIDIAN_CONFIG_PATH, JSON.stringify(obsidianConfig));\n}\n\nasync function prepareVault() {\n  debug(`Prepare vault`);\n\n  mkdirp.sync(VAULT_DIR);\n  fs.writeFileSync(VAULT_DIR + \"/test.md\", \"\");\n\n  const vaultConfigFilePath = `${VAULT_DIR}/.obsidian/app.json`;\n  const vaultCommunityPluginsConfigFilePath = `${VAULT_DIR}/.obsidian/community-plugins.json`;\n  const vaultPluginDir = `${VAULT_DIR}/.obsidian/plugins/obsidian-zoom`;\n\n  if (!fs.existsSync(vaultConfigFilePath)) {\n    debug(\"  Running Obsidian for 90 seconds to setup vault\");\n    await runForAWhile({ timeout: 90000, fileToCheck: vaultConfigFilePath });\n    await wait(2000);\n  }\n\n  const vaultConfig = JSON.parse(fs.readFileSync(vaultConfigFilePath));\n  const newVaultConfig = {\n    ...vaultConfig,\n    foldHeading: true,\n    foldIndent: true,\n    useTab: false,\n    tabSize: 2,\n    legacyEditor: false,\n  };\n  if (JSON.stringify(vaultConfig) !== JSON.stringify(newVaultConfig)) {\n    debug(`  Saving ${vaultConfigFilePath}`);\n    fs.writeFileSync(vaultConfigFilePath, JSON.stringify(newVaultConfig));\n  }\n\n  debug(`  Saving ${vaultCommunityPluginsConfigFilePath}`);\n  fs.writeFileSync(\n    vaultCommunityPluginsConfigFilePath,\n    JSON.stringify([\"obsidian-zoom\"])\n  );\n\n  debug(`  Disabling Safe Mode`);\n  mkdirp.sync(OBSIDIAN_LOCAL_STORAGE_PATH);\n  const localStorage = levelup(leveldown(OBSIDIAN_LOCAL_STORAGE_PATH));\n  const key = Buffer.from(\n    \"5f6170703a2f2f6f6273696469616e2e6d640001656e61626c652d706c7567696e2d35613135343733313236303931313131\",\n    \"hex\"\n  );\n  const value = Buffer.from(\"0174727565\", \"hex\");\n  await promisify(localStorage.put.bind(localStorage))(key, value);\n  await promisify(localStorage.close.bind(localStorage))();\n\n  mkdirp.sync(vaultPluginDir);\n\n  debug(`  Copying ${vaultPluginDir}/main.js`);\n  fs.copyFileSync(\"main.js\", `${vaultPluginDir}/main.js`);\n  debug(`  Copying ${vaultPluginDir}/manifest.json`);\n  fs.copyFileSync(\"manifest.json\", `${vaultPluginDir}/manifest.json`);\n  debug(`  Copying ${vaultPluginDir}/styles.css`);\n  fs.copyFileSync(\"styles.css\", `${vaultPluginDir}/styles.css`);\n}\n\nmodule.exports = async () => {\n  if (process.env.SKIP_OBSIDIAN) {\n    return;\n  }\n\n  cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));\n  await wait(2000);\n\n  await prepareObsidian();\n  await prepareVault();\n\n  global.wss = new WebSocket.Server({\n    port: 8080,\n  });\n\n  debug(`Running \"${OBSIDIAN_APP_CMD[0]}\"`);\n  const obsidian = cp.exec(OBSIDIAN_APP_CMD.join(\" \"), {\n    env: {\n      ...process.env,\n      TEST_PLATFORM: \"1\",\n    },\n  });\n  obsidian.on(\"exit\", (code) => {\n    debug(`Obsidian exited with code ${code}`);\n  });\n\n  debug(\"Waiting for Obsidian WebSocket connection\");\n  const obsidianWs = await new Promise((resolve) => {\n    wss.once(\"connection\", (ws) => {\n      debug(\"Waiting for Obsidian ready message\");\n      ws.once(\"message\", (msg) => {\n        if (msg.toString() === \"ready\") {\n          resolve(ws);\n        }\n      });\n    });\n  });\n  debug(\"Obsidian WebSocket ready\");\n\n  const callbacks = new Map();\n\n  obsidianWs.on(\"message\", (message) => {\n    const { id, data, error } = JSON.parse(message);\n    debug(`Response from Obsidian ${id}`);\n    const cb = callbacks.get(id);\n    if (cb) {\n      callbacks.delete(id);\n      cb(error, data);\n    } else {\n      debug(`Callback not found for ${id}`);\n      process.exit(1);\n    }\n  });\n\n  debug(\"Waiting for test environment connection\");\n  wss.on(\"connection\", (ws) => {\n    debug(\"Test environment connected\");\n    ws.on(\"message\", (message) => {\n      const { id, type, data } = JSON.parse(message);\n      debug(`Request to Obsidian ${type} ${id}`);\n      callbacks.set(id, (error, data) => {\n        ws.send(JSON.stringify({ id, error, data }));\n      });\n      obsidianWs.send(JSON.stringify({ id, type, data }));\n    });\n  });\n};\n"
  },
  {
    "path": "jest/global-teardown.js",
    "content": "const cp = require(\"child_process\");\nconst fs = require(\"fs\");\nconst debug = require(\"debug\")(\"jest-obsidian\");\n\nmodule.exports = () => {\n  if (global.wss) {\n    global.wss.close();\n  }\n\n  cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1));\n\n  if (global.originalObsidianConfig) {\n    debug(`Restoring ${OBSIDIAN_CONFIG_PATH}`);\n    fs.writeFileSync(OBSIDIAN_CONFIG_PATH, originalObsidianConfig);\n  }\n};\n"
  },
  {
    "path": "jest/md-spec-transformer.js",
    "content": "function isHeader(line) {\n  return line.startsWith(\"# \");\n}\n\nfunction isAction(line) {\n  return line.startsWith(\"- \");\n}\n\nfunction isCodeBlock(line) {\n  return line.startsWith(\"```\");\n}\n\nfunction parseState(l) {\n  if (!isCodeBlock(l.line)) {\n    throw new Error(\n      `parseState: Unexpected line \"${l.line}\", expected \"\\`\\`\\`\"`\n    );\n  }\n\n  const lines = [];\n\n  while (true) {\n    l.next();\n\n    if (l.isEnded()) {\n      throw new Error(`parseState: Unexpected EOF, expected \"\\`\\`\\`\"`);\n    } else if (isCodeBlock(l.line)) {\n      l.nextNotEmpty();\n      return {\n        lines,\n      };\n    } else {\n      lines.push(l.line);\n    }\n  }\n}\n\nfunction parseApplyState(l) {\n  l.nextNotEmpty();\n\n  return {\n    type: \"applyState\",\n    state: parseState(l),\n  };\n}\n\nfunction parseAssertState(l) {\n  l.nextNotEmpty();\n\n  return {\n    type: \"assertState\",\n    state: parseState(l),\n  };\n}\n\nfunction parseSimulateKeydown(l) {\n  const key = l.line.replace(/- keydown: `([^`]+)`/, \"$1\");\n\n  l.nextNotEmpty();\n\n  return {\n    type: \"simulateKeydown\",\n    key,\n  };\n}\n\nfunction parsePlatform(l) {\n  const platform = l.line.replace(/- platform: `([^`]+)`/, \"$1\");\n\n  l.nextNotEmpty();\n\n  return {\n    type: \"platform\",\n    platform,\n  };\n}\n\nfunction parseExecuteCommandById(l) {\n  const command = l.line.replace(/- execute: `([^`]+)`/, \"$1\");\n\n  l.nextNotEmpty();\n\n  return {\n    type: \"executeCommandById\",\n    command,\n  };\n}\n\nfunction parseReplaceSelection(l) {\n  const char = l.line.replace(/- replaceSelection: `([^`]+)`/, \"$1\");\n\n  l.nextNotEmpty();\n\n  return {\n    type: \"replaceSelection\",\n    char,\n  };\n}\n\nfunction parseAction(l) {\n  if (!isAction(l.line)) {\n    throw new Error(\n      `parseAction: Unexpected line \"${l.line}\", expected ACTION`\n    );\n  }\n\n  if (l.line.startsWith(\"- applyState:\")) {\n    return parseApplyState(l);\n  } else if (l.line.startsWith(\"- keydown:\")) {\n    return parseSimulateKeydown(l);\n  } else if (l.line.startsWith(\"- execute:\")) {\n    return parseExecuteCommandById(l);\n  } else if (l.line.startsWith(\"- replaceSelection:\")) {\n    return parseReplaceSelection(l);\n  } else if (l.line.startsWith(\"- assertState:\")) {\n    return parseAssertState(l);\n  } else if (l.line.startsWith(\"- platform:\")) {\n    return parsePlatform(l);\n  }\n\n  throw new Error(`parseAction: Unknown action \"${l.line}\"`);\n}\n\nfunction parseTest(l) {\n  if (!isHeader(l.line)) {\n    throw new Error(`parseTest: Unexpected line \"${l.line}\", expected HEADER`);\n  }\n\n  const title = l.line.replace(/^# /, \"\").trim();\n  const actions = [];\n\n  l.nextNotEmpty();\n\n  while (!l.isEnded() && !isHeader(l.line)) {\n    actions.push(parseAction(l));\n  }\n\n  return {\n    title,\n    actions,\n  };\n}\n\nfunction parseTests(l) {\n  l.nextNotEmpty();\n\n  const tests = [];\n\n  while (!l.isEnded()) {\n    tests.push(parseTest(l));\n  }\n\n  return tests;\n}\n\nclass LinesIterator {\n  constructor(lines) {\n    this.i = -1;\n    this.lines = lines;\n    this.len = this.lines.length;\n  }\n\n  get line() {\n    return this.lines[this.i];\n  }\n\n  isEnded() {\n    return this.i >= this.len;\n  }\n\n  nextNotEmpty() {\n    do {\n      this.i++;\n    } while (!this.isEnded() && this.line.trim() === \"\");\n  }\n\n  next() {\n    this.i++;\n  }\n}\n\nmodule.exports.process = function process(sourceText, sourcePath, options) {\n  const l = new LinesIterator(sourceText.split(\"\\n\"));\n  const s = (v) => JSON.stringify(v);\n\n  const name = sourcePath.replace(options.config.cwd + \"/\", \"\");\n\n  let code = \"\";\n  code += `describe(${s(name)}, () => {\\n`;\n\n  for (const test of parseTests(l)) {\n    const platform = test.actions.find((a) => a.type === \"platform\");\n    const testFn =\n      platform && process.platform !== platform.platform ? \"test.skip\" : \"test\";\n\n    code += `  ${testFn}(${s(test.title)}, async () => {\\n`;\n\n    for (const action of test.actions) {\n      switch (action.type) {\n        case \"applyState\":\n          code += `    await applyState(${s(action.state.lines)});\\n`;\n          break;\n        case \"simulateKeydown\":\n          code += `    await simulateKeydown(${s(action.key)});\\n`;\n          break;\n        case \"executeCommandById\":\n          code += `    await executeCommandById(${s(action.command)});\\n`;\n          break;\n        case \"replaceSelection\":\n          code += `    await replaceSelection(${s(action.char)});\\n`;\n          break;\n        case \"assertState\":\n          code += `    // Waiting for all operations to be applied\\n`;\n          code += `    await new Promise((resolve) => setTimeout(resolve, 10));\\n`;\n          code += `    await expect(await getCurrentState()).toEqualEditorState(${s(\n            action.state.lines\n          )});\\n`;\n          break;\n      }\n    }\n\n    code += `  });\\n`;\n  }\n\n  code += `});\\n`;\n\n  return {\n    code,\n  };\n};\n"
  },
  {
    "path": "jest/obsidian-environment.js",
    "content": "const { TestEnvironment } = require(\"jest-environment-node\");\nconst WebSocket = require(\"ws\");\n\nlet idSeq = 1;\n\nmodule.exports = class CustomEnvironment extends TestEnvironment {\n  async setup() {\n    await super.setup();\n\n    this.callbacks = new Map();\n\n    this.createCommand(\"applyState\");\n    this.createCommand(\"simulateKeydown\");\n    this.createCommand(\"executeCommandById\");\n    this.createCommand(\"replaceSelection\");\n    this.createCommand(\"parseState\");\n    this.createCommand(\"getCurrentState\");\n  }\n\n  createCommand(type) {\n    this.global[type] = (data) => this.runCommand(type, data);\n  }\n\n  async initWs() {\n    this.ws = new WebSocket(\"ws://127.0.0.1:8080\");\n\n    await new Promise((resolve) => this.ws.on(\"open\", resolve));\n\n    this.ws.on(\"message\", (message) => {\n      const { id, data, error } = JSON.parse(message);\n      const cb = this.callbacks.get(id);\n      if (cb) {\n        this.callbacks.delete(id);\n        cb(error, data);\n      }\n    });\n  }\n\n  async runCommand(type, data) {\n    if (!this.ws) {\n      await this.initWs();\n    }\n\n    return new Promise((resolve, reject) => {\n      const id = String(idSeq++);\n\n      this.callbacks.set(id, (error, data) => {\n        if (error) {\n          reject(new Error(error));\n        } else {\n          resolve(data);\n        }\n      });\n\n      this.ws.send(JSON.stringify({ id, type, data }));\n    });\n  }\n\n  async teardown() {\n    if (this.ws) {\n      this.ws.close();\n    }\n    await super.teardown();\n  }\n};\n"
  },
  {
    "path": "jest/obsidian-expect.js",
    "content": "const jestExpect = global.expect;\n\nfunction stateToString(state) {\n  const lines = state.value.split(\"\\n\");\n\n  const sels = state.selections.reduce((acc, sel) => {\n    acc.set(sel.anchor, \"anchor\");\n    acc.set(sel.head, \"head\");\n    return acc;\n  }, new Map());\n\n  const folds = state.folds.reduce((acc, sel) => {\n    acc.set(sel.from, \"from\");\n    acc.set(sel.to, \"to\");\n    return acc;\n  }, new Map());\n\n  let res = \"\";\n  let totalC = 0;\n\n  for (let l = 0; l < lines.length; l++) {\n    const line = lines[l];\n\n    for (let c = 0; c <= line.length; c++) {\n      if (sels.has(totalC)) {\n        res += \"|\";\n      }\n      if (folds.has(totalC)) {\n        res += folds.get(totalC) === \"from\" ? \">\" : \"<\";\n      }\n      if (c < line.length) {\n        res += line[c];\n        totalC++;\n      }\n    }\n\n    if (state.hidden.includes(l)) {\n      res += \" #hidden\";\n    }\n\n    res += \"\\n\";\n    totalC++;\n  }\n\n  return res;\n}\n\njestExpect.extend({\n  async toEqualEditorState(receivedState, expectedState) {\n    const options = {\n      comment: \"Obsidian editor state equality\",\n      isNot: this.isNot,\n      promise: this.promise,\n    };\n\n    expectedState = await parseState(expectedState);\n\n    const received = stateToString(receivedState);\n    const expected = stateToString(expectedState);\n\n    const pass = received === expected;\n\n    const message = pass\n      ? () =>\n          this.utils.matcherHint(\n            \"toEqualEditorState\",\n            undefined,\n            undefined,\n            options\n          ) +\n          \"\\n\\n\" +\n          `Expected: not ${this.utils.printExpected(expected)}\\n` +\n          `Received: ${this.utils.printReceived(received)}`\n      : () => {\n          const diffString = this.utils.diff(expected, received, {\n            expand: this.expand,\n          });\n          return (\n            this.utils.matcherHint(\n              \"toEqualEditorState\",\n              undefined,\n              undefined,\n              options\n            ) +\n            \"\\n\\n\" +\n            (diffString && diffString.includes(\"- Expect\")\n              ? `Difference:\\n\\n${diffString}`\n              : `Expected: ${this.utils.printExpected(expected)}\\n` +\n                `Received: ${this.utils.printReceived(received)}`)\n          );\n        };\n\n    return {\n      pass,\n      message,\n    };\n  },\n});\n"
  },
  {
    "path": "jest/test-globals.d.ts",
    "content": "declare namespace jest {\n  interface Matchers<R> {\n    toEqualEditorState(s: string): Promise<R>;\n    toEqualEditorState(s: string[]): Promise<R>;\n  }\n}\n\ninterface IFold {\n  from: number;\n  to: number;\n}\n\ninterface ISelection {\n  anchor: number;\n  head: number;\n}\n\ninterface IState {\n  hidden: number[];\n  folds: IFold[];\n  selections: ISelection[];\n  value: string;\n}\n\ndeclare function applyState(state: string): Promise<void>;\ndeclare function applyState(state: string[]): Promise<void>;\ndeclare function parseState(state: string): Promise<IState>;\ndeclare function parseState(state: string[]): Promise<IState>;\ndeclare function simulateKeydown(keys: string): Promise<void>;\ndeclare function replaceSelection(char: string): Promise<void>;\ndeclare function executeCommandById(keys: string): Promise<void>;\ndeclare function getCurrentState(): Promise<IState>;\n"
  },
  {
    "path": "jest.config.json",
    "content": "{\n  \"clearMocks\": true,\n  \"transform\": {\n    \"\\\\.ts$\": \"babel-jest\",\n    \"\\\\.spec\\\\.md$\": \"./jest/md-spec-transformer.js\"\n  },\n  \"testRegex\": [\"/__tests__/.*\\\\.ts$\", \"\\\\.spec\\\\.md$\"],\n  \"moduleFileExtensions\": [\"js\", \"ts\", \"md\"],\n  \"globalSetup\": \"./jest/global-setup.js\",\n  \"globalTeardown\": \"./jest/global-teardown.js\",\n  \"setupFilesAfterEnv\": [\"./jest/obsidian-expect.js\"],\n  \"testEnvironment\": \"./jest/obsidian-environment.js\",\n  \"maxConcurrency\": 1,\n  \"maxWorkers\": 1\n}\n"
  },
  {
    "path": "manifest.json",
    "content": "{\n  \"id\": \"obsidian-zoom\",\n  \"name\": \"Zoom\",\n  \"version\": \"1.1.2\",\n  \"minAppVersion\": \"1.1.16\",\n  \"description\": \"Zoom into heading and lists.\",\n  \"author\": \"Viacheslav Slinko\",\n  \"authorUrl\": \"https://github.com/vslinko\",\n  \"isDesktopOnly\": false\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"obsidian-zoom\",\n  \"version\": \"1.1.2\",\n  \"description\": \"Zoom into heading and lists.\",\n  \"main\": \"main.js\",\n  \"scripts\": {\n    \"dev\": \"rollup --config rollup.config.mjs -w --configWithTests\",\n    \"build-with-tests\": \"rollup --config rollup.config.mjs --configWithTests\",\n    \"build\": \"rollup --config rollup.config.mjs\",\n    \"lint\": \"prettier --check src && eslint src\",\n    \"test\": \"jest\",\n    \"prepare\": \"husky install\"\n  },\n  \"author\": \"Viacheslav Slinko\",\n  \"license\": \"MIT\",\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.21.8\",\n    \"@babel/preset-env\": \"^7.21.5\",\n    \"@babel/preset-typescript\": \"^7.21.5\",\n    \"@codemirror/language\": \"^6.6.0\",\n    \"@codemirror/state\": \"^6.2.0\",\n    \"@codemirror/view\": \"^6.11.0\",\n    \"@rollup/plugin-commonjs\": \"^24.1.0\",\n    \"@rollup/plugin-node-resolve\": \"^15.0.2\",\n    \"@rollup/plugin-typescript\": \"^11.1.0\",\n    \"@trivago/prettier-plugin-sort-imports\": \"^4.1.1\",\n    \"@types/diff\": \"^5.0.3\",\n    \"@types/jest\": \"^29.5.1\",\n    \"@types/node\": \"^18.16.3\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.59.2\",\n    \"@typescript-eslint/parser\": \"^5.59.2\",\n    \"babel-jest\": \"^29.5.0\",\n    \"debug\": \"^4.3.4\",\n    \"eslint\": \"^8.39.0\",\n    \"eslint-config-prettier\": \"^8.8.0\",\n    \"husky\": \"^8.0.3\",\n    \"inquirer\": \"^9.2.0\",\n    \"jest\": \"^29.5.0\",\n    \"jest-environment-jsdom\": \"^29.5.0\",\n    \"jest-environment-node\": \"^29.5.0\",\n    \"leveldown\": \"^6.1.1\",\n    \"levelup\": \"^5.1.1\",\n    \"mkdirp\": \"^3.0.1\",\n    \"obsidian\": \"^1.2.8\",\n    \"prettier\": \"^2.8.8\",\n    \"rollup\": \"^3.21.4\",\n    \"ts-node\": \"^10.9.1\",\n    \"tslib\": \"^2.5.0\",\n    \"typescript\": \"5.0.4\",\n    \"ws\": \"^8.13.0\"\n  }\n}\n"
  },
  {
    "path": "release.mjs",
    "content": "import inquirer from \"inquirer\";\nimport { spawnSync } from \"node:child_process\";\nimport { readFileSync, writeFileSync } from \"node:fs\";\n\nfunction increaseVersion(version, releaseType) {\n  const v = version.split(\".\").map((p) => Number(p));\n\n  if (releaseType === \"major\") {\n    v[0]++;\n    v[1] = 0;\n    v[2] = 0;\n  } else if (releaseType === \"minor\") {\n    v[1]++;\n    v[2] = 0;\n  } else if (releaseType === \"patch\") {\n    v[2]++;\n  } else {\n    throw new Error();\n  }\n\n  return v.join(\".\");\n}\n\nasync function main() {\n  const manifestFile = JSON.parse(readFileSync(\"manifest.json\"));\n\n  console.log(`Current version ${manifestFile.version}`);\n  console.log(`Current minAppVersion ${manifestFile.minAppVersion}`);\n\n  const { releaseType, minAppVersion } = await inquirer.prompt([\n    {\n      type: \"list\",\n      name: \"releaseType\",\n      message: \"Release type:\",\n      choices: [\n        {\n          name: \"major (Some major changes that have, or could lead to, breaking changes)\",\n          value: \"major\",\n        },\n        {\n          name: \"minor (Some notable changes without breaking changes)\",\n          value: \"minor\",\n        },\n        {\n          name: \"patch (Some changes, but without new features)\",\n          value: \"patch\",\n        },\n      ],\n    },\n    {\n      type: \"input\",\n      name: \"minAppVersion\",\n      message: \"Minimum supported version of Obsidian:\",\n      default: manifestFile.minAppVersion,\n    },\n  ]);\n\n  const newVersion = increaseVersion(manifestFile.version, releaseType);\n\n  manifestFile.version = newVersion;\n  manifestFile.minAppVersion = minAppVersion;\n  writeFileSync(\"manifest.json\", JSON.stringify(manifestFile, null, 2) + \"\\n\");\n\n  const packageLockFile = JSON.parse(readFileSync(\"package-lock.json\"));\n  packageLockFile.version = newVersion;\n  packageLockFile.packages[\"\"].version = newVersion;\n  writeFileSync(\n    \"package-lock.json\",\n    JSON.stringify(packageLockFile, null, 2) + \"\\n\"\n  );\n\n  const packageFile = JSON.parse(readFileSync(\"package.json\"));\n  packageFile.version = newVersion;\n  writeFileSync(\"package.json\", JSON.stringify(packageFile, null, 2) + \"\\n\");\n\n  const versionsFile = JSON.parse(readFileSync(\"versions.json\"));\n  const newVersionsFile = {\n    [newVersion]: minAppVersion,\n    ...versionsFile,\n  };\n  writeFileSync(\n    \"versions.json\",\n    JSON.stringify(newVersionsFile, null, 2) + \"\\n\"\n  );\n\n  spawnSync(\n    \"git\",\n    [\n      \"add\",\n      \"manifest.json\",\n      \"package-lock.json\",\n      \"package.json\",\n      \"versions.json\",\n    ],\n    {\n      stdio: \"inherit\",\n    }\n  );\n  spawnSync(\"git\", [\"commit\", \"-m\", newVersion], {\n    stdio: \"inherit\",\n  });\n}\n\nmain();\n"
  },
  {
    "path": "rollup.config.mjs",
    "content": "import commonjs from \"@rollup/plugin-commonjs\";\nimport { nodeResolve } from \"@rollup/plugin-node-resolve\";\nimport typescript from \"@rollup/plugin-typescript\";\n\nexport default (commandLineArgs) => ({\n  input: commandLineArgs.configWithTests\n    ? \"src/ObsidianZoomPluginWithTests.ts\"\n    : \"src/ObsidianZoomPlugin.ts\",\n  output: {\n    file: \"main.js\",\n    sourcemap: \"inline\",\n    format: \"cjs\",\n    exports: \"default\",\n  },\n  external: [\n    \"obsidian\",\n    \"@codemirror/language\",\n    \"@codemirror/state\",\n    \"@codemirror/view\",\n  ],\n  plugins: [typescript(), nodeResolve({ browser: true }), commonjs()],\n});\n"
  },
  {
    "path": "specs/LimitSelectionFeature.spec.md",
    "content": "# 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: `obsidian-zoom:zoom-in`\n- assertState:\n\n```md\ntext #hidden\n #hidden\n# 1 #hidden\n #hidden\ntext #hidden\n #hidden\n|## 1.1|\n\ntext\n\n# 2 #hidden\n #hidden\ntext #hidden\n```\n\n# Should limit selection when zoomed in\n\n- platform: `darwin`\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`\n- keydown: `Cmd-KeyA`\n- assertState:\n\n```md\ntext #hidden\n #hidden\n# 1 #hidden\n #hidden\ntext #hidden\n #hidden\n|## 1.1\n\ntext\n|\n# 2 #hidden\n #hidden\ntext #hidden\n```\n\n# Should limit selection when zoomed in\n\n- platform: `linux`\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`\n- keydown: `Ctrl-KeyA`\n- assertState:\n\n```md\ntext #hidden\n #hidden\n# 1 #hidden\n #hidden\ntext #hidden\n #hidden\n|## 1.1\n\ntext\n|\n# 2 #hidden\n #hidden\ntext #hidden\n```\n\n# Should not have bug #39\n\n- applyState:\n\n```md\n# h1|\n\n# h2\n```\n\n- execute: `obsidian-zoom:zoom-in`\n- keydown: `ArrowDown`\n- replaceSelection: `a`\n- replaceSelection: `b`\n- replaceSelection: `c`\n- assertState:\n\n```md\n# h1\nabc|\n# h2 #hidden\n```\n"
  },
  {
    "path": "specs/ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md",
    "content": "# 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- execute: `obsidian-zoom:zoom-in`\n- keydown: `Backspace`\n- assertState:\n\n```md\ntext\n|# 1\n\ntext\n```\n\n# Should reset zoom when second boundary of visible content is violated\n\n- applyState:\n\n```md\n# 1|\n\ntext\n\n# 2\n\ntext\n```\n\n- execute: `obsidian-zoom:zoom-in`\n- keydown: `ArrowRight`\n- keydown: `ArrowDown`\n- keydown: `ArrowDown`\n- assertState:\n\n```md\n# 1\n\ntext\n|\n# 2 #hidden\n #hidden\ntext #hidden\n```\n\n- keydown: `Delete`\n- assertState:\n\n```md\n# 1\n\ntext\n|# 2\n\ntext\n```\n"
  },
  {
    "path": "specs/ZoomFeature.spec.md",
    "content": "# 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`\n- assertState:\n\n```md\ntext #hidden\n #hidden\n# 1 #hidden\n #hidden\ntext #hidden\n #hidden\n## 1.1|\n\ntext\n\n# 2 #hidden\n #hidden\ntext #hidden\n```\n\n# Should zoom out\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`\n- execute: `obsidian-zoom:zoom-out`\n- assertState:\n\n```md\ntext\n\n# 1\n\ntext\n\n## 1.1|\n\ntext\n\n# 2\n\ntext\n```\n"
  },
  {
    "path": "src/ObsidianZoomPlugin.ts",
    "content": "import { Editor, Plugin } from \"obsidian\";\n\nimport { Feature } from \"./features/Feature\";\nimport { HeaderNavigationFeature } from \"./features/HeaderNavigationFeature\";\nimport { LimitSelectionFeature } from \"./features/LimitSelectionFeature\";\nimport { ListsStylesFeature } from \"./features/ListsStylesFeature\";\nimport { ResetZoomWhenVisibleContentBoundariesViolatedFeature } from \"./features/ResetZoomWhenVisibleContentBoundariesViolatedFeature\";\nimport { SettingsTabFeature } from \"./features/SettingsTabFeature\";\nimport { ZoomFeature } from \"./features/ZoomFeature\";\nimport { ZoomOnClickFeature } from \"./features/ZoomOnClickFeature\";\nimport { LoggerService } from \"./services/LoggerService\";\nimport { SettingsService } from \"./services/SettingsService\";\nimport { getEditorViewFromEditor } from \"./utils/getEditorViewFromEditor\";\n\ndeclare global {\n  interface Window {\n    ObsidianZoomPlugin?: ObsidianZoomPlugin;\n  }\n}\n\nexport default class ObsidianZoomPlugin extends Plugin {\n  protected zoomFeature: ZoomFeature;\n  protected features: Feature[];\n\n  async onload() {\n    console.log(`Loading obsidian-zoom`);\n\n    window.ObsidianZoomPlugin = this;\n\n    const settings = new SettingsService(this);\n    await settings.load();\n\n    const logger = new LoggerService(settings);\n\n    const settingsTabFeature = new SettingsTabFeature(this, settings);\n    this.zoomFeature = new ZoomFeature(this, logger);\n    const limitSelectionFeature = new LimitSelectionFeature(\n      this,\n      logger,\n      this.zoomFeature\n    );\n    const resetZoomWhenVisibleContentBoundariesViolatedFeature =\n      new ResetZoomWhenVisibleContentBoundariesViolatedFeature(\n        this,\n        logger,\n        this.zoomFeature,\n        this.zoomFeature\n      );\n    const headerNavigationFeature = new HeaderNavigationFeature(\n      this,\n      logger,\n      this.zoomFeature,\n      this.zoomFeature,\n      this.zoomFeature,\n      this.zoomFeature,\n      this.zoomFeature,\n      this.zoomFeature\n    );\n    const zoomOnClickFeature = new ZoomOnClickFeature(\n      this,\n      settings,\n      this.zoomFeature\n    );\n    const listsStylesFeature = new ListsStylesFeature(settings);\n\n    this.features = [\n      settingsTabFeature,\n      this.zoomFeature,\n      limitSelectionFeature,\n      resetZoomWhenVisibleContentBoundariesViolatedFeature,\n      headerNavigationFeature,\n      zoomOnClickFeature,\n      listsStylesFeature,\n    ];\n\n    for (const feature of this.features) {\n      await feature.load();\n    }\n  }\n\n  async onunload() {\n    console.log(`Unloading obsidian-zoom`);\n\n    delete window.ObsidianZoomPlugin;\n\n    for (const feature of this.features) {\n      await feature.unload();\n    }\n  }\n\n  public getZoomRange(editor: Editor) {\n    const cm = getEditorViewFromEditor(editor);\n    const range = this.zoomFeature.calculateVisibleContentRange(cm.state);\n\n    if (!range) {\n      return null;\n    }\n\n    const from = cm.state.doc.lineAt(range.from);\n    const to = cm.state.doc.lineAt(range.to);\n\n    return {\n      from: {\n        line: from.number - 1,\n        ch: range.from - from.from,\n      },\n      to: {\n        line: to.number - 1,\n        ch: range.to - to.from,\n      },\n    };\n  }\n\n  public zoomOut(editor: Editor) {\n    this.zoomFeature.zoomOut(getEditorViewFromEditor(editor));\n  }\n\n  public zoomIn(editor: Editor, line: number) {\n    const cm = getEditorViewFromEditor(editor);\n    const pos = cm.state.doc.line(line + 1).from;\n    this.zoomFeature.zoomIn(cm, pos);\n  }\n\n  public refreshZoom(editor: Editor) {\n    this.zoomFeature.refreshZoom(getEditorViewFromEditor(editor));\n  }\n}\n"
  },
  {
    "path": "src/ObsidianZoomPluginWithTests.ts",
    "content": "import { editorEditorField } from \"obsidian\";\n\nimport { foldEffect, foldedRanges } from \"@codemirror/language\";\nimport { EditorSelection, StateField } from \"@codemirror/state\";\nimport { EditorView, runScopeHandlers } from \"@codemirror/view\";\n\nimport ObsidianZoomPlugin from \"./ObsidianZoomPlugin\";\nimport { zoomOutEffect } from \"./logic/utils/effects\";\n\nconst keysMap: { [key: string]: number } = {\n  Backspace: 8,\n  Enter: 13,\n  ArrowLeft: 37,\n  ArrowUp: 38,\n  ArrowRight: 39,\n  ArrowDown: 40,\n  Delete: 46,\n  KeyA: 65,\n};\n\nexport default class ObsidianZoomPluginWithTests extends ObsidianZoomPlugin {\n  private editorView: EditorView;\n\n  wait(time: number) {\n    return new Promise((resolve) => setTimeout(resolve, time));\n  }\n\n  executeCommandById(id: string) {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    (this.app as any).commands.executeCommandById(id);\n  }\n\n  replaceSelection(char: string) {\n    this.editorView.dispatch(this.editorView.state.replaceSelection(char));\n  }\n\n  simulateKeydown(keys: string) {\n    const e = {\n      type: \"keydown\",\n      code: \"\",\n      keyCode: 0,\n      shiftKey: false,\n      metaKey: false,\n      altKey: false,\n      ctrlKey: false,\n      defaultPrevented: false,\n      returnValue: true,\n      cancelBubble: false,\n      preventDefault: function () {\n        e.defaultPrevented = true;\n        e.returnValue = true;\n      },\n      stopPropagation: function () {\n        e.cancelBubble = true;\n      },\n    };\n\n    for (const key of keys.split(\"-\")) {\n      switch (key.toLowerCase()) {\n        case \"cmd\":\n          e.metaKey = true;\n          break;\n        case \"ctrl\":\n          e.ctrlKey = true;\n          break;\n        case \"alt\":\n          e.altKey = true;\n          break;\n        case \"shift\":\n          e.shiftKey = true;\n          break;\n        default:\n          e.code = key;\n          break;\n      }\n    }\n\n    if (e.code in keysMap) {\n      e.keyCode = keysMap[e.code];\n    }\n\n    if (e.keyCode == 0) {\n      throw new Error(\"Unknown key: \" + e.code);\n    }\n\n    runScopeHandlers(this.editorView, e as KeyboardEvent, \"editor\");\n  }\n\n  async load() {\n    await super.load();\n\n    if (process.env.TEST_PLATFORM) {\n      setImmediate(async () => {\n        await this.wait(1000);\n        this.connect();\n      });\n    }\n  }\n\n  async prepareForTests() {\n    const filePath = `test.md`;\n    let file = this.app.vault\n      .getMarkdownFiles()\n      .find((f) => f.path === filePath);\n    if (!file) {\n      file = await this.app.vault.create(filePath, \"\");\n    }\n    for (let i = 0; i < 10; i++) {\n      await this.wait(1000);\n      if (this.app.workspace.activeLeaf) {\n        this.app.workspace.activeLeaf.openFile(file);\n        break;\n      }\n    }\n    await this.wait(1000);\n\n    this.registerEditorExtension(\n      StateField.define({\n        create: (state) => {\n          this.editorView = state.field(editorEditorField);\n        },\n        update: () => {},\n      })\n    );\n  }\n\n  async connect() {\n    const ws = new WebSocket(\"ws://127.0.0.1:8080/\");\n    await this.prepareForTests();\n    ws.send(\"ready\");\n\n    ws.addEventListener(\"message\", (event) => {\n      const { id, type, data } = JSON.parse(event.data);\n\n      let result;\n      let error;\n\n      try {\n        switch (type) {\n          case \"applyState\":\n            this.applyState(data);\n            break;\n          case \"simulateKeydown\":\n            this.simulateKeydown(data);\n            break;\n          case \"replaceSelection\":\n            this.replaceSelection(data);\n            break;\n          case \"executeCommandById\":\n            this.executeCommandById(data);\n            break;\n          case \"parseState\":\n            result = this.parseState(data);\n            break;\n          case \"getCurrentState\":\n            result = this.getCurrentState();\n            break;\n        }\n      } catch (e) {\n        error = String(e);\n        if (e.stack) {\n          error += \"\\n\" + e.stack;\n        }\n      }\n\n      ws.send(JSON.stringify({ id, data: result, error }));\n    });\n  }\n\n  applyState(state: string[]): void;\n  applyState(state: string): void;\n  applyState(state: IState): void;\n  applyState(state: IState | string | string[]) {\n    if (typeof state === \"string\") {\n      state = state.split(\"\\n\");\n    }\n\n    if (Array.isArray(state)) {\n      state = this.parseState(state);\n    }\n\n    this.editorView.dispatch({\n      effects: [zoomOutEffect.of()],\n    });\n    this.editorView.dispatch({\n      changes: [{ from: 0, to: this.editorView.state.doc.length, insert: \"\" }],\n    });\n    this.editorView.dispatch({\n      changes: [{ from: 0, insert: state.value }],\n    });\n    this.editorView.dispatch({\n      selection: EditorSelection.create(\n        state.selections.map((s) => EditorSelection.range(s.anchor, s.head))\n      ),\n    });\n    this.editorView.dispatch({\n      effects: state.folds.map((f) =>\n        foldEffect.of({ from: f.from, to: f.to })\n      ),\n    });\n  }\n\n  getCurrentState(): IState {\n    const hidden: number[] = [];\n\n    const hiddenRanges = this.zoomFeature.calculateHiddenContentRanges(\n      this.editorView.state\n    );\n    for (const i of hiddenRanges) {\n      const lineFrom = this.editorView.state.doc.lineAt(i.from).number - 1;\n      const lineTo = this.editorView.state.doc.lineAt(i.to).number - 1;\n      for (let lineNo = lineFrom; lineNo <= lineTo; lineNo++) {\n        hidden.push(lineNo);\n      }\n    }\n\n    const folds: IFold[] = [];\n    const iter = foldedRanges(this.editorView.state).iter();\n    while (iter.value !== null) {\n      folds.push({ from: iter.from, to: iter.to });\n      iter.next();\n    }\n\n    return {\n      hidden,\n      folds,\n      selections: this.editorView.state.selection.ranges.map((r) => ({\n        anchor: r.anchor,\n        head: r.head,\n      })),\n      value: this.editorView.state.doc.sliceString(0),\n    };\n  }\n\n  parseState(content: string[]): IState;\n  parseState(content: string): IState;\n  parseState(content: string | string[]): IState {\n    if (typeof content === \"string\") {\n      content = content.split(\"\\n\");\n    }\n\n    const acc = content.reduce(\n      (acc, line, lineNo) => {\n        if (acc.foldFrom === null) {\n          const arrowIndex = line.indexOf(\">\");\n          if (arrowIndex >= 0) {\n            acc.foldFrom = acc.chars + arrowIndex;\n            line =\n              line.substring(0, arrowIndex) + line.substring(arrowIndex + 1);\n          }\n        } else {\n          const arrowIndex = line.indexOf(\"<\");\n          if (arrowIndex >= 0) {\n            acc.folds.push({ from: acc.foldFrom, to: acc.chars + arrowIndex });\n            acc.foldFrom = null;\n            line =\n              line.substring(0, arrowIndex) + line.substring(arrowIndex + 1);\n          }\n        }\n\n        if (line.includes(\"#hidden\")) {\n          line = line.replace(\"#hidden\", \"\").trim();\n          acc.hidden.push(lineNo);\n        }\n\n        if (acc.anchor === null) {\n          const dashIndex = line.indexOf(\"|\");\n          if (dashIndex >= 0) {\n            acc.anchor = acc.chars + dashIndex;\n            line = line.substring(0, dashIndex) + line.substring(dashIndex + 1);\n          }\n        }\n\n        if (acc.head === null) {\n          const dashIndex = line.indexOf(\"|\");\n          if (dashIndex >= 0) {\n            acc.head = acc.chars + dashIndex;\n            line = line.substring(0, dashIndex) + line.substring(dashIndex + 1);\n          }\n        }\n\n        acc.chars += line.length;\n        acc.chars += 1;\n        acc.lines.push(line);\n\n        return acc;\n      },\n      {\n        lines: [] as string[],\n        chars: 0,\n        anchor: null as number | null,\n        head: null as number | null,\n        foldFrom: null as number | null,\n        folds: [] as IFold[],\n        hidden: [] as number[],\n      }\n    );\n    if (acc.anchor === null) {\n      acc.anchor = 0;\n    }\n    if (acc.head === null) {\n      acc.head = acc.anchor;\n    }\n\n    return {\n      hidden: acc.hidden,\n      folds: acc.folds,\n      selections: [{ anchor: acc.anchor, head: acc.head }],\n      value: acc.lines.join(\"\\n\"),\n    };\n  }\n}\n"
  },
  {
    "path": "src/features/Feature.ts",
    "content": "export interface Feature {\n  load(): Promise<void>;\n  unload(): Promise<void>;\n}\n"
  },
  {
    "path": "src/features/HeaderNavigationFeature.ts",
    "content": "import { Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from \"@codemirror/view\";\n\nimport { Feature } from \"./Feature\";\nimport { getDocumentTitle } from \"./utils/getDocumentTitle\";\nimport { getEditorViewFromEditorState } from \"./utils/getEditorViewFromEditorState\";\n\nimport { CollectBreadcrumbs } from \"../logic/CollectBreadcrumbs\";\nimport { DetectRangeBeforeVisibleRangeChanged } from \"../logic/DetectRangeBeforeVisibleRangeChanged\";\nimport { RenderNavigationHeader } from \"../logic/RenderNavigationHeader\";\nimport { LoggerService } from \"../services/LoggerService\";\n\nexport interface ZoomIn {\n  zoomIn(view: EditorView, pos: number): void;\n}\n\nexport interface ZoomOut {\n  zoomOut(view: EditorView): void;\n}\n\nexport interface NotifyAfterZoomIn {\n  notifyAfterZoomIn(cb: (view: EditorView, pos: number) => void): void;\n}\n\nexport interface NotifyAfterZoomOut {\n  notifyAfterZoomOut(cb: (view: EditorView) => void): void;\n}\n\nexport interface CalculateHiddenContentRanges {\n  calculateHiddenContentRanges(\n    state: EditorState\n  ): { from: number; to: number }[] | null;\n}\n\nexport interface CalculateVisibleContentRange {\n  calculateVisibleContentRange(\n    state: EditorState\n  ): { from: number; to: number } | null;\n}\n\nclass ShowHeaderAfterZoomIn implements Feature {\n  constructor(\n    private notifyAfterZoomIn: NotifyAfterZoomIn,\n    private collectBreadcrumbs: CollectBreadcrumbs,\n    private renderNavigationHeader: RenderNavigationHeader\n  ) {}\n\n  async load() {\n    this.notifyAfterZoomIn.notifyAfterZoomIn((view, pos) => {\n      const breadcrumbs = this.collectBreadcrumbs.collectBreadcrumbs(\n        view.state,\n        pos\n      );\n      this.renderNavigationHeader.showHeader(view, breadcrumbs);\n    });\n  }\n\n  async unload() {}\n}\n\nclass HideHeaderAfterZoomOut implements Feature {\n  constructor(\n    private notifyAfterZoomOut: NotifyAfterZoomOut,\n    private renderNavigationHeader: RenderNavigationHeader\n  ) {}\n\n  async load() {\n    this.notifyAfterZoomOut.notifyAfterZoomOut((view) => {\n      this.renderNavigationHeader.hideHeader(view);\n    });\n  }\n\n  async unload() {}\n}\n\nclass UpdateHeaderAfterRangeBeforeVisibleRangeChanged implements Feature {\n  private detectRangeBeforeVisibleRangeChanged =\n    new DetectRangeBeforeVisibleRangeChanged(\n      this.calculateHiddenContentRanges,\n      {\n        rangeBeforeVisibleRangeChanged: (state) =>\n          this.rangeBeforeVisibleRangeChanged(state),\n      }\n    );\n\n  constructor(\n    private plugin: Plugin,\n    private calculateHiddenContentRanges: CalculateHiddenContentRanges,\n    private calculateVisibleContentRange: CalculateVisibleContentRange,\n    private collectBreadcrumbs: CollectBreadcrumbs,\n    private renderNavigationHeader: RenderNavigationHeader\n  ) {}\n\n  async load() {\n    this.plugin.registerEditorExtension(\n      this.detectRangeBeforeVisibleRangeChanged.getExtension()\n    );\n  }\n\n  async unload() {}\n\n  private rangeBeforeVisibleRangeChanged(state: EditorState) {\n    const view = getEditorViewFromEditorState(state);\n\n    const pos =\n      this.calculateVisibleContentRange.calculateVisibleContentRange(\n        state\n      ).from;\n\n    const breadcrumbs = this.collectBreadcrumbs.collectBreadcrumbs(state, pos);\n\n    this.renderNavigationHeader.showHeader(view, breadcrumbs);\n  }\n}\n\nexport class HeaderNavigationFeature implements Feature {\n  private collectBreadcrumbs = new CollectBreadcrumbs({\n    getDocumentTitle: getDocumentTitle,\n  });\n\n  private renderNavigationHeader = new RenderNavigationHeader(\n    this.logger,\n    this.zoomIn,\n    this.zoomOut\n  );\n\n  private showHeaderAfterZoomIn = new ShowHeaderAfterZoomIn(\n    this.notifyAfterZoomIn,\n    this.collectBreadcrumbs,\n    this.renderNavigationHeader\n  );\n\n  private hideHeaderAfterZoomOut = new HideHeaderAfterZoomOut(\n    this.notifyAfterZoomOut,\n    this.renderNavigationHeader\n  );\n\n  private updateHeaderAfterRangeBeforeVisibleRangeChanged =\n    new UpdateHeaderAfterRangeBeforeVisibleRangeChanged(\n      this.plugin,\n      this.calculateHiddenContentRanges,\n      this.calculateVisibleContentRange,\n      this.collectBreadcrumbs,\n      this.renderNavigationHeader\n    );\n\n  constructor(\n    private plugin: Plugin,\n    private logger: LoggerService,\n    private calculateHiddenContentRanges: CalculateHiddenContentRanges,\n    private calculateVisibleContentRange: CalculateVisibleContentRange,\n    private zoomIn: ZoomIn,\n    private zoomOut: ZoomOut,\n    private notifyAfterZoomIn: NotifyAfterZoomIn,\n    private notifyAfterZoomOut: NotifyAfterZoomOut\n  ) {}\n\n  async load() {\n    this.plugin.registerEditorExtension(\n      this.renderNavigationHeader.getExtension()\n    );\n\n    this.showHeaderAfterZoomIn.load();\n    this.hideHeaderAfterZoomOut.load();\n    this.updateHeaderAfterRangeBeforeVisibleRangeChanged.load();\n  }\n\n  async unload() {\n    this.showHeaderAfterZoomIn.unload();\n    this.hideHeaderAfterZoomOut.unload();\n    this.updateHeaderAfterRangeBeforeVisibleRangeChanged.unload();\n  }\n}\n"
  },
  {
    "path": "src/features/LimitSelectionFeature.ts",
    "content": "import { Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\n\nimport { Feature } from \"./Feature\";\n\nimport { LimitSelectionOnZoomingIn } from \"../logic/LimitSelectionOnZoomingIn\";\nimport { LimitSelectionWhenZoomedIn } from \"../logic/LimitSelectionWhenZoomedIn\";\nimport { LoggerService } from \"../services/LoggerService\";\n\nexport interface CalculateVisibleContentRange {\n  calculateVisibleContentRange(\n    state: EditorState\n  ): { from: number; to: number } | null;\n}\n\nexport class LimitSelectionFeature implements Feature {\n  private limitSelectionOnZoomingIn = new LimitSelectionOnZoomingIn(\n    this.logger\n  );\n  private limitSelectionWhenZoomedIn = new LimitSelectionWhenZoomedIn(\n    this.logger,\n    this.calculateVisibleContentRange\n  );\n\n  constructor(\n    private plugin: Plugin,\n    private logger: LoggerService,\n    private calculateVisibleContentRange: CalculateVisibleContentRange\n  ) {}\n\n  async load() {\n    this.plugin.registerEditorExtension(\n      this.limitSelectionOnZoomingIn.getExtension()\n    );\n\n    this.plugin.registerEditorExtension(\n      this.limitSelectionWhenZoomedIn.getExtension()\n    );\n  }\n\n  async unload() {}\n}\n"
  },
  {
    "path": "src/features/ListsStylesFeature.ts",
    "content": "import { Feature } from \"./Feature\";\n\nimport { SettingsService } from \"../services/SettingsService\";\n\nexport class ListsStylesFeature implements Feature {\n  constructor(private settings: SettingsService) {}\n\n  async load() {\n    if (this.settings.zoomOnClick) {\n      this.addZoomStyles();\n    }\n\n    this.settings.onChange(\"zoomOnClick\", this.onZoomOnClickSettingChange);\n  }\n\n  async unload() {\n    this.settings.removeCallback(\n      \"zoomOnClick\",\n      this.onZoomOnClickSettingChange\n    );\n\n    this.removeZoomStyles();\n  }\n\n  private onZoomOnClickSettingChange = (zoomOnClick: boolean) => {\n    if (zoomOnClick) {\n      this.addZoomStyles();\n    } else {\n      this.removeZoomStyles();\n    }\n  };\n\n  private addZoomStyles() {\n    document.body.classList.add(\"zoom-plugin-bls-zoom\");\n  }\n\n  private removeZoomStyles() {\n    document.body.classList.remove(\"zoom-plugin-bls-zoom\");\n  }\n}\n"
  },
  {
    "path": "src/features/ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts",
    "content": "import { Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from \"@codemirror/view\";\n\nimport { Feature } from \"./Feature\";\nimport { getEditorViewFromEditorState } from \"./utils/getEditorViewFromEditorState\";\n\nimport { DetectVisibleContentBoundariesViolation } from \"../logic/DetectVisibleContentBoundariesViolation\";\nimport { LoggerService } from \"../services/LoggerService\";\n\nexport interface CalculateHiddenContentRanges {\n  calculateHiddenContentRanges(\n    state: EditorState\n  ): { from: number; to: number }[] | null;\n}\n\nexport interface ZoomOut {\n  zoomOut(view: EditorView): void;\n}\n\nexport class ResetZoomWhenVisibleContentBoundariesViolatedFeature\n  implements Feature\n{\n  private detectVisibleContentBoundariesViolation =\n    new DetectVisibleContentBoundariesViolation(\n      this.calculateHiddenContentRanges,\n      {\n        visibleContentBoundariesViolated: (state) =>\n          this.visibleContentBoundariesViolated(state),\n      }\n    );\n\n  constructor(\n    private plugin: Plugin,\n    private logger: LoggerService,\n    private calculateHiddenContentRanges: CalculateHiddenContentRanges,\n    private zoomOut: ZoomOut\n  ) {}\n\n  async load() {\n    this.plugin.registerEditorExtension(\n      this.detectVisibleContentBoundariesViolation.getExtension()\n    );\n  }\n\n  async unload() {}\n\n  private visibleContentBoundariesViolated(state: EditorState) {\n    const l = this.logger.bind(\n      \"ResetZoomWhenVisibleContentBoundariesViolatedFeature:visibleContentBoundariesViolated\"\n    );\n    l(\"visible content boundaries violated, zooming out\");\n    this.zoomOut.zoomOut(getEditorViewFromEditorState(state));\n  }\n}\n"
  },
  {
    "path": "src/features/SettingsTabFeature.ts",
    "content": "import { App, Plugin, PluginSettingTab, Setting } from \"obsidian\";\n\nimport { Feature } from \"./Feature\";\n\nimport { SettingsService } from \"../services/SettingsService\";\n\nclass ObsidianZoomPluginSettingTab extends PluginSettingTab {\n  constructor(app: App, plugin: Plugin, private settings: SettingsService) {\n    super(app, plugin);\n  }\n\n  display(): void {\n    const { containerEl } = this;\n\n    containerEl.empty();\n\n    new Setting(containerEl)\n      .setName(\"Zooming in when clicking on the bullet\")\n      .addToggle((toggle) => {\n        toggle.setValue(this.settings.zoomOnClick).onChange(async (value) => {\n          this.settings.zoomOnClick = value;\n          await this.settings.save();\n        });\n      });\n\n    new Setting(containerEl)\n      .setName(\"Debug mode\")\n      .setDesc(\n        \"Open DevTools (Command+Option+I or Control+Shift+I) to copy the debug logs.\"\n      )\n      .addToggle((toggle) => {\n        toggle.setValue(this.settings.debug).onChange(async (value) => {\n          this.settings.debug = value;\n          await this.settings.save();\n        });\n      });\n  }\n}\n\nexport class SettingsTabFeature implements Feature {\n  constructor(private plugin: Plugin, private settings: SettingsService) {}\n\n  async load() {\n    this.plugin.addSettingTab(\n      new ObsidianZoomPluginSettingTab(\n        this.plugin.app,\n        this.plugin,\n        this.settings\n      )\n    );\n  }\n\n  async unload() {}\n}\n"
  },
  {
    "path": "src/features/ZoomFeature.ts",
    "content": "import { Notice, Plugin } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from \"@codemirror/view\";\n\nimport { Feature } from \"./Feature\";\nimport { isFoldingEnabled } from \"./utils/isFoldingEnabled\";\n\nimport { CalculateRangeForZooming } from \"../logic/CalculateRangeForZooming\";\nimport { KeepOnlyZoomedContentVisible } from \"../logic/KeepOnlyZoomedContentVisible\";\nimport { LoggerService } from \"../services/LoggerService\";\nimport { getEditorViewFromEditor } from \"../utils/getEditorViewFromEditor\";\n\nexport type ZoomInCallback = (view: EditorView, pos: number) => void;\nexport type ZoomOutCallback = (view: EditorView) => void;\n\nexport class ZoomFeature implements Feature {\n  private zoomInCallbacks: ZoomInCallback[] = [];\n  private zoomOutCallbacks: ZoomOutCallback[] = [];\n\n  private keepOnlyZoomedContentVisible = new KeepOnlyZoomedContentVisible(\n    this.logger\n  );\n\n  private calculateRangeForZooming = new CalculateRangeForZooming();\n\n  constructor(private plugin: Plugin, private logger: LoggerService) {}\n\n  public calculateVisibleContentRange(state: EditorState) {\n    return this.keepOnlyZoomedContentVisible.calculateVisibleContentRange(\n      state\n    );\n  }\n\n  public calculateHiddenContentRanges(state: EditorState) {\n    return this.keepOnlyZoomedContentVisible.calculateHiddenContentRanges(\n      state\n    );\n  }\n\n  public notifyAfterZoomIn(cb: ZoomInCallback) {\n    this.zoomInCallbacks.push(cb);\n  }\n\n  public notifyAfterZoomOut(cb: ZoomOutCallback) {\n    this.zoomOutCallbacks.push(cb);\n  }\n\n  public refreshZoom(view: EditorView) {\n    const prevRange =\n      this.keepOnlyZoomedContentVisible.calculateVisibleContentRange(\n        view.state\n      );\n\n    if (!prevRange) {\n      return;\n    }\n\n    const newRange = this.calculateRangeForZooming.calculateRangeForZooming(\n      view.state,\n      prevRange.from\n    );\n\n    if (!newRange) {\n      return;\n    }\n\n    this.keepOnlyZoomedContentVisible.keepOnlyZoomedContentVisible(\n      view,\n      newRange.from,\n      newRange.to,\n      { scrollIntoView: false }\n    );\n  }\n\n  public zoomIn(view: EditorView, pos: number) {\n    const l = this.logger.bind(\"ZoomFeature:zoomIn\");\n    l(\"zooming in\");\n\n    if (!isFoldingEnabled(this.plugin.app)) {\n      new Notice(\n        `In order to zoom, you must first enable \"Fold heading\" and \"Fold indent\" under Settings -> Editor`\n      );\n      return;\n    }\n\n    const range = this.calculateRangeForZooming.calculateRangeForZooming(\n      view.state,\n      pos\n    );\n\n    if (!range) {\n      l(\"unable to calculate range for zooming\");\n      return;\n    }\n\n    this.keepOnlyZoomedContentVisible.keepOnlyZoomedContentVisible(\n      view,\n      range.from,\n      range.to\n    );\n\n    for (const cb of this.zoomInCallbacks) {\n      cb(view, pos);\n    }\n  }\n\n  public zoomOut(view: EditorView) {\n    const l = this.logger.bind(\"ZoomFeature:zoomIn\");\n    l(\"zooming out\");\n\n    this.keepOnlyZoomedContentVisible.showAllContent(view);\n\n    for (const cb of this.zoomOutCallbacks) {\n      cb(view);\n    }\n  }\n\n  async load() {\n    this.plugin.registerEditorExtension(\n      this.keepOnlyZoomedContentVisible.getExtension()\n    );\n\n    this.plugin.addCommand({\n      id: \"zoom-in\",\n      name: \"Zoom in\",\n      icon: \"zoom-in\",\n      editorCallback: (editor) => {\n        const view = getEditorViewFromEditor(editor);\n        this.zoomIn(view, view.state.selection.main.head);\n      },\n      hotkeys: [\n        {\n          modifiers: [\"Mod\"],\n          key: \".\",\n        },\n      ],\n    });\n\n    this.plugin.addCommand({\n      id: \"zoom-out\",\n      name: \"Zoom out the entire document\",\n      icon: \"zoom-out\",\n      editorCallback: (editor) => this.zoomOut(getEditorViewFromEditor(editor)),\n      hotkeys: [\n        {\n          modifiers: [\"Mod\", \"Shift\"],\n          key: \".\",\n        },\n      ],\n    });\n  }\n\n  async unload() {}\n}\n"
  },
  {
    "path": "src/features/ZoomOnClickFeature.ts",
    "content": "import { Plugin } from \"obsidian\";\n\nimport { EditorView } from \"@codemirror/view\";\n\nimport { Feature } from \"./Feature\";\n\nimport { DetectClickOnBullet } from \"../logic/DetectClickOnBullet\";\nimport { SettingsService } from \"../services/SettingsService\";\n\nexport interface ZoomIn {\n  zoomIn(view: EditorView, pos: number): void;\n}\n\nexport class ZoomOnClickFeature implements Feature {\n  private detectClickOnBullet = new DetectClickOnBullet(this.settings, {\n    clickOnBullet: (view, pos) => this.clickOnBullet(view, pos),\n  });\n\n  constructor(\n    private plugin: Plugin,\n    private settings: SettingsService,\n    private zoomIn: ZoomIn\n  ) {}\n\n  async load() {\n    this.plugin.registerEditorExtension(\n      this.detectClickOnBullet.getExtension()\n    );\n  }\n\n  async unload() {}\n\n  private clickOnBullet(view: EditorView, pos: number) {\n    this.detectClickOnBullet.moveCursorToLineEnd(view, pos);\n    this.zoomIn.zoomIn(view, pos);\n  }\n}\n"
  },
  {
    "path": "src/features/utils/getDocumentTitle.ts",
    "content": "import { editorViewField } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\n\nexport function getDocumentTitle(state: EditorState) {\n  return state.field(editorViewField).getDisplayText();\n}\n"
  },
  {
    "path": "src/features/utils/getEditorViewFromEditorState.ts",
    "content": "import { editorEditorField } from \"obsidian\";\n\nimport { EditorState } from \"@codemirror/state\";\nimport { EditorView } from \"@codemirror/view\";\n\nexport function getEditorViewFromEditorState(state: EditorState): EditorView {\n  return state.field(editorEditorField);\n}\n"
  },
  {
    "path": "src/features/utils/isFoldingEnabled.ts",
    "content": "import { App } from \"obsidian\";\n\nexport function isFoldingEnabled(app: App) {\n  const config: {\n    foldHeading: boolean;\n    foldIndent: boolean;\n  } = {\n    foldHeading: true,\n    foldIndent: true,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    ...(app.vault as any).config,\n  };\n\n  return config.foldHeading && config.foldIndent;\n}\n"
  },
  {
    "path": "src/logic/CalculateRangeForZooming.ts",
    "content": "import { foldable } from \"@codemirror/language\";\nimport { EditorState } from \"@codemirror/state\";\n\nexport class CalculateRangeForZooming {\n  public calculateRangeForZooming(state: EditorState, pos: number) {\n    const line = state.doc.lineAt(pos);\n    const foldRange = foldable(state, line.from, line.to);\n\n    if (!foldRange && /^\\s*([-*+]|\\d+\\.)\\s+/.test(line.text)) {\n      return { from: line.from, to: line.to };\n    }\n\n    if (!foldRange) {\n      return null;\n    }\n\n    return { from: line.from, to: foldRange.to };\n  }\n}\n"
  },
  {
    "path": "src/logic/CollectBreadcrumbs.ts",
    "content": "import { foldable } from \"@codemirror/language\";\nimport { EditorState } from \"@codemirror/state\";\n\nimport { cleanTitle } from \"./utils/cleanTitle\";\n\nexport interface Breadcrumb {\n  title: string;\n  pos: number | null;\n}\n\nexport interface GetDocumentTitle {\n  getDocumentTitle(state: EditorState): string;\n}\n\nexport class CollectBreadcrumbs {\n  constructor(private getDocumentTitle: GetDocumentTitle) {}\n\n  public collectBreadcrumbs(state: EditorState, pos: number) {\n    const breadcrumbs: Breadcrumb[] = [\n      { title: this.getDocumentTitle.getDocumentTitle(state), pos: null },\n    ];\n\n    const posLine = state.doc.lineAt(pos);\n\n    for (let i = 1; i < posLine.number; i++) {\n      const line = state.doc.line(i);\n      const f = foldable(state, line.from, line.to);\n      if (f && f.to > posLine.from) {\n        breadcrumbs.push({ title: cleanTitle(line.text), pos: line.from });\n      }\n    }\n\n    breadcrumbs.push({\n      title: cleanTitle(posLine.text),\n      pos: posLine.from,\n    });\n\n    return breadcrumbs;\n  }\n}\n"
  },
  {
    "path": "src/logic/DetectClickOnBullet.ts",
    "content": "import { EditorSelection } from \"@codemirror/state\";\nimport { EditorView } from \"@codemirror/view\";\n\nimport { isBulletPoint } from \"./utils/isBulletPoint\";\n\nimport { SettingsService } from \"../services/SettingsService\";\n\nexport interface ClickOnBullet {\n  clickOnBullet(view: EditorView, pos: number): void;\n}\n\nexport class DetectClickOnBullet {\n  constructor(\n    private settings: SettingsService,\n    private clickOnBullet: ClickOnBullet\n  ) {}\n\n  getExtension() {\n    return EditorView.domEventHandlers({\n      click: this.detectClickOnBullet,\n    });\n  }\n\n  public moveCursorToLineEnd(view: EditorView, pos: number) {\n    const line = view.state.doc.lineAt(pos);\n\n    view.dispatch({\n      selection: EditorSelection.cursor(line.to),\n    });\n  }\n\n  private detectClickOnBullet = (e: MouseEvent, view: EditorView) => {\n    if (\n      !this.settings.zoomOnClick ||\n      !(e.target instanceof HTMLElement) ||\n      !isBulletPoint(e.target)\n    ) {\n      return;\n    }\n\n    const pos = view.posAtDOM(e.target);\n    this.clickOnBullet.clickOnBullet(view, pos);\n  };\n}\n"
  },
  {
    "path": "src/logic/DetectRangeBeforeVisibleRangeChanged.ts",
    "content": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateVisibleContentBoundariesViolation } from \"./utils/calculateVisibleContentBoundariesViolation\";\n\nexport interface RangeBeforeVisibleRangeChanged {\n  rangeBeforeVisibleRangeChanged(state: EditorState): void;\n}\n\nexport interface CalculateHiddenContentRanges {\n  calculateHiddenContentRanges(\n    state: EditorState\n  ): { from: number; to: number }[] | null;\n}\n\nexport class DetectRangeBeforeVisibleRangeChanged {\n  constructor(\n    private calculateHiddenContentRanges: CalculateHiddenContentRanges,\n    private rangeBeforeVisibleRangeChanged: RangeBeforeVisibleRangeChanged\n  ) {}\n\n  getExtension() {\n    return EditorState.transactionExtender.of(\n      this.detectVisibleContentBoundariesViolation\n    );\n  }\n\n  private detectVisibleContentBoundariesViolation = (tr: Transaction): null => {\n    const hiddenRanges =\n      this.calculateHiddenContentRanges.calculateHiddenContentRanges(\n        tr.startState\n      );\n\n    const { touchedBefore, touchedInside } =\n      calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n    if (touchedBefore && !touchedInside) {\n      setImmediate(() => {\n        this.rangeBeforeVisibleRangeChanged.rangeBeforeVisibleRangeChanged(\n          tr.state\n        );\n      });\n    }\n\n    return null;\n  };\n}\n"
  },
  {
    "path": "src/logic/DetectVisibleContentBoundariesViolation.ts",
    "content": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateVisibleContentBoundariesViolation } from \"./utils/calculateVisibleContentBoundariesViolation\";\n\nexport interface VisibleContentBoundariesViolated {\n  visibleContentBoundariesViolated(state: EditorState): void;\n}\n\nexport interface CalculateHiddenContentRanges {\n  calculateHiddenContentRanges(\n    state: EditorState\n  ): { from: number; to: number }[] | null;\n}\n\nexport class DetectVisibleContentBoundariesViolation {\n  constructor(\n    private calculateHiddenContentRanges: CalculateHiddenContentRanges,\n    private visibleContentBoundariesViolated: VisibleContentBoundariesViolated\n  ) {}\n\n  getExtension() {\n    return EditorState.transactionExtender.of(\n      this.detectVisibleContentBoundariesViolation\n    );\n  }\n\n  private detectVisibleContentBoundariesViolation = (tr: Transaction): null => {\n    const hiddenRanges =\n      this.calculateHiddenContentRanges.calculateHiddenContentRanges(\n        tr.startState\n      );\n\n    const { touchedOutside, touchedInside } =\n      calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n    if (touchedOutside && touchedInside) {\n      setImmediate(() => {\n        this.visibleContentBoundariesViolated.visibleContentBoundariesViolated(\n          tr.state\n        );\n      });\n    }\n\n    return null;\n  };\n}\n"
  },
  {
    "path": "src/logic/KeepOnlyZoomedContentVisible.ts",
    "content": "import { EditorState, Extension, StateField } from \"@codemirror/state\";\nimport { Decoration, DecorationSet, EditorView } from \"@codemirror/view\";\n\nimport { zoomInEffect, zoomOutEffect } from \"./utils/effects\";\nimport { rangeSetToArray } from \"./utils/rangeSetToArray\";\n\nimport { LoggerService } from \"../services/LoggerService\";\n\nconst zoomMarkHidden = Decoration.replace({ block: true });\n\nconst zoomStateField = StateField.define<DecorationSet>({\n  create: () => {\n    return Decoration.none;\n  },\n\n  update: (value, tr) => {\n    value = value.map(tr.changes);\n\n    for (const e of tr.effects) {\n      if (e.is(zoomInEffect)) {\n        value = value.update({ filter: () => false });\n\n        if (e.value.from > 0) {\n          value = value.update({\n            add: [zoomMarkHidden.range(0, e.value.from - 1)],\n          });\n        }\n\n        if (e.value.to < tr.newDoc.length) {\n          value = value.update({\n            add: [zoomMarkHidden.range(e.value.to + 1, tr.newDoc.length)],\n          });\n        }\n      }\n\n      if (e.is(zoomOutEffect)) {\n        value = value.update({ filter: () => false });\n      }\n    }\n\n    return value;\n  },\n\n  provide: (zoomStateField) => EditorView.decorations.from(zoomStateField),\n});\n\nexport class KeepOnlyZoomedContentVisible {\n  constructor(private logger: LoggerService) {}\n\n  public getExtension(): Extension {\n    return zoomStateField;\n  }\n\n  public calculateHiddenContentRanges(state: EditorState) {\n    return rangeSetToArray(state.field(zoomStateField));\n  }\n\n  public calculateVisibleContentRange(state: EditorState) {\n    const hidden = this.calculateHiddenContentRanges(state);\n\n    if (hidden.length === 1) {\n      const [a] = hidden;\n\n      if (a.from === 0) {\n        return { from: a.to + 1, to: state.doc.length };\n      } else {\n        return { from: 0, to: a.from - 1 };\n      }\n    }\n\n    if (hidden.length === 2) {\n      const [a, b] = hidden;\n\n      return { from: a.to + 1, to: b.from - 1 };\n    }\n\n    return null;\n  }\n\n  public keepOnlyZoomedContentVisible(\n    view: EditorView,\n    from: number,\n    to: number,\n    options: { scrollIntoView?: boolean } = {}\n  ) {\n    const { scrollIntoView } = { ...{ scrollIntoView: true }, ...options };\n\n    const effect = zoomInEffect.of({ from, to });\n\n    this.logger.log(\n      \"KeepOnlyZoomedContent:keepOnlyZoomedContentVisible\",\n      \"keep only zoomed content visible\",\n      effect.value.from,\n      effect.value.to\n    );\n\n    view.dispatch({\n      effects: [effect],\n    });\n\n    if (scrollIntoView) {\n      view.dispatch({\n        effects: [\n          EditorView.scrollIntoView(view.state.selection.main, {\n            y: \"start\",\n          }),\n        ],\n      });\n    }\n  }\n\n  public showAllContent(view: EditorView) {\n    this.logger.log(\"KeepOnlyZoomedContent:showAllContent\", \"show all content\");\n\n    view.dispatch({ effects: [zoomOutEffect.of()] });\n    view.dispatch({\n      effects: [\n        EditorView.scrollIntoView(view.state.selection.main, {\n          y: \"center\",\n        }),\n      ],\n    });\n  }\n}\n"
  },
  {
    "path": "src/logic/LimitSelectionOnZoomingIn.ts",
    "content": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateLimitedSelection } from \"./utils/calculateLimitedSelection\";\nimport { ZoomInStateEffect, isZoomInEffect } from \"./utils/effects\";\n\nimport { LoggerService } from \"../services/LoggerService\";\n\nexport class LimitSelectionOnZoomingIn {\n  constructor(private logger: LoggerService) {}\n\n  getExtension() {\n    return EditorState.transactionFilter.of(this.limitSelectionOnZoomingIn);\n  }\n\n  private limitSelectionOnZoomingIn = (tr: Transaction) => {\n    const e = tr.effects.find<ZoomInStateEffect>(isZoomInEffect);\n\n    if (!e) {\n      return tr;\n    }\n\n    const newSelection = calculateLimitedSelection(\n      tr.newSelection,\n      e.value.from,\n      e.value.to\n    );\n\n    if (!newSelection) {\n      return tr;\n    }\n\n    this.logger.log(\n      \"LimitSelectionOnZoomingIn:limitSelectionOnZoomingIn\",\n      \"limiting selection\",\n      newSelection.toJSON()\n    );\n\n    return [tr, { selection: newSelection }];\n  };\n}\n"
  },
  {
    "path": "src/logic/LimitSelectionWhenZoomedIn.ts",
    "content": "import { EditorState, Transaction } from \"@codemirror/state\";\n\nimport { calculateLimitedSelection } from \"./utils/calculateLimitedSelection\";\n\nimport { LoggerService } from \"../services/LoggerService\";\n\nexport interface CalculateVisibleContentRange {\n  calculateVisibleContentRange(\n    state: EditorState\n  ): { from: number; to: number } | null;\n}\n\nexport class LimitSelectionWhenZoomedIn {\n  constructor(\n    private logger: LoggerService,\n    private calculateVisibleContentRange: CalculateVisibleContentRange\n  ) {}\n\n  public getExtension() {\n    return EditorState.transactionFilter.of(this.limitSelectionWhenZoomedIn);\n  }\n\n  private limitSelectionWhenZoomedIn = (tr: Transaction) => {\n    if (!tr.selection || !tr.isUserEvent(\"select\")) {\n      return tr;\n    }\n\n    const range =\n      this.calculateVisibleContentRange.calculateVisibleContentRange(tr.state);\n\n    if (!range) {\n      return tr;\n    }\n\n    const newSelection = calculateLimitedSelection(\n      tr.newSelection,\n      range.from,\n      range.to\n    );\n\n    if (!newSelection) {\n      return tr;\n    }\n\n    this.logger.log(\n      \"LimitSelectionWhenZoomedIn:limitSelectionWhenZoomedIn\",\n      \"limiting selection\",\n      newSelection.toJSON()\n    );\n\n    return [tr, { selection: newSelection }];\n  };\n}\n"
  },
  {
    "path": "src/logic/RenderNavigationHeader.ts",
    "content": "import { StateEffect, StateField } from \"@codemirror/state\";\nimport { EditorView, showPanel } from \"@codemirror/view\";\n\nimport { renderHeader } from \"./utils/renderHeader\";\n\nimport { LoggerService } from \"../services/LoggerService\";\n\nexport interface Breadcrumb {\n  title: string;\n  pos: number | null;\n}\n\nexport interface ZoomIn {\n  zoomIn(view: EditorView, pos: number): void;\n}\n\nexport interface ZoomOut {\n  zoomOut(view: EditorView): void;\n}\n\ninterface HeaderState {\n  breadcrumbs: Breadcrumb[];\n  onClick: (view: EditorView, pos: number | null) => void;\n}\n\nconst showHeaderEffect = StateEffect.define<HeaderState>();\nconst hideHeaderEffect = StateEffect.define<void>();\n\nconst headerState = StateField.define<HeaderState | null>({\n  create: () => null,\n  update: (value, tr) => {\n    for (const e of tr.effects) {\n      if (e.is(showHeaderEffect)) {\n        value = e.value;\n      }\n      if (e.is(hideHeaderEffect)) {\n        value = null;\n      }\n    }\n    return value;\n  },\n  provide: (f) =>\n    showPanel.from(f, (state) => {\n      if (!state) {\n        return null;\n      }\n\n      return (view) => ({\n        top: true,\n        dom: renderHeader(view.dom.ownerDocument, {\n          breadcrumbs: state.breadcrumbs,\n          onClick: (pos) => state.onClick(view, pos),\n        }),\n      });\n    }),\n});\n\nexport class RenderNavigationHeader {\n  getExtension() {\n    return headerState;\n  }\n\n  constructor(\n    private logger: LoggerService,\n    private zoomIn: ZoomIn,\n    private zoomOut: ZoomOut\n  ) {}\n\n  public showHeader(view: EditorView, breadcrumbs: Breadcrumb[]) {\n    const l = this.logger.bind(\"ToggleNavigationHeaderLogic:showHeader\");\n    l(\"show header\");\n\n    view.dispatch({\n      effects: [\n        showHeaderEffect.of({\n          breadcrumbs,\n          onClick: this.onClick,\n        }),\n      ],\n    });\n  }\n\n  public hideHeader(view: EditorView) {\n    const l = this.logger.bind(\"ToggleNavigationHeaderLogic:hideHeader\");\n    l(\"hide header\");\n\n    view.dispatch({\n      effects: [hideHeaderEffect.of()],\n    });\n  }\n\n  private onClick = (view: EditorView, pos: number | null) => {\n    if (pos === null) {\n      this.zoomOut.zoomOut(view);\n    } else {\n      this.zoomIn.zoomIn(view, pos);\n    }\n  };\n}\n"
  },
  {
    "path": "src/logic/__tests__/CalculateRangeForZooming.test.ts",
    "content": "import { EditorState } from \"@codemirror/state\";\n\nimport { CalculateRangeForZooming } from \"../CalculateRangeForZooming\";\n\njest.mock(\"@codemirror/language\", () => {\n  return {\n    foldable: jest.fn(),\n  };\n});\n\nconst foldable: jest.Mock = jest.requireMock(\"@codemirror/language\").foldable;\n\nbeforeEach(() => {\n  foldable.mockReturnValue(null);\n});\n\ntest(\"should return nothing if block is unfoldable\", () => {\n  foldable.mockReturnValue(null);\n  const state = EditorState.create({\n    doc: \"# header\\n\\nline1\\n\",\n  });\n  const calculateRangeForZooming = new CalculateRangeForZooming();\n\n  const x = calculateRangeForZooming.calculateRangeForZooming(state, 1);\n\n  expect(x).toBeNull();\n});\n\ntest(\"should return range from line start if block is foldable\", () => {\n  foldable.mockReturnValue({ from: 8, to: 16 });\n  const state = EditorState.create({\n    doc: \"# header\\n\\nline1\\n\",\n  });\n  const calculateRangeForZooming = new CalculateRangeForZooming();\n\n  const x = calculateRangeForZooming.calculateRangeForZooming(state, 1);\n\n  expect(x).toStrictEqual({ from: 0, to: 16 });\n});\n\ntest(\"should return range of current line if block is unfoldable but line is list item\", () => {\n  foldable.mockReturnValue(null);\n  const state = EditorState.create({\n    doc: \"line\\n\\n- list\\n\\nline\",\n  });\n  const calculateRangeForZooming = new CalculateRangeForZooming();\n\n  const x = calculateRangeForZooming.calculateRangeForZooming(state, 8);\n\n  expect(x).toStrictEqual({ from: 6, to: 12 });\n});\n"
  },
  {
    "path": "src/logic/__tests__/CollectBreadcrumbs.test.ts",
    "content": "import { EditorState } from \"@codemirror/state\";\n\nimport { CollectBreadcrumbs } from \"../CollectBreadcrumbs\";\n\njest.mock(\"@codemirror/language\", () => {\n  return {\n    foldable: jest.fn(),\n  };\n});\n\nconst getDocumentTitle = { getDocumentTitle: () => \"Document\" };\nconst foldable: jest.Mock = jest.requireMock(\"@codemirror/language\").foldable;\n\ntest(\"should return breadcrumbs based on folable zones that should include input position\", () => {\n  const state = EditorState.create({\n    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\",\n    //    0123 4 5678 9 01234 5 6789 0 1234 5 6 7890 1 234567 8 9012 3 45\n    //                  1            2             3             4\n  });\n  foldable.mockImplementation((state, from) => {\n    if (from === 0) return { from: 0, to: 4 };\n    if (from === 5) return { from: 5, to: 38 };\n    if (from === 10) return { from: 10, to: 38 };\n    if (from === 16) return { from: 16, to: 29 };\n    if (from === 20) return { from: 20, to: 29 };\n    if (from === 32) return { from: 32, to: 38 };\n    if (from === 39) return { from: 39, to: 44 };\n    return null;\n  });\n\n  const collectBreadcrumbs = new CollectBreadcrumbs(getDocumentTitle);\n\n  const b = collectBreadcrumbs.collectBreadcrumbs(state, 28);\n\n  expect(b).toStrictEqual([\n    { title: \"Document\", pos: null },\n    { title: \"b\", pos: 5 },\n    { title: \"c\", pos: 10 },\n    { title: \"1\", pos: 16 },\n    { title: \"2\", pos: 20 },\n    { title: \"3\", pos: 25 },\n  ]);\n});\n"
  },
  {
    "path": "src/logic/__tests__/DetectClickOnBullet.test.ts",
    "content": "/**\n * @jest-environment jsdom\n */\nimport { EditorState } from \"@codemirror/state\";\nimport { Decoration, EditorView } from \"@codemirror/view\";\n\nimport { DetectClickOnBullet } from \"../DetectClickOnBullet\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst settings: any = { zoomOnClick: true };\nconst clickOnBullet = { clickOnBullet: jest.fn() };\nconst detectClickOnBullet = new DetectClickOnBullet(settings, clickOnBullet);\nconst decs = Decoration.set([\n  Decoration.mark({ class: \"list-bullet\" }).range(0, 1),\n  Decoration.mark({ class: \"cm-formatting-list\" }).range(6, 7),\n  Decoration.mark({ class: \"other\" }).range(8, 9),\n]);\nconst view = new EditorView({\n  state: EditorState.create({\n    doc: \"- 1\\n  - 2\",\n    extensions: [\n      detectClickOnBullet.getExtension(),\n      EditorView.decorations.of(decs),\n    ],\n  }),\n  parent: document.body,\n});\n\ntest(\"should detect click on span.list-bullet\", () => {\n  view.dom.querySelector<HTMLSpanElement>(\".list-bullet\").click();\n\n  expect(clickOnBullet.clickOnBullet).toBeCalled();\n});\n\ntest(\"should detect click on span.cm-formatting-list\", () => {\n  view.dom.querySelector<HTMLSpanElement>(\".cm-formatting-list\").click();\n\n  expect(clickOnBullet.clickOnBullet).toBeCalled();\n});\n\ntest(\"should not detect click on other elements\", () => {\n  view.dom.querySelector<HTMLSpanElement>(\".other\").click();\n\n  expect(clickOnBullet.clickOnBullet).not.toBeCalled();\n});\n"
  },
  {
    "path": "src/logic/utils/__tests__/calculateLimitedSelection.test.ts",
    "content": "import { EditorSelection } from \"@codemirror/state\";\n\nimport { calculateLimitedSelection } from \"../calculateLimitedSelection\";\n\ntest(\"should limit selection if visible area is smaller\", () => {\n  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);\n  const visibleArea = [12, 18];\n\n  const newSelection = calculateLimitedSelection(\n    selection,\n    visibleArea[0],\n    visibleArea[1]\n  );\n\n  expect(newSelection.from).toBe(12);\n  expect(newSelection.to).toBe(18);\n});\n\ntest(\"should limit selection if visible area ends before selection\", () => {\n  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);\n  const visibleArea = [1, 18];\n\n  const newSelection = calculateLimitedSelection(\n    selection,\n    visibleArea[0],\n    visibleArea[1]\n  );\n\n  expect(newSelection.from).toBe(10);\n  expect(newSelection.to).toBe(18);\n});\n\ntest(\"should limit selection if visible area starts after selection\", () => {\n  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);\n  const visibleArea = [12, 30];\n\n  const newSelection = calculateLimitedSelection(\n    selection,\n    visibleArea[0],\n    visibleArea[1]\n  );\n\n  expect(newSelection.from).toBe(12);\n  expect(newSelection.to).toBe(20);\n});\n\ntest(\"should not limit selection if visible area is bigger\", () => {\n  const selection = EditorSelection.create([EditorSelection.range(10, 20)]);\n  const visibleArea = [1, 30];\n\n  const newSelection = calculateLimitedSelection(\n    selection,\n    visibleArea[0],\n    visibleArea[1]\n  );\n\n  expect(newSelection).toBeNull();\n});\n"
  },
  {
    "path": "src/logic/utils/__tests__/calculateVisibleContentBoundariesViolation.test.ts",
    "content": "import { EditorState } from \"@codemirror/state\";\n\nimport { calculateVisibleContentBoundariesViolation } from \"../calculateVisibleContentBoundariesViolation\";\n\nconst state = EditorState.create({ doc: \"line1\\nline2\\nline3\" });\nconst hiddenRanges = [\n  { from: 0, to: 5 },\n  { from: 12, to: 17 },\n];\n\ntest(\"should calculate correctly when changes are touching area before visible content\", () => {\n  const tr = state.update({ changes: { from: 0, to: 1, insert: \"X\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeTruthy();\n  expect(res.touchedAfter).toBeFalsy();\n  expect(res.touchedOutside).toBeTruthy();\n  expect(res.touchedInside).toBeFalsy();\n});\n\ntest(\"should calculate correctly when changes are touching area after visible content\", () => {\n  const tr = state.update({ changes: { from: 12, to: 13, insert: \"X\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeFalsy();\n  expect(res.touchedAfter).toBeTruthy();\n  expect(res.touchedOutside).toBeTruthy();\n  expect(res.touchedInside).toBeFalsy();\n});\n\ntest(\"should calculate correctly when changes are touching visible content\", () => {\n  const tr = state.update({ changes: { from: 6, to: 7, insert: \"X\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeFalsy();\n  expect(res.touchedAfter).toBeFalsy();\n  expect(res.touchedOutside).toBeFalsy();\n  expect(res.touchedInside).toBeTruthy();\n});\n\ntest(\"should calculate correctly when changes are crossing first boundary of visible content\", () => {\n  const tr = state.update({ changes: { from: 4, to: 7, insert: \"X\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeTruthy();\n  expect(res.touchedAfter).toBeFalsy();\n  expect(res.touchedOutside).toBeTruthy();\n  expect(res.touchedInside).toBeTruthy();\n});\n\ntest(\"should calculate correctly when changes are crossing second boundary of visible content\", () => {\n  const tr = state.update({ changes: { from: 8, to: 13, insert: \"X\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeFalsy();\n  expect(res.touchedAfter).toBeTruthy();\n  expect(res.touchedOutside).toBeTruthy();\n  expect(res.touchedInside).toBeTruthy();\n});\n\ntest(\"should calculate correctly when changes are removing newline just before first boundary of visible content\", () => {\n  const tr = state.update({ changes: { from: 5, to: 6, insert: \"\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeTruthy();\n  expect(res.touchedAfter).toBeFalsy();\n  expect(res.touchedOutside).toBeTruthy();\n  expect(res.touchedInside).toBeTruthy();\n});\n\ntest(\"should calculate correctly when changes are removing newline just after second boundary of visible content\", () => {\n  const tr = state.update({ changes: { from: 11, to: 12, insert: \"\" } });\n\n  const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges);\n\n  expect(res.touchedBefore).toBeFalsy();\n  expect(res.touchedAfter).toBeTruthy();\n  expect(res.touchedOutside).toBeTruthy();\n  expect(res.touchedInside).toBeTruthy();\n});\n"
  },
  {
    "path": "src/logic/utils/__tests__/cleanTitle.test.ts",
    "content": "import { cleanTitle } from \"../cleanTitle\";\n\ntest(\"should clean title\", () => {\n  expect(cleanTitle(\" Text with spaces \")).toBe(\"Text with spaces\");\n  expect(cleanTitle(\"# Some header\")).toBe(\"Some header\");\n  expect(cleanTitle(\"## Some header\")).toBe(\"Some header\");\n  expect(cleanTitle(\"### Some header\")).toBe(\"Some header\");\n  expect(cleanTitle(\"#### Some header\")).toBe(\"Some header\");\n  expect(cleanTitle(\"#\\tSome header\")).toBe(\"Some header\");\n  expect(cleanTitle(\"#Some invalid header\")).toBe(\"#Some invalid header\");\n  expect(cleanTitle(\"- Some bullet\")).toBe(\"Some bullet\");\n  expect(cleanTitle(\"+ Some bullet\")).toBe(\"Some bullet\");\n  expect(cleanTitle(\"* Some bullet\")).toBe(\"Some bullet\");\n  expect(cleanTitle(\"  * Some bullet  \")).toBe(\"Some bullet\");\n  expect(cleanTitle(\"\\t*\\tSome bullet  \")).toBe(\"Some bullet\");\n  expect(cleanTitle(\"\\t*Some invalid bullet  \")).toBe(\"*Some invalid bullet\");\n});\n"
  },
  {
    "path": "src/logic/utils/__tests__/rangeSetToArray.test.ts",
    "content": "import { RangeSetBuilder } from \"@codemirror/state\";\nimport { Decoration } from \"@codemirror/view\";\n\nimport { rangeSetToArray } from \"../rangeSetToArray\";\n\ntest(\"should return array of ranges\", () => {\n  const dec = Decoration.replace({});\n  const rsb = new RangeSetBuilder();\n  rsb.add(1, 2, dec);\n  rsb.add(10, 20, dec);\n  rsb.add(30, 40, dec);\n  const rs = rsb.finish();\n\n  const ranges = rangeSetToArray(rs);\n\n  expect(ranges).toStrictEqual([\n    { from: 1, to: 2 },\n    { from: 10, to: 20 },\n    { from: 30, to: 40 },\n  ]);\n});\n"
  },
  {
    "path": "src/logic/utils/__tests__/renderHeader.test.ts",
    "content": "/**\n * @jest-environment jsdom\n */\nimport { renderHeader } from \"../renderHeader\";\n\ntest(\"should render html\", () => {\n  const h = renderHeader(document, {\n    breadcrumbs: [\n      { title: \"Document\", pos: null },\n      { title: \"header 1\", pos: 10 },\n    ],\n    onClick: () => {},\n  });\n\n  expect(h.outerHTML).toBe(\n    `<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>`\n  );\n});\n\ntest(\"should handle click on document link\", () => {\n  const onClick = jest.fn();\n  const h = renderHeader(document, {\n    breadcrumbs: [\n      { title: \"Document\", pos: null },\n      { title: \"header 1\", pos: 10 },\n    ],\n    onClick,\n  });\n\n  h.querySelectorAll<HTMLSpanElement>(\".zoom-plugin-title\")[0].click();\n\n  expect(onClick).toHaveBeenCalledWith(null);\n});\n\ntest(\"should handle click on header link\", () => {\n  const onClick = jest.fn();\n  const h = renderHeader(document, {\n    breadcrumbs: [\n      { title: \"Document\", pos: null },\n      { title: \"header 1\", pos: 10 },\n    ],\n    onClick,\n  });\n\n  h.querySelectorAll<HTMLSpanElement>(\".zoom-plugin-title\")[1].click();\n\n  expect(onClick).toHaveBeenCalledWith(10);\n});\n"
  },
  {
    "path": "src/logic/utils/calculateLimitedSelection.ts",
    "content": "import { EditorSelection } from \"@codemirror/state\";\n\nexport function calculateLimitedSelection(\n  selection: EditorSelection,\n  from: number,\n  to: number\n) {\n  const mainSelection = selection.main;\n\n  const newSelection = EditorSelection.range(\n    Math.min(Math.max(mainSelection.anchor, from), to),\n    Math.min(Math.max(mainSelection.head, from), to),\n    mainSelection.goalColumn\n  );\n\n  const shouldUpdate =\n    selection.ranges.length > 1 ||\n    newSelection.anchor !== mainSelection.anchor ||\n    newSelection.head !== mainSelection.head;\n\n  return shouldUpdate ? newSelection : null;\n}\n"
  },
  {
    "path": "src/logic/utils/calculateVisibleContentBoundariesViolation.ts",
    "content": "import { Transaction } from \"@codemirror/state\";\n\nexport function calculateVisibleContentBoundariesViolation(\n  tr: Transaction,\n  hiddenRanges: Array<{ from: number; to: number }>\n) {\n  let touchedBefore = false;\n  let touchedAfter = false;\n  let touchedInside = false;\n\n  const t = (f: number, t: number) => Boolean(tr.changes.touchesRange(f, t));\n\n  if (hiddenRanges.length === 2) {\n    const [a, b] = hiddenRanges;\n\n    touchedBefore = t(a.from, a.to);\n    touchedInside = t(a.to + 1, b.from - 1);\n    touchedAfter = t(b.from, b.to);\n  }\n\n  if (hiddenRanges.length === 1) {\n    const [a] = hiddenRanges;\n\n    if (a.from === 0) {\n      touchedBefore = t(a.from, a.to);\n      touchedInside = t(a.to + 1, tr.newDoc.length);\n    } else {\n      touchedInside = t(0, a.from - 1);\n      touchedAfter = t(a.from, a.to);\n    }\n  }\n\n  const touchedOutside = touchedBefore || touchedAfter;\n\n  const res = {\n    touchedOutside,\n    touchedBefore,\n    touchedAfter,\n    touchedInside,\n  };\n\n  return res;\n}\n"
  },
  {
    "path": "src/logic/utils/cleanTitle.ts",
    "content": "export function cleanTitle(title: string) {\n  return title\n    .trim()\n    .replace(/^#+(\\s)/, \"$1\")\n    .replace(/^([-+*]|\\d+\\.)(\\s)/, \"$2\")\n    .trim();\n}\n"
  },
  {
    "path": "src/logic/utils/effects.ts",
    "content": "import { StateEffect } from \"@codemirror/state\";\n\nexport interface ZoomInRange {\n  from: number;\n  to: number;\n}\n\nexport type ZoomInStateEffect = StateEffect<ZoomInRange>;\n\nexport const zoomInEffect = StateEffect.define<ZoomInRange>();\n\nexport const zoomOutEffect = StateEffect.define<void>();\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function isZoomInEffect(e: StateEffect<any>): e is ZoomInStateEffect {\n  return e.is(zoomInEffect);\n}\n"
  },
  {
    "path": "src/logic/utils/isBulletPoint.ts",
    "content": "export function isBulletPoint(e: HTMLElement) {\n  return (\n    e instanceof HTMLSpanElement &&\n    (e.classList.contains(\"list-bullet\") ||\n      e.classList.contains(\"cm-formatting-list\"))\n  );\n}\n"
  },
  {
    "path": "src/logic/utils/rangeSetToArray.ts",
    "content": "import { RangeSet, RangeValue } from \"@codemirror/state\";\n\nexport function rangeSetToArray<T extends RangeValue>(\n  rs: RangeSet<T>\n): Array<{ from: number; to: number }> {\n  const res = [];\n  const i = rs.iter();\n  while (i.value !== null) {\n    res.push({ from: i.from, to: i.to });\n    i.next();\n  }\n  return res;\n}\n"
  },
  {
    "path": "src/logic/utils/renderHeader.ts",
    "content": "export function renderHeader(\n  doc: Document,\n  ctx: {\n    breadcrumbs: Array<{ title: string; pos: number | null }>;\n    onClick: (pos: number | null) => void;\n  }\n) {\n  const { breadcrumbs, onClick } = ctx;\n\n  const h = doc.createElement(\"div\");\n  h.classList.add(\"zoom-plugin-header\");\n\n  for (let i = 0; i < breadcrumbs.length; i++) {\n    if (i > 0) {\n      const d = doc.createElement(\"span\");\n      d.classList.add(\"zoom-plugin-delimiter\");\n      d.innerText = \">\";\n      h.append(d);\n    }\n\n    const breadcrumb = breadcrumbs[i];\n    const b = doc.createElement(\"a\");\n    b.classList.add(\"zoom-plugin-title\");\n    b.dataset.pos = String(breadcrumb.pos);\n    b.appendChild(doc.createTextNode(breadcrumb.title));\n    b.addEventListener(\"click\", (e) => {\n      e.preventDefault();\n      const t = e.target as HTMLAnchorElement;\n      const pos = t.dataset.pos;\n      onClick(pos === \"null\" ? null : Number(pos));\n    });\n    h.appendChild(b);\n  }\n\n  return h;\n}\n"
  },
  {
    "path": "src/services/LoggerService.ts",
    "content": "import { SettingsService } from \"./SettingsService\";\n\nexport class LoggerService {\n  constructor(private settings: SettingsService) {}\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  log(method: string, ...args: any[]) {\n    if (!this.settings.debug) {\n      return;\n    }\n\n    console.info(method, ...args);\n  }\n\n  bind(method: string) {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return (...args: any[]) => this.log(method, ...args);\n  }\n}\n"
  },
  {
    "path": "src/services/SettingsService.ts",
    "content": "import { Platform } from \"obsidian\";\n\nexport interface ObsidianZoomPluginSettings {\n  debug: boolean;\n  zoomOnClick: boolean;\n}\n\ninterface ObsidianZoomPluginSettingsJson {\n  debug: boolean;\n  zoomOnClick: boolean;\n  zoomOnClickMobile: boolean;\n}\n\nconst DEFAULT_SETTINGS: ObsidianZoomPluginSettingsJson = {\n  debug: false,\n  zoomOnClick: true,\n  zoomOnClickMobile: false,\n};\n\nexport interface Storage {\n  loadData(): Promise<any>; // eslint-disable-line @typescript-eslint/no-explicit-any\n  saveData(settigns: any): Promise<void>; // eslint-disable-line @typescript-eslint/no-explicit-any\n}\n\ntype K = keyof ObsidianZoomPluginSettings;\ntype V<T extends K> = ObsidianZoomPluginSettings[T];\ntype Callback<T extends K> = (cb: V<T>) => void;\n\nconst zoomOnClickProp = Platform.isDesktop\n  ? \"zoomOnClick\"\n  : \"zoomOnClickMobile\";\n\nconst mappingToJson = {\n  zoomOnClick: zoomOnClickProp,\n  debug: \"debug\",\n} as {\n  [key in keyof ObsidianZoomPluginSettings]: keyof ObsidianZoomPluginSettingsJson;\n};\n\nexport class SettingsService implements ObsidianZoomPluginSettings {\n  private storage: Storage;\n  private values: ObsidianZoomPluginSettingsJson;\n  private handlers: Map<K, Set<Callback<K>>>;\n\n  constructor(storage: Storage) {\n    this.storage = storage;\n    this.handlers = new Map();\n  }\n\n  get debug() {\n    return this.values.debug;\n  }\n  set debug(value: boolean) {\n    this.set(\"debug\", value);\n  }\n\n  get zoomOnClick() {\n    return this.values[mappingToJson.zoomOnClick];\n  }\n  set zoomOnClick(value: boolean) {\n    this.set(\"zoomOnClick\", value);\n  }\n\n  onChange<T extends K>(key: T, cb: Callback<T>) {\n    if (!this.handlers.has(key)) {\n      this.handlers.set(key, new Set());\n    }\n\n    this.handlers.get(key).add(cb);\n  }\n\n  removeCallback<T extends K>(key: T, cb: Callback<T>): void {\n    const handlers = this.handlers.get(key);\n\n    if (handlers) {\n      handlers.delete(cb);\n    }\n  }\n\n  async load() {\n    this.values = Object.assign(\n      {},\n      DEFAULT_SETTINGS,\n      await this.storage.loadData()\n    );\n  }\n\n  async save() {\n    await this.storage.saveData(this.values);\n  }\n\n  private set<T extends K>(key: T, value: V<K>): void {\n    this.values[mappingToJson[key]] = value;\n    const callbacks = this.handlers.get(key);\n\n    if (!callbacks) {\n      return;\n    }\n\n    for (const cb of callbacks.values()) {\n      cb(value);\n    }\n  }\n}\n"
  },
  {
    "path": "src/utils/getEditorViewFromEditor.ts",
    "content": "import { Editor } from \"obsidian\";\n\nimport { EditorView } from \"@codemirror/view\";\n\nexport function getEditorViewFromEditor(editor: Editor): EditorView {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  return (editor as any).cm;\n}\n"
  },
  {
    "path": "styles.css",
    "content": ".zoom-plugin-header {\n  display: flex;\n  flex-wrap: wrap;\n  margin: var(--file-margins);\n  margin-top: var(--size-4-2);\n  margin-bottom: var(--size-4-2);\n}\n\n.zoom-plugin-title {\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.zoom-plugin-delimiter {\n  display: inline-block;\n  padding: 0 var(--size-4-2);\n}\n\n.zoom-plugin-bls-zoom .cm-editor .cm-formatting-list-ol,\n.zoom-plugin-bls-zoom .cm-editor .cm-formatting-list-ul {\n  cursor: pointer;\n}\n\n.zoom-plugin-bls-zoom\n  .markdown-source-view.mod-cm6\n  .cm-fold-indicator\n  .collapse-indicator {\n  margin-right: 6px;\n  padding-right: 0;\n}\n\n.zoom-plugin-bls-zoom\n  .markdown-source-view.mod-cm6\n  .cm-line:not(.cm-active):not(.HyperMD-header):not(.HyperMD-task-line)\n  .cm-fold-indicator\n  .collapse-indicator {\n  margin-right: 18px;\n  padding-right: 0;\n}\n\n.markdown-source-view.mod-cm6 .cm-panels {\n  border-bottom-color: var(--background-modifier-border);\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"inlineSourceMap\": true,\n    \"inlineSources\": true,\n    \"module\": \"ESNext\",\n    \"target\": \"es6\",\n    \"allowJs\": true,\n    \"noImplicitAny\": true,\n    \"moduleResolution\": \"node\",\n    \"importHelpers\": true,\n    \"lib\": [\"dom\", \"es5\", \"scripthost\", \"es2015\"]\n  },\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "versions.json",
    "content": "{\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.9\",\n  \"0.3.0\": \"0.15.9\",\n  \"0.2.8\": \"0.13.19\",\n  \"0.2.7\": \"0.13.14\",\n  \"0.2.6\": \"0.13.14\",\n  \"0.2.5\": \"0.13.14\",\n  \"0.2.4\": \"0.13.14\",\n  \"0.2.3\": \"0.13.10\",\n  \"0.2.2\": \"0.13.10\",\n  \"0.2.1\": \"0.13.10\",\n  \"0.2.0\": \"0.13.10\",\n  \"0.1.2\": \"0.12.3\",\n  \"0.1.1\": \"0.12.3\",\n  \"0.1.0\": \"0.12.3\"\n}\n"
  }
]