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