├── vault ├── test.md ├── .obsidian │ ├── appearance.json │ ├── community-plugins.json │ ├── app.json │ ├── plugins │ │ └── obsidian-outliner │ │ │ └── manifest.json │ └── core-plugins.json └── Testing.md ├── versions.json ├── .npmrc ├── src ├── utils │ ├── checkboxRe.ts │ ├── isEmptyLineOrEmptyCheckbox.ts │ ├── getEditorViewFromEditor.ts │ ├── createEditorCallback.ts │ └── createKeymapRunCallback.ts ├── features │ ├── Feature.ts │ ├── zoom-utils │ │ ├── getEditorViewFromEditorState.ts │ │ ├── getDocumentTitle.ts │ │ └── isFoldingEnabled.ts │ ├── ListsStylesFeature.ts │ ├── ZoomOnClickFeature.ts │ ├── BetterListsStyles.ts │ ├── LimitSelectionFeature.ts │ ├── CtrlAAndCmdABehaviourOverride.ts │ ├── DeleteBehaviourOverride.ts │ ├── ShiftTabBehaviourOverride.ts │ ├── BackspaceBehaviourOverride.ts │ ├── MetaBackspaceBehaviourOverride.ts │ ├── TabBehaviourOverride.ts │ ├── ArrowLeftAndCtrlArrowLeftBehaviourOverride.ts │ ├── ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts │ ├── ListsFoldingCommands.ts │ ├── EnterBehaviourOverride.ts │ ├── EditorSelectionsBehaviourOverride.ts │ ├── SystemInfo.ts │ ├── ListsMovementCommands.ts │ ├── ReleaseNotesAnnouncement.ts │ └── VimOBehaviourOverride.ts ├── operations │ ├── Operation.ts │ ├── KeepCursorOutsideFoldedLines.ts │ ├── OutdentListIfItsEmpty.ts │ ├── DeleteTillCurrentLineContentStart.ts │ ├── KeepCursorWithinListContent.ts │ ├── OutdentList.ts │ ├── DeleteTillNextLineContentStart.ts │ ├── MoveListUp.ts │ ├── MoveListDown.ts │ ├── MoveCursorToPreviousUnfoldedLine.ts │ ├── IndentList.ts │ ├── ExpandSelectionToFullItems.ts │ ├── SelectAllContent.ts │ ├── __tests__ │ │ ├── DeleteTillCurrentLineContentStart.test.ts │ │ ├── OutdentListIfItsEmpty.test.ts │ │ ├── KeepCursorOutsideFoldedLines.test.ts │ │ ├── OutdentList.test.ts │ │ ├── DeleteTillPreviousLineContentEnd.test.ts │ │ ├── MoveCursorToPreviousUnfoldedLine.test.ts │ │ ├── MoveListUp.test.ts │ │ └── KeepCursorWithinListContent.test.ts │ ├── DeleteTillPreviousLineContentEnd.ts │ └── MoveListToDifferentPosition.ts ├── logic │ ├── utils │ │ ├── isBulletPoint.ts │ │ ├── rangeSetToArray.ts │ │ ├── effects.ts │ │ ├── __tests__ │ │ │ ├── rangeSetToArray.test.ts │ │ │ ├── cleanTitle.test.ts │ │ │ ├── calculateLimitedSelection.test.ts │ │ │ └── calculateVisibleContentBoundariesViolation.test.ts │ │ ├── calculateLimitedSelection.ts │ │ ├── calculateVisibleContentBoundariesViolation.ts │ │ └── cleanTitle.ts │ ├── CalculateRangeForZooming.ts │ ├── LimitSelectionOnZoomingIn.ts │ ├── CollectBreadcrumbs.ts │ ├── DetectClickOnBullet.ts │ ├── LimitSelectionWhenZoomedIn.ts │ ├── DetectRangeBeforeVisibleRangeChanged.ts │ ├── DetectVisibleContentBoundariesViolation.ts │ ├── __tests__ │ │ ├── DetectClickOnBullet.test.ts │ │ ├── CollectBreadcrumbs.test.ts │ │ └── CalculateRangeForZooming.test.ts │ ├── KeepOnlyZoomedContentVisible.ts │ └── RenderNavigationHeader.ts ├── services │ ├── Logger.ts │ ├── IMEDetector.ts │ ├── OperationPerformer.ts │ ├── ObsidianSettings.ts │ ├── __tests__ │ │ └── ChangesApplicator.test.ts │ └── ChangesApplicator.ts ├── __mocks__.ts ├── root │ └── __tests__ │ │ └── index.test.ts └── editor │ └── index.ts ├── demos └── demo1.gif ├── icons ├── fold.png ├── indent.png ├── unfold.png ├── move-up.png ├── outdent.png └── move-down.png ├── babel.config.js ├── specs ├── features │ ├── ListsFoldingCommands.spec.md │ ├── MetaBackspaceBehaviourOverride.spec.md │ ├── ShiftTabBehaviourOverride.spec.md │ ├── DeleteBehaviourOverride.spec.md │ ├── ArrowLeftAndCtrlArrowLeftBehaviourOverride.spec.md │ ├── TabBehaviourOverride.spec.md │ ├── BackspaceBehaviourOverride.spec.md │ ├── EditorSelectionsBehaviourOverride.spec.md │ ├── DragAndDrop.spec.md │ ├── CtrlAAndCmdABehaviourOverride.spec.md │ └── ListsMovementCommands.spec.md ├── services │ ├── Parser.spec.md │ └── ChangesApplicator.spec.md ├── ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md ├── ZoomFeature.spec.md ├── LimitSelectionFeature.spec.md └── DefaultObsidianBehaviour.spec.md ├── .gitignore ├── manifest.json ├── eslint.config.mjs ├── jest ├── global-teardown.js ├── test-globals.d.ts ├── obsidian-environment.js └── obsidian-expect.js ├── tsconfig.json ├── jest.config.json ├── .prettierrc.js ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── build.yml │ └── release.yml ├── rollup.config.mjs ├── LICENSE ├── package.json └── CHANGELOG.md /vault/test.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vault/.obsidian/appearance.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.8.7" 3 | } 4 | 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | loglevel=error 3 | 4 | -------------------------------------------------------------------------------- /vault/.obsidian/community-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "obsidian-outliner" 3 | ] -------------------------------------------------------------------------------- /src/utils/checkboxRe.ts: -------------------------------------------------------------------------------- 1 | export const checkboxRe = `\\[[^\\[\\]]\\][ \t]`; 2 | -------------------------------------------------------------------------------- /demos/demo1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/demos/demo1.gif -------------------------------------------------------------------------------- /icons/fold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/icons/fold.png -------------------------------------------------------------------------------- /icons/indent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/icons/indent.png -------------------------------------------------------------------------------- /icons/unfold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/icons/unfold.png -------------------------------------------------------------------------------- /vault/Testing.md: -------------------------------------------------------------------------------- 1 | - First level 2 | - Second level 3 | - Something more 4 | - 5 | 6 | 7 | -------------------------------------------------------------------------------- /icons/move-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/icons/move-up.png -------------------------------------------------------------------------------- /icons/outdent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/icons/outdent.png -------------------------------------------------------------------------------- /icons/move-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrkhachaturov/obsidian-pro-outliner/HEAD/icons/move-down.png -------------------------------------------------------------------------------- /src/features/Feature.ts: -------------------------------------------------------------------------------- 1 | export interface Feature { 2 | load(): Promise; 3 | unload(): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/isEmptyLineOrEmptyCheckbox.ts: -------------------------------------------------------------------------------- 1 | export function isEmptyLineOrEmptyCheckbox(line: string) { 2 | return line === "" || line === "[ ] "; 3 | } 4 | -------------------------------------------------------------------------------- /vault/.obsidian/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "foldHeading": true, 3 | "foldIndent": true, 4 | "useTab": false, 5 | "tabSize": 2, 6 | "legacyEditor": false 7 | } -------------------------------------------------------------------------------- /src/operations/Operation.ts: -------------------------------------------------------------------------------- 1 | export interface Operation { 2 | shouldStopPropagation(): boolean; 3 | shouldUpdate(): boolean; 4 | perform(): void; 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | 8 | -------------------------------------------------------------------------------- /src/logic/utils/isBulletPoint.ts: -------------------------------------------------------------------------------- 1 | export function isBulletPoint(e: HTMLElement) { 2 | return ( 3 | e instanceof HTMLSpanElement && 4 | (e.classList.contains("list-bullet") || 5 | e.classList.contains("cm-formatting-list")) 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /specs/features/ListsFoldingCommands.spec.md: -------------------------------------------------------------------------------- 1 | # should fold 2 | 3 | - applyState: 4 | 5 | ```md 6 | - one| 7 | - two 8 | ``` 9 | 10 | - execute: `obsidian-outliner:fold` 11 | - assertState: 12 | 13 | ```md 14 | - one| #folded 15 | - two 16 | ``` 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | main.js 3 | *.js.map 4 | 5 | # Dependencies 6 | node_modules/ 7 | 8 | # IDE 9 | .idea/ 10 | *.iml 11 | .vscode/ 12 | 13 | # OS files 14 | .DS_Store 15 | Thumbs.db 16 | 17 | # Husky 18 | .husky/_ 19 | 20 | # Test cache 21 | .jest-cache/ 22 | 23 | -------------------------------------------------------------------------------- /src/utils/getEditorViewFromEditor.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "obsidian"; 2 | 3 | import { EditorView } from "@codemirror/view"; 4 | 5 | export function getEditorViewFromEditor(editor: Editor): EditorView { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | return (editor as any).cm; 8 | } 9 | -------------------------------------------------------------------------------- /specs/services/Parser.spec.md: -------------------------------------------------------------------------------- 1 | # should ignore space on last line 2 | 3 | 4 | - applyState: 5 | 6 | ```md 7 | - one 8 | - two 9 | - three| 10 | 11 | ``` 12 | 13 | - execute: `obsidian-outliner:move-list-item-up` 14 | - assertState: 15 | 16 | ```md 17 | - one 18 | - three| 19 | - two 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /src/features/zoom-utils/getEditorViewFromEditorState.ts: -------------------------------------------------------------------------------- 1 | import { editorEditorField } from "obsidian"; 2 | 3 | import { EditorState } from "@codemirror/state"; 4 | import { EditorView } from "@codemirror/view"; 5 | 6 | export function getEditorViewFromEditorState(state: EditorState): EditorView { 7 | return state.field(editorEditorField); 8 | } 9 | -------------------------------------------------------------------------------- /src/features/zoom-utils/getDocumentTitle.ts: -------------------------------------------------------------------------------- 1 | import { editorViewField } from "obsidian"; 2 | 3 | import { EditorState } from "@codemirror/state"; 4 | 5 | export function getDocumentTitle(state: EditorState) { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | return (state.field(editorViewField) as any).getDisplayText(); 8 | } 9 | -------------------------------------------------------------------------------- /src/logic/utils/rangeSetToArray.ts: -------------------------------------------------------------------------------- 1 | import { RangeSet, RangeValue } from "@codemirror/state"; 2 | 3 | export function rangeSetToArray( 4 | rs: RangeSet, 5 | ): Array<{ from: number; to: number }> { 6 | const res = []; 7 | const i = rs.iter(); 8 | while (i.value !== null) { 9 | res.push({ from: i.from, to: i.to }); 10 | i.next(); 11 | } 12 | return res; 13 | } 14 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-pro-outliner", 3 | "name": "Pro Outliner", 4 | "version": "1.0.6", 5 | "minAppVersion": "1.8.7", 6 | "description": "Work with your lists like in Workflowy, RoamResearch, or Tana, with powerful zoom functionality. Combines the best of Outliner and Zoom plugins.", 7 | "author": "Ruben Khachaturov", 8 | "authorUrl": "https://github.com/mrkhachaturov", 9 | "isDesktopOnly": false 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/features/zoom-utils/isFoldingEnabled.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | 3 | export function isFoldingEnabled(app: App) { 4 | const config: { 5 | foldHeading: boolean; 6 | foldIndent: boolean; 7 | } = { 8 | foldHeading: true, 9 | foldIndent: true, 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | ...(app.vault as any).config, 12 | }; 13 | 14 | return config.foldHeading && config.foldIndent; 15 | } 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | 4 | export default tseslint.config( 5 | eslint.configs.recommended, 6 | ...tseslint.configs.recommended, 7 | { 8 | rules: { 9 | "@typescript-eslint/no-this-alias": 0, 10 | "@typescript-eslint/no-unused-vars": [ 11 | "error", 12 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 13 | ], 14 | }, 15 | }, 16 | ); 17 | 18 | -------------------------------------------------------------------------------- /jest/global-teardown.js: -------------------------------------------------------------------------------- 1 | const cp = require("child_process"); 2 | const fs = require("fs"); 3 | const debug = require("debug")("jest-obsidian"); 4 | 5 | module.exports = () => { 6 | if (global.wss) { 7 | global.wss.close(); 8 | } 9 | 10 | cp.spawnSync(KILL_CMD[0], KILL_CMD.slice(1)); 11 | 12 | if (global.originalObsidianConfig) { 13 | debug(`Restoring ${OBSIDIAN_CONFIG_PATH}`); 14 | fs.writeFileSync(OBSIDIAN_CONFIG_PATH, originalObsidianConfig); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/services/Logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Settings } from "./Settings"; 3 | 4 | export class Logger { 5 | constructor(private settings: Settings) {} 6 | 7 | log(method: string, ...args: any[]) { 8 | if (!this.settings.debug) { 9 | return; 10 | } 11 | 12 | console.info(method, ...args); 13 | } 14 | 15 | bind(method: string) { 16 | return (...args: any[]) => this.log(method, ...args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vault/.obsidian/plugins/obsidian-outliner/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-pro-outliner", 3 | "name": "Pro Outliner", 4 | "version": "1.0.0", 5 | "minAppVersion": "1.8.7", 6 | "description": "Work with your lists like in Workflowy, RoamResearch, or Tana, with powerful zoom functionality. Combines the best of Outliner and Zoom plugins.", 7 | "author": "Ruben Khachaturov", 8 | "authorUrl": "https://github.com/mrkhachaturov", 9 | "isDesktopOnly": false 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": ".", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "es6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "lib": ["dom", "es5", "scripthost", "es2015"], 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src/**/*.ts"], 17 | "exclude": ["node_modules"] 18 | } 19 | 20 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "clearMocks": true, 3 | "transform": { 4 | "\\.ts$": "babel-jest", 5 | "\\.spec\\.md$": "./jest/md-spec-transformer.js" 6 | }, 7 | "testRegex": ["/__tests__/.*\\.ts$", "\\.spec\\.md$"], 8 | "moduleFileExtensions": ["js", "ts", "md"], 9 | "globalSetup": "./jest/global-setup.js", 10 | "globalTeardown": "./jest/global-teardown.js", 11 | "setupFilesAfterEnv": ["./jest/obsidian-expect.js"], 12 | "testEnvironment": "./jest/obsidian-environment.js", 13 | "maxConcurrency": 1, 14 | "maxWorkers": 1 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/logic/utils/effects.ts: -------------------------------------------------------------------------------- 1 | import { StateEffect } from "@codemirror/state"; 2 | 3 | export interface ZoomInRange { 4 | from: number; 5 | to: number; 6 | } 7 | 8 | export type ZoomInStateEffect = StateEffect; 9 | 10 | export const zoomInEffect = StateEffect.define(); 11 | 12 | export const zoomOutEffect = StateEffect.define(); 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | export function isZoomInEffect(e: StateEffect): e is ZoomInStateEffect { 16 | return e.is(zoomInEffect); 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/createEditorCallback.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "obsidian"; 2 | 3 | import { MyEditor } from "../editor"; 4 | 5 | export function createEditorCallback(cb: (editor: MyEditor) => boolean) { 6 | return (editor: Editor) => { 7 | const myEditor = new MyEditor(editor); 8 | const shouldStopPropagation = cb(myEditor); 9 | 10 | if ( 11 | !shouldStopPropagation && 12 | window.event && 13 | window.event.type === "keydown" 14 | ) { 15 | myEditor.triggerOnKeyDown(window.event as KeyboardEvent); 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")], 3 | importOrder: [ 4 | "^obsidian$", 5 | "^@codemirror/.*$", 6 | "", 7 | "^\\./", 8 | "^\\.\\./", 9 | ], 10 | importOrderSeparation: true, 11 | importOrderSortSpecifiers: true, 12 | // https://github.com/trivago/prettier-plugin-sort-imports/issues/113 13 | overrides: [ 14 | { 15 | files: "*.ts", 16 | options: { 17 | importOrderParserPlugins: ["typescript"], 18 | }, 19 | }, 20 | ], 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /specs/features/MetaBackspaceBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # cmd+backspace should remove content only 2 | 3 | - platform: `darwin` 4 | - applyState: 5 | 6 | ```md 7 | - one 8 | - two| 9 | ``` 10 | 11 | - keydown: `Cmd-Backspace` 12 | - keydown: `Cmd-Backspace` 13 | - assertState: 14 | 15 | ```md 16 | - one 17 | - | 18 | ``` 19 | 20 | # cmd+backspace should remove content only in notes 21 | 22 | - platform: `darwin` 23 | - applyState: 24 | 25 | ```md 26 | - one 27 | two| 28 | ``` 29 | 30 | - keydown: `Cmd-Backspace` 31 | - keydown: `Cmd-Backspace` 32 | - assertState: 33 | 34 | ```md 35 | - one 36 | | 37 | ``` 38 | -------------------------------------------------------------------------------- /src/logic/CalculateRangeForZooming.ts: -------------------------------------------------------------------------------- 1 | import { foldable } from "@codemirror/language"; 2 | import { EditorState } from "@codemirror/state"; 3 | 4 | export class CalculateRangeForZooming { 5 | public calculateRangeForZooming(state: EditorState, pos: number) { 6 | const line = state.doc.lineAt(pos); 7 | const foldRange = foldable(state, line.from, line.to); 8 | 9 | if (!foldRange && /^\s*([-*+]|\d+\.)\s+/.test(line.text)) { 10 | return { from: line.from, to: line.to }; 11 | } 12 | 13 | if (!foldRange) { 14 | return null; 15 | } 16 | 17 | return { from: line.from, to: foldRange.to }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/logic/utils/__tests__/rangeSetToArray.test.ts: -------------------------------------------------------------------------------- 1 | import { RangeSetBuilder } from "@codemirror/state"; 2 | import { Decoration } from "@codemirror/view"; 3 | 4 | import { rangeSetToArray } from "../rangeSetToArray"; 5 | 6 | test("should return array of ranges", () => { 7 | const dec = Decoration.replace({}); 8 | const rsb = new RangeSetBuilder(); 9 | rsb.add(1, 2, dec); 10 | rsb.add(10, 20, dec); 11 | rsb.add(30, 40, dec); 12 | const rs = rsb.finish(); 13 | 14 | const ranges = rangeSetToArray(rs); 15 | 16 | expect(ranges).toStrictEqual([ 17 | { from: 1, to: 2 }, 18 | { from: 10, to: 20 }, 19 | { from: 30, to: 40 }, 20 | ]); 21 | }); 22 | -------------------------------------------------------------------------------- /src/logic/utils/calculateLimitedSelection.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state"; 2 | 3 | export function calculateLimitedSelection( 4 | selection: EditorSelection, 5 | from: number, 6 | to: number, 7 | ) { 8 | const mainSelection = selection.main; 9 | 10 | const newSelection = EditorSelection.range( 11 | Math.min(Math.max(mainSelection.anchor, from), to), 12 | Math.min(Math.max(mainSelection.head, from), to), 13 | mainSelection.goalColumn, 14 | ); 15 | 16 | const shouldUpdate = 17 | selection.ranges.length > 1 || 18 | newSelection.anchor !== mainSelection.anchor || 19 | newSelection.head !== mainSelection.head; 20 | 21 | return shouldUpdate ? newSelection : null; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/createKeymapRunCallback.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | 3 | import { MyEditor, getEditorFromState } from "../editor"; 4 | 5 | export function createKeymapRunCallback(config: { 6 | check?: (editor: MyEditor) => boolean; 7 | run: (editor: MyEditor) => { 8 | shouldUpdate: boolean; 9 | shouldStopPropagation: boolean; 10 | }; 11 | }) { 12 | const check = config.check || (() => true); 13 | const { run } = config; 14 | 15 | return (view: EditorView): boolean => { 16 | const editor = getEditorFromState(view.state); 17 | 18 | if (!check(editor)) { 19 | return false; 20 | } 21 | 22 | const { shouldUpdate, shouldStopPropagation } = run(editor); 23 | 24 | return shouldUpdate || shouldStopPropagation; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/services/IMEDetector.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "obsidian"; 2 | 3 | export class IMEDetector { 4 | private composition = false; 5 | 6 | async load() { 7 | document.addEventListener("compositionstart", this.onCompositionStart); 8 | document.addEventListener("compositionend", this.onCompositionEnd); 9 | } 10 | 11 | async unload() { 12 | document.removeEventListener("compositionend", this.onCompositionEnd); 13 | document.removeEventListener("compositionstart", this.onCompositionStart); 14 | } 15 | 16 | isOpened() { 17 | return this.composition && Platform.isDesktop; 18 | } 19 | 20 | private onCompositionStart = () => { 21 | this.composition = true; 22 | }; 23 | 24 | private onCompositionEnd = () => { 25 | this.composition = false; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /specs/features/ShiftTabBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # Shift-Tab should outdent line 2 | 3 | - applyState: 4 | 5 | ```md 6 | - qwe 7 | - qwe| 8 | ``` 9 | 10 | - keydown: `Shift-Tab` 11 | - assertState: 12 | 13 | ```md 14 | - qwe 15 | - qwe| 16 | ``` 17 | 18 | # Shift-Tab should outdent children 19 | 20 | - applyState: 21 | 22 | ```md 23 | - qwe 24 | - qwe| 25 | - qwe 26 | ``` 27 | 28 | - keydown: `Shift-Tab` 29 | - assertState: 30 | 31 | ```md 32 | - qwe 33 | - qwe| 34 | - qwe 35 | ``` 36 | 37 | # Shift-Tab should outdent in case #144 38 | 39 | - applyState: 40 | 41 | ```md 42 | - qwe 43 | - qwe 44 | - qwe 45 | - qwe 46 | - qwe| 47 | ``` 48 | 49 | - keydown: `Shift-Tab` 50 | - assertState: 51 | 52 | ```md 53 | - qwe 54 | - qwe 55 | - qwe 56 | - qwe 57 | - qwe| 58 | ``` 59 | -------------------------------------------------------------------------------- /vault/.obsidian/core-plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "file-explorer": true, 3 | "global-search": true, 4 | "switcher": true, 5 | "graph": true, 6 | "backlink": true, 7 | "canvas": true, 8 | "outgoing-link": true, 9 | "tag-pane": true, 10 | "footnotes": false, 11 | "properties": true, 12 | "page-preview": true, 13 | "daily-notes": true, 14 | "templates": true, 15 | "note-composer": true, 16 | "command-palette": true, 17 | "slash-command": false, 18 | "editor-status": true, 19 | "bookmarks": true, 20 | "markdown-importer": false, 21 | "zk-prefixer": false, 22 | "random-note": false, 23 | "outline": true, 24 | "word-count": true, 25 | "slides": false, 26 | "audio-recorder": false, 27 | "workspaces": false, 28 | "file-recovery": true, 29 | "publish": false, 30 | "sync": true, 31 | "bases": true, 32 | "webviewer": false 33 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '...' 16 | 3. Scroll down to '...' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Environment:** 26 | - OS: [e.g. macOS, Windows, Linux] 27 | - Obsidian version: [e.g. 1.8.7] 28 | - Plugin version: [e.g. 1.0.0] 29 | 30 | **Debug info** 31 | Please enable "Debug mode" in plugin settings and paste the console output here. 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | 36 | -------------------------------------------------------------------------------- /specs/ResetZoomWhenVisibleContentBoundariesViolatedFeature.spec.md: -------------------------------------------------------------------------------- 1 | # Should reset zoom when first boundary of visible content is violated 2 | 3 | - applyState: 4 | 5 | ```md 6 | text 7 | 8 | |# 1 9 | 10 | text 11 | ``` 12 | 13 | - execute: `obsidian-zoom:zoom-in` 14 | - keydown: `Backspace` 15 | - assertState: 16 | 17 | ```md 18 | text 19 | |# 1 20 | 21 | text 22 | ``` 23 | 24 | # Should reset zoom when second boundary of visible content is violated 25 | 26 | - applyState: 27 | 28 | ```md 29 | # 1| 30 | 31 | text 32 | 33 | # 2 34 | 35 | text 36 | ``` 37 | 38 | - execute: `obsidian-zoom:zoom-in` 39 | - keydown: `ArrowRight` 40 | - keydown: `ArrowDown` 41 | - keydown: `ArrowDown` 42 | - assertState: 43 | 44 | ```md 45 | # 1 46 | 47 | text 48 | | 49 | # 2 #hidden 50 | #hidden 51 | text #hidden 52 | ``` 53 | 54 | - keydown: `Delete` 55 | - assertState: 56 | 57 | ```md 58 | # 1 59 | 60 | text 61 | |# 2 62 | 63 | text 64 | ``` 65 | -------------------------------------------------------------------------------- /src/logic/utils/__tests__/cleanTitle.test.ts: -------------------------------------------------------------------------------- 1 | import { cleanTitle } from "../cleanTitle"; 2 | 3 | test("should clean title", () => { 4 | expect(cleanTitle(" Text with spaces ")).toBe("Text with spaces"); 5 | expect(cleanTitle("# Some header")).toBe("Some header"); 6 | expect(cleanTitle("## Some header")).toBe("Some header"); 7 | expect(cleanTitle("### Some header")).toBe("Some header"); 8 | expect(cleanTitle("#### Some header")).toBe("Some header"); 9 | expect(cleanTitle("#\tSome header")).toBe("Some header"); 10 | expect(cleanTitle("#Some invalid header")).toBe("#Some invalid header"); 11 | expect(cleanTitle("- Some bullet")).toBe("Some bullet"); 12 | expect(cleanTitle("+ Some bullet")).toBe("Some bullet"); 13 | expect(cleanTitle("* Some bullet")).toBe("Some bullet"); 14 | expect(cleanTitle(" * Some bullet ")).toBe("Some bullet"); 15 | expect(cleanTitle("\t*\tSome bullet ")).toBe("Some bullet"); 16 | expect(cleanTitle("\t*Some invalid bullet ")).toBe("*Some invalid bullet"); 17 | }); 18 | -------------------------------------------------------------------------------- /src/operations/KeepCursorOutsideFoldedLines.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root } from "../root"; 4 | 5 | export class KeepCursorOutsideFoldedLines implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | const cursor = root.getCursor(); 27 | 28 | const list = root.getListUnderCursor(); 29 | if (!list.isFolded()) { 30 | return; 31 | } 32 | 33 | const foldRoot = list.getTopFoldRoot(); 34 | const firstLineEnd = foldRoot.getLinesInfo()[0].to; 35 | 36 | if (cursor.line > firstLineEnd.line) { 37 | this.updated = true; 38 | this.stopPropagation = true; 39 | root.replaceCursor(firstLineEnd); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/features/ListsStylesFeature.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from "./Feature"; 2 | 3 | import { Settings } from "../services/Settings"; 4 | 5 | export class ListsStylesFeature implements Feature { 6 | constructor(private settings: Settings) {} 7 | 8 | async load() { 9 | if (this.settings.zoomOnClick) { 10 | this.addZoomStyles(); 11 | } 12 | 13 | this.settings.onChange("zoomOnClick", this.onZoomOnClickSettingChange); 14 | } 15 | 16 | async unload() { 17 | this.settings.removeCallback( 18 | "zoomOnClick", 19 | this.onZoomOnClickSettingChange, 20 | ); 21 | 22 | this.removeZoomStyles(); 23 | } 24 | 25 | private onZoomOnClickSettingChange = (zoomOnClick: boolean) => { 26 | if (zoomOnClick) { 27 | this.addZoomStyles(); 28 | } else { 29 | this.removeZoomStyles(); 30 | } 31 | }; 32 | 33 | private addZoomStyles() { 34 | document.body.classList.add("zoom-plugin-bls-zoom"); 35 | } 36 | 37 | private removeZoomStyles() { 38 | document.body.classList.remove("zoom-plugin-bls-zoom"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /specs/features/DeleteBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # delete should remove next item if cursor is on the end 2 | 3 | - applyState: 4 | 5 | ```md 6 | - qwe| 7 | - ee 8 | ``` 9 | 10 | - keydown: `Delete` 11 | - assertState: 12 | 13 | ```md 14 | - qwe|ee 15 | ``` 16 | 17 | # delete should remove next item if cursor is on the end and have notes 18 | 19 | - applyState: 20 | 21 | ```md 22 | - qwe 23 | notes| 24 | - ee 25 | ``` 26 | 27 | - keydown: `Delete` 28 | - assertState: 29 | 30 | ```md 31 | - qwe 32 | notes|ee 33 | ``` 34 | 35 | # delete should remove next line if cursor is on the end and have notes 36 | 37 | - applyState: 38 | 39 | ```md 40 | - qwe| 41 | notes 42 | - ee 43 | ``` 44 | 45 | - keydown: `Delete` 46 | - assertState: 47 | 48 | ```md 49 | - qwe|notes 50 | - ee 51 | ``` 52 | 53 | # delete should remove next line if cursor is on the end, issue #175 54 | 55 | - applyState: 56 | 57 | ```md 58 | - 1 59 | - 2| 60 | 61 | 3 62 | ``` 63 | 64 | - keydown: `Delete` 65 | - keydown: `Delete` 66 | - assertState: 67 | 68 | ```md 69 | - 1 70 | - 2|3 71 | ``` 72 | -------------------------------------------------------------------------------- /specs/features/ArrowLeftAndCtrlArrowLeftBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # cursor should be moved to previous line after arrowleft 2 | 3 | - applyState: 4 | 5 | ```md 6 | - one 7 | - |two 8 | ``` 9 | 10 | - keydown: `ArrowLeft` 11 | - assertState: 12 | 13 | ```md 14 | - one| 15 | - two 16 | ``` 17 | 18 | # cursor should be moved to previous line when previous item have notes 19 | 20 | - applyState: 21 | 22 | ```md 23 | - one 24 | note 25 | - |two 26 | ``` 27 | 28 | - keydown: `ArrowLeft` 29 | - assertState: 30 | 31 | ```md 32 | - one 33 | note| 34 | - two 35 | ``` 36 | 37 | # cursor should be moved to previous note line 38 | 39 | - applyState: 40 | 41 | ```md 42 | - one 43 | |note 44 | ``` 45 | 46 | - keydown: `ArrowLeft` 47 | - assertState: 48 | 49 | ```md 50 | - one| 51 | note 52 | ``` 53 | 54 | # cursor should be moved to previous line after arrowleft when line have checkbox 55 | 56 | - applyState: 57 | 58 | ```md 59 | - [ ] one 60 | - [ ] |two 61 | ``` 62 | 63 | - keydown: `ArrowLeft` 64 | - assertState: 65 | 66 | ```md 67 | - [ ] one| 68 | - [ ] two 69 | ``` 70 | -------------------------------------------------------------------------------- /src/features/ZoomOnClickFeature.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { EditorView } from "@codemirror/view"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { DetectClickOnBullet } from "../logic/DetectClickOnBullet"; 8 | import { Settings } from "../services/Settings"; 9 | 10 | export interface ZoomIn { 11 | zoomIn(view: EditorView, pos: number): void; 12 | } 13 | 14 | export class ZoomOnClickFeature implements Feature { 15 | private detectClickOnBullet = new DetectClickOnBullet(this.settings, { 16 | clickOnBullet: (view, pos) => this.clickOnBullet(view, pos), 17 | }); 18 | 19 | constructor( 20 | private plugin: Plugin, 21 | private settings: Settings, 22 | private zoomIn: ZoomIn, 23 | ) {} 24 | 25 | async load() { 26 | this.plugin.registerEditorExtension( 27 | this.detectClickOnBullet.getExtension(), 28 | ); 29 | } 30 | 31 | async unload() {} 32 | 33 | private clickOnBullet(view: EditorView, pos: number) { 34 | this.detectClickOnBullet.moveCursorToLineEnd(view, pos); 35 | this.zoomIn.zoomIn(view, pos); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/operations/OutdentListIfItsEmpty.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | import { OutdentList } from "./OutdentList"; 3 | 4 | import { Root } from "../root"; 5 | import { isEmptyLineOrEmptyCheckbox } from "../utils/isEmptyLineOrEmptyCheckbox"; 6 | 7 | export class OutdentListIfItsEmpty implements Operation { 8 | private outdentList: OutdentList; 9 | 10 | constructor(private root: Root) { 11 | this.outdentList = new OutdentList(root); 12 | } 13 | 14 | shouldStopPropagation() { 15 | return this.outdentList.shouldStopPropagation(); 16 | } 17 | 18 | shouldUpdate() { 19 | return this.outdentList.shouldUpdate(); 20 | } 21 | 22 | perform() { 23 | const { root } = this; 24 | 25 | if (!root.hasSingleCursor()) { 26 | return; 27 | } 28 | 29 | const list = root.getListUnderCursor(); 30 | const lines = list.getLines(); 31 | 32 | if ( 33 | lines.length > 1 || 34 | !isEmptyLineOrEmptyCheckbox(lines[0]) || 35 | list.getLevel() === 1 36 | ) { 37 | return; 38 | } 39 | 40 | this.outdentList.perform(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/operations/DeleteTillCurrentLineContentStart.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root } from "../root"; 4 | 5 | export class DeleteTillCurrentLineContentStart implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | this.stopPropagation = true; 27 | this.updated = true; 28 | 29 | const cursor = root.getCursor(); 30 | const list = root.getListUnderCursor(); 31 | const lines = list.getLinesInfo(); 32 | const lineNo = lines.findIndex((l) => l.from.line === cursor.line); 33 | 34 | lines[lineNo].text = lines[lineNo].text.slice( 35 | cursor.ch - lines[lineNo].from.ch, 36 | ); 37 | 38 | list.replaceLines(lines.map((l) => l.text)); 39 | root.replaceCursor(lines[lineNo].from); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import replace from "@rollup/plugin-replace"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import fs from "node:fs"; 6 | 7 | export default (commandLineArgs) => ({ 8 | input: commandLineArgs.configWithTests 9 | ? "src/ObsidianProOutlinerPluginWithTests.ts" 10 | : "src/ObsidianProOutlinerPlugin.ts", 11 | output: { 12 | file: "main.js", 13 | sourcemap: "inline", 14 | format: "cjs", 15 | exports: "default", 16 | }, 17 | external: [ 18 | "obsidian", 19 | "codemirror", 20 | "@codemirror/state", 21 | "@codemirror/view", 22 | "@codemirror/language", 23 | ], 24 | plugins: [ 25 | replace({ 26 | preventAssignment: true, 27 | PLUGIN_VERSION: JSON.stringify( 28 | JSON.parse(fs.readFileSync("./package.json", "utf-8")).version, 29 | ), 30 | CHANGELOG_MD: JSON.stringify(fs.readFileSync("./CHANGELOG.md", "utf-8")), 31 | }), 32 | typescript(), 33 | nodeResolve({ browser: true }), 34 | commonjs(), 35 | ], 36 | }); 37 | 38 | -------------------------------------------------------------------------------- /src/operations/KeepCursorWithinListContent.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root } from "../root"; 4 | 5 | export class KeepCursorWithinListContent implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | const cursor = root.getCursor(); 27 | const list = root.getListUnderCursor(); 28 | const contentStart = list.getFirstLineContentStartAfterCheckbox(); 29 | const linePrefix = 30 | contentStart.line === cursor.line 31 | ? contentStart.ch 32 | : list.getNotesIndent().length; 33 | 34 | if (cursor.ch < linePrefix) { 35 | this.updated = true; 36 | this.stopPropagation = true; 37 | root.replaceCursor({ 38 | line: cursor.line, 39 | ch: linePrefix, 40 | }); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/logic/LimitSelectionOnZoomingIn.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Transaction } from "@codemirror/state"; 2 | 3 | import { calculateLimitedSelection } from "./utils/calculateLimitedSelection"; 4 | import { ZoomInStateEffect, isZoomInEffect } from "./utils/effects"; 5 | 6 | import { Logger } from "../services/Logger"; 7 | 8 | export class LimitSelectionOnZoomingIn { 9 | constructor(private logger: Logger) {} 10 | 11 | getExtension() { 12 | return EditorState.transactionFilter.of(this.limitSelectionOnZoomingIn); 13 | } 14 | 15 | private limitSelectionOnZoomingIn = (tr: Transaction) => { 16 | const e = tr.effects.find(isZoomInEffect); 17 | 18 | if (!e) { 19 | return tr; 20 | } 21 | 22 | const newSelection = calculateLimitedSelection( 23 | tr.newSelection, 24 | e.value.from, 25 | e.value.to, 26 | ); 27 | 28 | if (!newSelection) { 29 | return tr; 30 | } 31 | 32 | this.logger.log( 33 | "LimitSelectionOnZoomingIn:limitSelectionOnZoomingIn", 34 | "limiting selection", 35 | newSelection.toJSON(), 36 | ); 37 | 38 | return [tr, { selection: newSelection }]; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Viacheslav Slinko 4 | Copyright (c) 2025 Ruben Khachaturov 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /src/services/OperationPerformer.ts: -------------------------------------------------------------------------------- 1 | import { ChangesApplicator } from "./ChangesApplicator"; 2 | import { Parser } from "./Parser"; 3 | 4 | import { MyEditor } from "../editor"; 5 | import { Operation } from "../operations/Operation"; 6 | import { Root } from "../root"; 7 | 8 | export class OperationPerformer { 9 | constructor( 10 | private parser: Parser, 11 | private changesApplicator: ChangesApplicator, 12 | ) {} 13 | 14 | eval(root: Root, op: Operation, editor: MyEditor) { 15 | const prevRoot = root.clone(); 16 | 17 | op.perform(); 18 | 19 | if (op.shouldUpdate()) { 20 | this.changesApplicator.apply(editor, prevRoot, root); 21 | } 22 | 23 | return { 24 | shouldUpdate: op.shouldUpdate(), 25 | shouldStopPropagation: op.shouldStopPropagation(), 26 | }; 27 | } 28 | 29 | perform( 30 | cb: (root: Root) => Operation, 31 | editor: MyEditor, 32 | cursor = editor.getCursor(), 33 | ) { 34 | const root = this.parser.parse(editor, cursor); 35 | 36 | if (!root) { 37 | return { shouldUpdate: false, shouldStopPropagation: false }; 38 | } 39 | 40 | const op = cb(root); 41 | 42 | return this.eval(root, op, editor); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/logic/CollectBreadcrumbs.ts: -------------------------------------------------------------------------------- 1 | import { foldable } from "@codemirror/language"; 2 | import { EditorState } from "@codemirror/state"; 3 | 4 | import { cleanTitle } from "./utils/cleanTitle"; 5 | 6 | export interface Breadcrumb { 7 | title: string; 8 | pos: number | null; 9 | } 10 | 11 | export interface GetDocumentTitle { 12 | getDocumentTitle(state: EditorState): string; 13 | } 14 | 15 | export class CollectBreadcrumbs { 16 | constructor(private getDocumentTitle: GetDocumentTitle) {} 17 | 18 | public collectBreadcrumbs(state: EditorState, pos: number) { 19 | const breadcrumbs: Breadcrumb[] = [ 20 | { title: this.getDocumentTitle.getDocumentTitle(state), pos: null }, 21 | ]; 22 | 23 | const posLine = state.doc.lineAt(pos); 24 | 25 | for (let i = 1; i < posLine.number; i++) { 26 | const line = state.doc.line(i); 27 | const f = foldable(state, line.from, line.to); 28 | if (f && f.to > posLine.from) { 29 | breadcrumbs.push({ title: cleanTitle(line.text), pos: line.from }); 30 | } 31 | } 32 | 33 | breadcrumbs.push({ 34 | title: cleanTitle(posLine.text), 35 | pos: posLine.from, 36 | }); 37 | 38 | return breadcrumbs; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /specs/features/TabBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # Tab should indent line 2 | 3 | - applyState: 4 | 5 | ```md 6 | - qwe 7 | - qwe| 8 | ``` 9 | 10 | - keydown: `Tab` 11 | - assertState: 12 | 13 | ```md 14 | - qwe 15 | - qwe| 16 | ``` 17 | 18 | # Tab should indent children 19 | 20 | - applyState: 21 | 22 | ```md 23 | - qwe 24 | - qwe| 25 | - qwe 26 | ``` 27 | 28 | - keydown: `Tab` 29 | - assertState: 30 | 31 | ```md 32 | - qwe 33 | - qwe| 34 | - qwe 35 | ``` 36 | 37 | # Tab should not indent line if it's no parent 38 | 39 | - applyState: 40 | 41 | ```md 42 | - qwe 43 | - qwe| 44 | ``` 45 | 46 | - keydown: `Tab` 47 | - assertState: 48 | 49 | ```md 50 | - qwe 51 | - qwe| 52 | ``` 53 | 54 | # Tab should keep cursor at the same text position 55 | 56 | - applyState: 57 | 58 | ```md 59 | - qwe 60 | - qwe 61 | - q|we 62 | ``` 63 | 64 | - keydown: `Tab` 65 | - assertState: 66 | 67 | ```md 68 | - qwe 69 | - qwe 70 | - q|we 71 | ``` 72 | 73 | # Tab should keep numeration 74 | 75 | - applyState: 76 | 77 | ```md 78 | 1. one 79 | 1. two 80 | 2. three| 81 | 3. four 82 | ``` 83 | 84 | - keydown: `Tab` 85 | - assertState: 86 | 87 | ```md 88 | 1. one 89 | 1. two 90 | 1. three| 91 | 2. four 92 | ``` 93 | -------------------------------------------------------------------------------- /src/logic/utils/calculateVisibleContentBoundariesViolation.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from "@codemirror/state"; 2 | 3 | export function calculateVisibleContentBoundariesViolation( 4 | tr: Transaction, 5 | hiddenRanges: Array<{ from: number; to: number }>, 6 | ) { 7 | let touchedBefore = false; 8 | let touchedAfter = false; 9 | let touchedInside = false; 10 | 11 | const t = (f: number, t: number) => Boolean(tr.changes.touchesRange(f, t)); 12 | 13 | if (hiddenRanges.length === 2) { 14 | const [a, b] = hiddenRanges; 15 | 16 | touchedBefore = t(a.from, a.to); 17 | touchedInside = t(a.to + 1, b.from - 1); 18 | touchedAfter = t(b.from, b.to); 19 | } 20 | 21 | if (hiddenRanges.length === 1) { 22 | const [a] = hiddenRanges; 23 | 24 | if (a.from === 0) { 25 | touchedBefore = t(a.from, a.to); 26 | touchedInside = t(a.to + 1, tr.newDoc.length); 27 | } else { 28 | touchedInside = t(0, a.from - 1); 29 | touchedAfter = t(a.from, a.to); 30 | } 31 | } 32 | 33 | const touchedOutside = touchedBefore || touchedAfter; 34 | 35 | const res = { 36 | touchedOutside, 37 | touchedBefore, 38 | touchedAfter, 39 | touchedInside, 40 | }; 41 | 42 | return res; 43 | } 44 | -------------------------------------------------------------------------------- /src/logic/DetectClickOnBullet.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | 4 | import { isBulletPoint } from "./utils/isBulletPoint"; 5 | 6 | import { Settings } from "../services/Settings"; 7 | 8 | export interface ClickOnBullet { 9 | clickOnBullet(view: EditorView, pos: number): void; 10 | } 11 | 12 | export class DetectClickOnBullet { 13 | constructor( 14 | private settings: Settings, 15 | private clickOnBullet: ClickOnBullet, 16 | ) {} 17 | 18 | getExtension() { 19 | return EditorView.domEventHandlers({ 20 | click: this.detectClickOnBullet, 21 | }); 22 | } 23 | 24 | public moveCursorToLineEnd(view: EditorView, pos: number) { 25 | const line = view.state.doc.lineAt(pos); 26 | 27 | view.dispatch({ 28 | selection: EditorSelection.cursor(line.to), 29 | }); 30 | } 31 | 32 | private detectClickOnBullet = (e: MouseEvent, view: EditorView) => { 33 | if ( 34 | !this.settings.zoomOnClick || 35 | !(e.target instanceof HTMLElement) || 36 | !isBulletPoint(e.target) 37 | ) { 38 | return; 39 | } 40 | 41 | const pos = view.posAtDOM(e.target); 42 | this.clickOnBullet.clickOnBullet(view, pos); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/features/BetterListsStyles.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from "./Feature"; 2 | 3 | import { ObsidianSettings } from "../services/ObsidianSettings"; 4 | import { Settings } from "../services/Settings"; 5 | 6 | const BETTER_LISTS_BODY_CLASS = "outliner-plugin-better-lists"; 7 | 8 | export class BetterListsStyles implements Feature { 9 | private updateBodyClassInterval: number; 10 | 11 | constructor( 12 | private settings: Settings, 13 | private obsidianSettings: ObsidianSettings, 14 | ) {} 15 | 16 | async load() { 17 | this.updateBodyClass(); 18 | this.updateBodyClassInterval = window.setInterval(() => { 19 | this.updateBodyClass(); 20 | }, 1000); 21 | } 22 | 23 | async unload() { 24 | clearInterval(this.updateBodyClassInterval); 25 | document.body.classList.remove(BETTER_LISTS_BODY_CLASS); 26 | } 27 | 28 | private updateBodyClass = () => { 29 | const shouldExists = 30 | this.obsidianSettings.isDefaultThemeEnabled() && 31 | this.settings.betterListsStyles; 32 | const exists = document.body.classList.contains(BETTER_LISTS_BODY_CLASS); 33 | 34 | if (shouldExists && !exists) { 35 | document.body.classList.add(BETTER_LISTS_BODY_CLASS); 36 | } 37 | 38 | if (!shouldExists && exists) { 39 | document.body.classList.remove(BETTER_LISTS_BODY_CLASS); 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/features/LimitSelectionFeature.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { EditorState } from "@codemirror/state"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { LimitSelectionOnZoomingIn } from "../logic/LimitSelectionOnZoomingIn"; 8 | import { LimitSelectionWhenZoomedIn } from "../logic/LimitSelectionWhenZoomedIn"; 9 | import { Logger } from "../services/Logger"; 10 | 11 | export interface CalculateVisibleContentRange { 12 | calculateVisibleContentRange( 13 | state: EditorState, 14 | ): { from: number; to: number } | null; 15 | } 16 | 17 | export class LimitSelectionFeature implements Feature { 18 | private limitSelectionOnZoomingIn = new LimitSelectionOnZoomingIn( 19 | this.logger, 20 | ); 21 | private limitSelectionWhenZoomedIn = new LimitSelectionWhenZoomedIn( 22 | this.logger, 23 | this.calculateVisibleContentRange, 24 | ); 25 | 26 | constructor( 27 | private plugin: Plugin, 28 | private logger: Logger, 29 | private calculateVisibleContentRange: CalculateVisibleContentRange, 30 | ) {} 31 | 32 | async load() { 33 | this.plugin.registerEditorExtension( 34 | this.limitSelectionOnZoomingIn.getExtension(), 35 | ); 36 | 37 | this.plugin.registerEditorExtension( 38 | this.limitSelectionWhenZoomedIn.getExtension(), 39 | ); 40 | } 41 | 42 | async unload() {} 43 | } 44 | -------------------------------------------------------------------------------- /src/logic/utils/cleanTitle.ts: -------------------------------------------------------------------------------- 1 | export function cleanTitle(title: string) { 2 | return ( 3 | title 4 | .trim() 5 | .replace(/^#+(\s)/, "$1") 6 | .replace(/^([-+*]|\d+\.)(\s)/, "$2") 7 | // Remove mirror markers 8 | .replace(/\s*\s*/g, "") 9 | // Remove block IDs (both outliner- and native Obsidian format) 10 | .replace(/\s*\^[a-zA-Z0-9-]+\s*/g, "") 11 | // Extract display text from wikilinks: [[link|display]] -> display, [[link]] -> link 12 | .replace(/\[\[([^\]]+)\]\]/g, (_match, content: string) => { 13 | const parts = content.split("|"); 14 | // If there's a pipe, use the part after it (display text) 15 | // Otherwise use the link itself (remove path if present) 16 | if (parts.length > 1) { 17 | return parts[parts.length - 1]; 18 | } 19 | // For [[link]], extract just the note name (remove path) 20 | const linkPart = parts[0]; 21 | const lastSlash = linkPart.lastIndexOf("/"); 22 | return lastSlash >= 0 ? linkPart.substring(lastSlash + 1) : linkPart; 23 | }) 24 | // Remove Creases plugin fold markers: %% fold %%, %% fold:... %%, etc. 25 | .replace(/\s*%%\s*fold[^%]*%%\s*/gi, "") 26 | // Remove other common markers: %% ... %% 27 | .replace(/\s*%%[^%]*%%\s*/g, "") 28 | .trim() 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /jest/test-globals.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace jest { 2 | interface Matchers { 3 | toEqualEditorState(s: string): Promise; 4 | toEqualEditorState(s: string[]): Promise; 5 | } 6 | } 7 | 8 | interface StatePosition { 9 | line: number; 10 | ch: number; 11 | } 12 | 13 | interface StateSelection { 14 | anchor: StatePosition; 15 | head: StatePosition; 16 | } 17 | 18 | interface State { 19 | folds: number[]; 20 | selections: StateSelection[]; 21 | value: string; 22 | } 23 | 24 | declare function applyState(state: string): Promise; 25 | declare function applyState(state: string[]): Promise; 26 | declare function parseState(state: string): Promise; 27 | declare function parseState(state: string[]): Promise; 28 | declare function simulateKeydown(keys: string): Promise; 29 | declare function insertText(text: string): Promise; 30 | declare function executeCommandById(keys: string): Promise; 31 | declare function setSetting(opts: { k: string; v: any }): Promise; 32 | declare function resetSettings(): Promise; 33 | declare function getCurrentState(): Promise; 34 | declare function drag(opts: { 35 | from: { line: number; ch: number }; 36 | }): Promise; 37 | declare function move(opts: { 38 | to: { line: number; ch: number }; 39 | offsetX: number; 40 | offsetY: number; 41 | }): Promise; 42 | declare function drop(): Promise; 43 | -------------------------------------------------------------------------------- /specs/services/ChangesApplicator.spec.md: -------------------------------------------------------------------------------- 1 | # should keep foldind on change 2 | 3 | - applyState: 4 | 5 | ```md 6 | - one #folded 7 | - two 8 | - three| 9 | ``` 10 | 11 | - execute: `obsidian-outliner:move-list-item-up` 12 | - assertState: 13 | 14 | ```md 15 | - three| 16 | - one #folded 17 | - two 18 | ``` 19 | 20 | # should keep foldind on change, issue #236 21 | 22 | - applyState: 23 | 24 | ```md 25 | - one 26 | - two #folded 27 | - three 28 | - four| 29 | - five 30 | ``` 31 | 32 | - keydown: `ArrowRight` 33 | - assertState: 34 | 35 | ```md 36 | - one 37 | - two #folded 38 | - three 39 | - four 40 | - |five 41 | ``` 42 | 43 | # should keep subfoldind on change, issue #258 44 | 45 | - applyState: 46 | 47 | ```md 48 | - one 49 | - two 50 | - three| 51 | - four 52 | - five 53 | ``` 54 | 55 | - execute: `obsidian-outliner:fold` 56 | - keydown: `ArrowUp` 57 | - assertState: 58 | 59 | ```md 60 | - one 61 | - two| 62 | - three #folded 63 | - four 64 | - five 65 | ``` 66 | 67 | - execute: `obsidian-outliner:fold` 68 | - keydown: `ArrowDown` 69 | - keydown: `Cmd-ArrowRight` 70 | - assertState: 71 | 72 | ```md 73 | - one 74 | - two #folded 75 | - three #folded 76 | - four 77 | - five| 78 | ``` 79 | 80 | - execute: `obsidian-outliner:move-list-item-up` 81 | - assertState: 82 | 83 | ```md 84 | - one 85 | - five| 86 | - two #folded 87 | - three #folded 88 | - four 89 | ``` 90 | -------------------------------------------------------------------------------- /src/services/ObsidianSettings.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | 3 | export interface ObsidianTabsSettings { 4 | useTab: boolean; 5 | tabSize: number; 6 | } 7 | 8 | export interface ObsidianFoldSettings { 9 | foldIndent: boolean; 10 | } 11 | 12 | function getHiddenObsidianConfig(app: App) { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | return (app.vault as any).config; 15 | } 16 | 17 | export class ObsidianSettings { 18 | constructor(private app: App) {} 19 | 20 | isLegacyEditorEnabled() { 21 | const config: { legacyEditor: boolean } = { 22 | legacyEditor: false, 23 | ...getHiddenObsidianConfig(this.app), 24 | }; 25 | 26 | return config.legacyEditor; 27 | } 28 | 29 | isDefaultThemeEnabled() { 30 | const config: { cssTheme: string } = { 31 | cssTheme: "", 32 | ...getHiddenObsidianConfig(this.app), 33 | }; 34 | 35 | return config.cssTheme === ""; 36 | } 37 | 38 | getTabsSettings(): ObsidianTabsSettings { 39 | return { 40 | useTab: true, 41 | tabSize: 4, 42 | ...getHiddenObsidianConfig(this.app), 43 | }; 44 | } 45 | 46 | getFoldSettings(): ObsidianFoldSettings { 47 | return { 48 | foldIndent: true, 49 | ...getHiddenObsidianConfig(this.app), 50 | }; 51 | } 52 | 53 | getDefaultIndentChars() { 54 | const { useTab, tabSize } = this.getTabsSettings(); 55 | 56 | return useTab ? "\t" : new Array(tabSize).fill(" ").join(""); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/logic/LimitSelectionWhenZoomedIn.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Transaction } from "@codemirror/state"; 2 | 3 | import { calculateLimitedSelection } from "./utils/calculateLimitedSelection"; 4 | 5 | import { Logger } from "../services/Logger"; 6 | 7 | export interface CalculateVisibleContentRange { 8 | calculateVisibleContentRange( 9 | state: EditorState, 10 | ): { from: number; to: number } | null; 11 | } 12 | 13 | export class LimitSelectionWhenZoomedIn { 14 | constructor( 15 | private logger: Logger, 16 | private calculateVisibleContentRange: CalculateVisibleContentRange, 17 | ) {} 18 | 19 | public getExtension() { 20 | return EditorState.transactionFilter.of(this.limitSelectionWhenZoomedIn); 21 | } 22 | 23 | private limitSelectionWhenZoomedIn = (tr: Transaction) => { 24 | if (!tr.selection || !tr.isUserEvent("select")) { 25 | return tr; 26 | } 27 | 28 | const range = 29 | this.calculateVisibleContentRange.calculateVisibleContentRange(tr.state); 30 | 31 | if (!range) { 32 | return tr; 33 | } 34 | 35 | const newSelection = calculateLimitedSelection( 36 | tr.newSelection, 37 | range.from, 38 | range.to, 39 | ); 40 | 41 | if (!newSelection) { 42 | return tr; 43 | } 44 | 45 | this.logger.log( 46 | "LimitSelectionWhenZoomedIn:limitSelectionWhenZoomedIn", 47 | "limiting selection", 48 | newSelection.toJSON(), 49 | ); 50 | 51 | return [tr, { selection: newSelection }]; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/features/CtrlAAndCmdABehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { keymap } from "@codemirror/view"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { MyEditor } from "../editor"; 8 | import { SelectAllContent } from "../operations/SelectAllContent"; 9 | import { IMEDetector } from "../services/IMEDetector"; 10 | import { OperationPerformer } from "../services/OperationPerformer"; 11 | import { Settings } from "../services/Settings"; 12 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 13 | 14 | export class CtrlAAndCmdABehaviourOverride implements Feature { 15 | constructor( 16 | private plugin: Plugin, 17 | private settings: Settings, 18 | private imeDetector: IMEDetector, 19 | private operationPerformer: OperationPerformer, 20 | ) {} 21 | 22 | async load() { 23 | this.plugin.registerEditorExtension( 24 | keymap.of([ 25 | { 26 | key: "c-a", 27 | mac: "m-a", 28 | run: createKeymapRunCallback({ 29 | check: this.check, 30 | run: this.run, 31 | }), 32 | }, 33 | ]), 34 | ); 35 | } 36 | 37 | async unload() {} 38 | 39 | private check = () => { 40 | return ( 41 | this.settings.overrideSelectAllBehaviour && !this.imeDetector.isOpened() 42 | ); 43 | }; 44 | 45 | private run = (editor: MyEditor) => { 46 | return this.operationPerformer.perform( 47 | (root) => new SelectAllContent(root), 48 | editor, 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/logic/DetectRangeBeforeVisibleRangeChanged.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Transaction } from "@codemirror/state"; 2 | 3 | import { calculateVisibleContentBoundariesViolation } from "./utils/calculateVisibleContentBoundariesViolation"; 4 | 5 | export interface RangeBeforeVisibleRangeChanged { 6 | rangeBeforeVisibleRangeChanged(state: EditorState): void; 7 | } 8 | 9 | export interface CalculateHiddenContentRanges { 10 | calculateHiddenContentRanges( 11 | state: EditorState, 12 | ): { from: number; to: number }[] | null; 13 | } 14 | 15 | export class DetectRangeBeforeVisibleRangeChanged { 16 | constructor( 17 | private calculateHiddenContentRanges: CalculateHiddenContentRanges, 18 | private rangeBeforeVisibleRangeChanged: RangeBeforeVisibleRangeChanged, 19 | ) {} 20 | 21 | getExtension() { 22 | return EditorState.transactionExtender.of( 23 | this.detectVisibleContentBoundariesViolation, 24 | ); 25 | } 26 | 27 | private detectVisibleContentBoundariesViolation = (tr: Transaction): null => { 28 | const hiddenRanges = 29 | this.calculateHiddenContentRanges.calculateHiddenContentRanges( 30 | tr.startState, 31 | ); 32 | 33 | const { touchedBefore, touchedInside } = 34 | calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 35 | 36 | if (touchedBefore && !touchedInside) { 37 | setImmediate(() => { 38 | this.rangeBeforeVisibleRangeChanged.rangeBeforeVisibleRangeChanged( 39 | tr.state, 40 | ); 41 | }); 42 | } 43 | 44 | return null; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/features/DeleteBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { keymap } from "@codemirror/view"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { MyEditor } from "../editor"; 8 | import { DeleteTillNextLineContentStart } from "../operations/DeleteTillNextLineContentStart"; 9 | import { IMEDetector } from "../services/IMEDetector"; 10 | import { OperationPerformer } from "../services/OperationPerformer"; 11 | import { Settings } from "../services/Settings"; 12 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 13 | 14 | export class DeleteBehaviourOverride implements Feature { 15 | constructor( 16 | private plugin: Plugin, 17 | private settings: Settings, 18 | private imeDetector: IMEDetector, 19 | private operationPerformer: OperationPerformer, 20 | ) {} 21 | 22 | async load() { 23 | this.plugin.registerEditorExtension( 24 | keymap.of([ 25 | { 26 | key: "Delete", 27 | run: createKeymapRunCallback({ 28 | check: this.check, 29 | run: this.run, 30 | }), 31 | }, 32 | ]), 33 | ); 34 | } 35 | 36 | async unload() {} 37 | 38 | private check = () => { 39 | return ( 40 | this.settings.keepCursorWithinContent !== "never" && 41 | !this.imeDetector.isOpened() 42 | ); 43 | }; 44 | 45 | private run = (editor: MyEditor) => { 46 | return this.operationPerformer.perform( 47 | (root) => new DeleteTillNextLineContentStart(root), 48 | editor, 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/features/ShiftTabBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { Prec } from "@codemirror/state"; 4 | import { keymap } from "@codemirror/view"; 5 | 6 | import { Feature } from "./Feature"; 7 | 8 | import { MyEditor } from "../editor"; 9 | import { OutdentList } from "../operations/OutdentList"; 10 | import { IMEDetector } from "../services/IMEDetector"; 11 | import { OperationPerformer } from "../services/OperationPerformer"; 12 | import { Settings } from "../services/Settings"; 13 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 14 | 15 | export class ShiftTabBehaviourOverride implements Feature { 16 | constructor( 17 | private plugin: Plugin, 18 | private imeDetector: IMEDetector, 19 | private settings: Settings, 20 | private operationPerformer: OperationPerformer, 21 | ) {} 22 | 23 | async load() { 24 | this.plugin.registerEditorExtension( 25 | Prec.highest( 26 | keymap.of([ 27 | { 28 | key: "s-Tab", 29 | run: createKeymapRunCallback({ 30 | check: this.check, 31 | run: this.run, 32 | }), 33 | }, 34 | ]), 35 | ), 36 | ); 37 | } 38 | 39 | async unload() {} 40 | 41 | private check = () => { 42 | return this.settings.overrideTabBehaviour && !this.imeDetector.isOpened(); 43 | }; 44 | 45 | private run = (editor: MyEditor) => { 46 | return this.operationPerformer.perform( 47 | (root) => new OutdentList(root), 48 | editor, 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/features/BackspaceBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { keymap } from "@codemirror/view"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { MyEditor } from "../editor"; 8 | import { DeleteTillPreviousLineContentEnd } from "../operations/DeleteTillPreviousLineContentEnd"; 9 | import { IMEDetector } from "../services/IMEDetector"; 10 | import { OperationPerformer } from "../services/OperationPerformer"; 11 | import { Settings } from "../services/Settings"; 12 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 13 | 14 | export class BackspaceBehaviourOverride implements Feature { 15 | constructor( 16 | private plugin: Plugin, 17 | private settings: Settings, 18 | private imeDetector: IMEDetector, 19 | private operationPerformer: OperationPerformer, 20 | ) {} 21 | 22 | async load() { 23 | this.plugin.registerEditorExtension( 24 | keymap.of([ 25 | { 26 | key: "Backspace", 27 | run: createKeymapRunCallback({ 28 | check: this.check, 29 | run: this.run, 30 | }), 31 | }, 32 | ]), 33 | ); 34 | } 35 | 36 | async unload() {} 37 | 38 | private check = () => { 39 | return ( 40 | this.settings.keepCursorWithinContent !== "never" && 41 | !this.imeDetector.isOpened() 42 | ); 43 | }; 44 | 45 | private run = (editor: MyEditor) => { 46 | return this.operationPerformer.perform( 47 | (root) => new DeleteTillPreviousLineContentEnd(root), 48 | editor, 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/logic/DetectVisibleContentBoundariesViolation.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Transaction } from "@codemirror/state"; 2 | 3 | import { calculateVisibleContentBoundariesViolation } from "./utils/calculateVisibleContentBoundariesViolation"; 4 | 5 | export interface VisibleContentBoundariesViolated { 6 | visibleContentBoundariesViolated(state: EditorState): void; 7 | } 8 | 9 | export interface CalculateHiddenContentRanges { 10 | calculateHiddenContentRanges( 11 | state: EditorState, 12 | ): { from: number; to: number }[] | null; 13 | } 14 | 15 | export class DetectVisibleContentBoundariesViolation { 16 | constructor( 17 | private calculateHiddenContentRanges: CalculateHiddenContentRanges, 18 | private visibleContentBoundariesViolated: VisibleContentBoundariesViolated, 19 | ) {} 20 | 21 | getExtension() { 22 | return EditorState.transactionExtender.of( 23 | this.detectVisibleContentBoundariesViolation, 24 | ); 25 | } 26 | 27 | private detectVisibleContentBoundariesViolation = (tr: Transaction): null => { 28 | const hiddenRanges = 29 | this.calculateHiddenContentRanges.calculateHiddenContentRanges( 30 | tr.startState, 31 | ); 32 | 33 | const { touchedOutside, touchedInside } = 34 | calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 35 | 36 | if (touchedOutside && touchedInside) { 37 | setImmediate(() => { 38 | this.visibleContentBoundariesViolated.visibleContentBoundariesViolated( 39 | tr.state, 40 | ); 41 | }); 42 | } 43 | 44 | return null; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/features/MetaBackspaceBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { keymap } from "@codemirror/view"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { MyEditor } from "../editor"; 8 | import { DeleteTillCurrentLineContentStart } from "../operations/DeleteTillCurrentLineContentStart"; 9 | import { IMEDetector } from "../services/IMEDetector"; 10 | import { OperationPerformer } from "../services/OperationPerformer"; 11 | import { Settings } from "../services/Settings"; 12 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 13 | 14 | export class MetaBackspaceBehaviourOverride implements Feature { 15 | constructor( 16 | private plugin: Plugin, 17 | private settings: Settings, 18 | private imeDetector: IMEDetector, 19 | private operationPerformer: OperationPerformer, 20 | ) {} 21 | 22 | async load() { 23 | this.plugin.registerEditorExtension( 24 | keymap.of([ 25 | { 26 | mac: "m-Backspace", 27 | run: createKeymapRunCallback({ 28 | check: this.check, 29 | run: this.run, 30 | }), 31 | }, 32 | ]), 33 | ); 34 | } 35 | 36 | async unload() {} 37 | 38 | private check = () => { 39 | return ( 40 | this.settings.keepCursorWithinContent !== "never" && 41 | !this.imeDetector.isOpened() 42 | ); 43 | }; 44 | 45 | private run = (editor: MyEditor) => { 46 | return this.operationPerformer.perform( 47 | (root) => new DeleteTillCurrentLineContentStart(root), 48 | editor, 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/operations/OutdentList.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root, recalculateNumericBullets } from "../root"; 4 | 5 | export class OutdentList implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | this.stopPropagation = true; 27 | 28 | const list = root.getListUnderCursor(); 29 | const parent = list.getParent(); 30 | const grandParent = parent.getParent(); 31 | 32 | if (!grandParent) { 33 | return; 34 | } 35 | 36 | this.updated = true; 37 | 38 | const listStartLineBefore = root.getContentLinesRangeOf(list)[0]; 39 | const indentRmFrom = parent.getFirstLineIndent().length; 40 | const indentRmTill = list.getFirstLineIndent().length; 41 | 42 | parent.removeChild(list); 43 | grandParent.addAfter(parent, list); 44 | list.unindentContent(indentRmFrom, indentRmTill); 45 | 46 | const listStartLineAfter = root.getContentLinesRangeOf(list)[0]; 47 | const lineDiff = listStartLineAfter - listStartLineBefore; 48 | const chDiff = indentRmTill - indentRmFrom; 49 | 50 | const cursor = root.getCursor(); 51 | root.replaceCursor({ 52 | line: cursor.line + lineDiff, 53 | ch: cursor.ch - chDiff, 54 | }); 55 | 56 | recalculateNumericBullets(root); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/logic/__tests__/DetectClickOnBullet.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { EditorState } from "@codemirror/state"; 5 | import { Decoration, EditorView } from "@codemirror/view"; 6 | 7 | import { DetectClickOnBullet } from "../DetectClickOnBullet"; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | const settings: any = { zoomOnClick: true }; 11 | const clickOnBullet = { clickOnBullet: jest.fn() }; 12 | const detectClickOnBullet = new DetectClickOnBullet(settings, clickOnBullet); 13 | const decs = Decoration.set([ 14 | Decoration.mark({ class: "list-bullet" }).range(0, 1), 15 | Decoration.mark({ class: "cm-formatting-list" }).range(6, 7), 16 | Decoration.mark({ class: "other" }).range(8, 9), 17 | ]); 18 | const view = new EditorView({ 19 | state: EditorState.create({ 20 | doc: "- 1\n - 2", 21 | extensions: [ 22 | detectClickOnBullet.getExtension(), 23 | EditorView.decorations.of(decs), 24 | ], 25 | }), 26 | parent: document.body, 27 | }); 28 | 29 | test("should detect click on span.list-bullet", () => { 30 | view.dom.querySelector(".list-bullet").click(); 31 | 32 | expect(clickOnBullet.clickOnBullet).toBeCalled(); 33 | }); 34 | 35 | test("should detect click on span.cm-formatting-list", () => { 36 | view.dom.querySelector(".cm-formatting-list").click(); 37 | 38 | expect(clickOnBullet.clickOnBullet).toBeCalled(); 39 | }); 40 | 41 | test("should not detect click on other elements", () => { 42 | view.dom.querySelector(".other").click(); 43 | 44 | expect(clickOnBullet.clickOnBullet).not.toBeCalled(); 45 | }); 46 | -------------------------------------------------------------------------------- /src/operations/DeleteTillNextLineContentStart.ts: -------------------------------------------------------------------------------- 1 | import { DeleteTillPreviousLineContentEnd } from "./DeleteTillPreviousLineContentEnd"; 2 | import { Operation } from "./Operation"; 3 | 4 | import { Root } from "../root"; 5 | 6 | export class DeleteTillNextLineContentStart implements Operation { 7 | private deleteTillPreviousLineContentEnd: DeleteTillPreviousLineContentEnd; 8 | 9 | constructor(private root: Root) { 10 | this.deleteTillPreviousLineContentEnd = 11 | new DeleteTillPreviousLineContentEnd(root); 12 | } 13 | 14 | shouldStopPropagation() { 15 | return this.deleteTillPreviousLineContentEnd.shouldStopPropagation(); 16 | } 17 | 18 | shouldUpdate() { 19 | return this.deleteTillPreviousLineContentEnd.shouldUpdate(); 20 | } 21 | 22 | perform() { 23 | const { root } = this; 24 | 25 | if (!root.hasSingleCursor()) { 26 | return; 27 | } 28 | 29 | const list = root.getListUnderCursor(); 30 | const cursor = root.getCursor(); 31 | const lines = list.getLinesInfo(); 32 | 33 | const lineNo = lines.findIndex( 34 | (l) => cursor.ch === l.to.ch && cursor.line === l.to.line, 35 | ); 36 | 37 | if (lineNo === lines.length - 1) { 38 | const nextLine = lines[lineNo].to.line + 1; 39 | const nextList = root.getListUnderLine(nextLine); 40 | if (!nextList) { 41 | return; 42 | } 43 | root.replaceCursor(nextList.getFirstLineContentStart()); 44 | this.deleteTillPreviousLineContentEnd.perform(); 45 | } else if (lineNo >= 0) { 46 | root.replaceCursor(lines[lineNo + 1].from); 47 | this.deleteTillPreviousLineContentEnd.perform(); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/logic/__tests__/CollectBreadcrumbs.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state"; 2 | 3 | import { CollectBreadcrumbs } from "../CollectBreadcrumbs"; 4 | 5 | jest.mock("@codemirror/language", () => { 6 | return { 7 | foldable: jest.fn(), 8 | }; 9 | }); 10 | 11 | const getDocumentTitle = { getDocumentTitle: () => "Document" }; 12 | const foldable: jest.Mock = jest.requireMock("@codemirror/language").foldable; 13 | 14 | test("should return breadcrumbs based on folable zones that should include input position", () => { 15 | const state = EditorState.create({ 16 | 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", 17 | // 0123 4 5678 9 01234 5 6789 0 1234 5 6 7890 1 234567 8 9012 3 45 18 | // 1 2 3 4 19 | }); 20 | foldable.mockImplementation((state, from) => { 21 | if (from === 0) return { from: 0, to: 4 }; 22 | if (from === 5) return { from: 5, to: 38 }; 23 | if (from === 10) return { from: 10, to: 38 }; 24 | if (from === 16) return { from: 16, to: 29 }; 25 | if (from === 20) return { from: 20, to: 29 }; 26 | if (from === 32) return { from: 32, to: 38 }; 27 | if (from === 39) return { from: 39, to: 44 }; 28 | return null; 29 | }); 30 | 31 | const collectBreadcrumbs = new CollectBreadcrumbs(getDocumentTitle); 32 | 33 | const b = collectBreadcrumbs.collectBreadcrumbs(state, 28); 34 | 35 | expect(b).toStrictEqual([ 36 | { title: "Document", pos: null }, 37 | { title: "b", pos: 5 }, 38 | { title: "c", pos: 10 }, 39 | { title: "1", pos: 16 }, 40 | { title: "2", pos: 20 }, 41 | { title: "3", pos: 25 }, 42 | ]); 43 | }); 44 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Build 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | env: 14 | NODE_VERSION: 22 15 | 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-24.04 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ env.NODE_VERSION }} 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Lint 29 | run: npm run lint 30 | 31 | build: 32 | runs-on: ubuntu-24.04 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | - name: Setup Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ env.NODE_VERSION }} 40 | - name: Install dependencies 41 | run: npm ci 42 | - name: Build 43 | run: npm run build 44 | - name: Upload build artifact 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: plugin-build 48 | path: | 49 | main.js 50 | manifest.json 51 | styles.css 52 | 53 | test: 54 | runs-on: ubuntu-24.04 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v4 58 | - name: Setup Node.js 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: ${{ env.NODE_VERSION }} 62 | - name: Install dependencies 63 | run: npm ci 64 | - name: Run unit tests 65 | run: npm run test:unit 66 | 67 | -------------------------------------------------------------------------------- /src/features/TabBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { Prec } from "@codemirror/state"; 4 | import { keymap } from "@codemirror/view"; 5 | 6 | import { Feature } from "./Feature"; 7 | 8 | import { MyEditor } from "../editor"; 9 | import { IndentList } from "../operations/IndentList"; 10 | import { IMEDetector } from "../services/IMEDetector"; 11 | import { ObsidianSettings } from "../services/ObsidianSettings"; 12 | import { OperationPerformer } from "../services/OperationPerformer"; 13 | import { Settings } from "../services/Settings"; 14 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 15 | 16 | export class TabBehaviourOverride implements Feature { 17 | constructor( 18 | private plugin: Plugin, 19 | private imeDetector: IMEDetector, 20 | private obsidianSettings: ObsidianSettings, 21 | private settings: Settings, 22 | private operationPerformer: OperationPerformer, 23 | ) {} 24 | 25 | async load() { 26 | this.plugin.registerEditorExtension( 27 | Prec.highest( 28 | keymap.of([ 29 | { 30 | key: "Tab", 31 | run: createKeymapRunCallback({ 32 | check: this.check, 33 | run: this.run, 34 | }), 35 | }, 36 | ]), 37 | ), 38 | ); 39 | } 40 | 41 | async unload() {} 42 | 43 | private check = () => { 44 | return this.settings.overrideTabBehaviour && !this.imeDetector.isOpened(); 45 | }; 46 | 47 | private run = (editor: MyEditor) => { 48 | return this.operationPerformer.perform( 49 | (root) => 50 | new IndentList(root, this.obsidianSettings.getDefaultIndentChars()), 51 | editor, 52 | ); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/logic/__tests__/CalculateRangeForZooming.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state"; 2 | 3 | import { CalculateRangeForZooming } from "../CalculateRangeForZooming"; 4 | 5 | jest.mock("@codemirror/language", () => { 6 | return { 7 | foldable: jest.fn(), 8 | }; 9 | }); 10 | 11 | const foldable: jest.Mock = jest.requireMock("@codemirror/language").foldable; 12 | 13 | beforeEach(() => { 14 | foldable.mockReturnValue(null); 15 | }); 16 | 17 | test("should return nothing if block is unfoldable", () => { 18 | foldable.mockReturnValue(null); 19 | const state = EditorState.create({ 20 | doc: "# header\n\nline1\n", 21 | }); 22 | const calculateRangeForZooming = new CalculateRangeForZooming(); 23 | 24 | const x = calculateRangeForZooming.calculateRangeForZooming(state, 1); 25 | 26 | expect(x).toBeNull(); 27 | }); 28 | 29 | test("should return range from line start if block is foldable", () => { 30 | foldable.mockReturnValue({ from: 8, to: 16 }); 31 | const state = EditorState.create({ 32 | doc: "# header\n\nline1\n", 33 | }); 34 | const calculateRangeForZooming = new CalculateRangeForZooming(); 35 | 36 | const x = calculateRangeForZooming.calculateRangeForZooming(state, 1); 37 | 38 | expect(x).toStrictEqual({ from: 0, to: 16 }); 39 | }); 40 | 41 | test("should return range of current line if block is unfoldable but line is list item", () => { 42 | foldable.mockReturnValue(null); 43 | const state = EditorState.create({ 44 | doc: "line\n\n- list\n\nline", 45 | }); 46 | const calculateRangeForZooming = new CalculateRangeForZooming(); 47 | 48 | const x = calculateRangeForZooming.calculateRangeForZooming(state, 8); 49 | 50 | expect(x).toStrictEqual({ from: 6, to: 12 }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/operations/MoveListUp.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root, recalculateNumericBullets } from "../root"; 4 | 5 | export class MoveListUp implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | this.stopPropagation = true; 27 | 28 | const list = root.getListUnderCursor(); 29 | const parent = list.getParent(); 30 | const grandParent = parent.getParent(); 31 | const prev = parent.getPrevSiblingOf(list); 32 | 33 | const listStartLineBefore = root.getContentLinesRangeOf(list)[0]; 34 | 35 | if (!prev && grandParent) { 36 | const newParent = grandParent.getPrevSiblingOf(parent); 37 | 38 | if (newParent) { 39 | this.updated = true; 40 | parent.removeChild(list); 41 | newParent.addAfterAll(list); 42 | } 43 | } else if (prev) { 44 | this.updated = true; 45 | parent.removeChild(list); 46 | parent.addBefore(prev, list); 47 | } 48 | 49 | if (!this.updated) { 50 | return; 51 | } 52 | 53 | const listStartLineAfter = root.getContentLinesRangeOf(list)[0]; 54 | const lineDiff = listStartLineAfter - listStartLineBefore; 55 | 56 | const cursor = root.getCursor(); 57 | root.replaceCursor({ 58 | line: cursor.line + lineDiff, 59 | ch: cursor.ch, 60 | }); 61 | 62 | recalculateNumericBullets(root); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/operations/MoveListDown.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root, recalculateNumericBullets } from "../root"; 4 | 5 | export class MoveListDown implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | this.stopPropagation = true; 27 | 28 | const list = root.getListUnderCursor(); 29 | const parent = list.getParent(); 30 | const grandParent = parent.getParent(); 31 | const next = parent.getNextSiblingOf(list); 32 | 33 | const listStartLineBefore = root.getContentLinesRangeOf(list)[0]; 34 | 35 | if (!next && grandParent) { 36 | const newParent = grandParent.getNextSiblingOf(parent); 37 | 38 | if (newParent) { 39 | this.updated = true; 40 | parent.removeChild(list); 41 | newParent.addBeforeAll(list); 42 | } 43 | } else if (next) { 44 | this.updated = true; 45 | parent.removeChild(list); 46 | parent.addAfter(next, list); 47 | } 48 | 49 | if (!this.updated) { 50 | return; 51 | } 52 | 53 | const listStartLineAfter = root.getContentLinesRangeOf(list)[0]; 54 | const lineDiff = listStartLineAfter - listStartLineBefore; 55 | 56 | const cursor = root.getCursor(); 57 | root.replaceCursor({ 58 | line: cursor.line + lineDiff, 59 | ch: cursor.ch, 60 | }); 61 | 62 | recalculateNumericBullets(root); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/__mocks__.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { MyEditor } from "./editor"; 3 | import { Logger } from "./services/Logger"; 4 | import { Parser } from "./services/Parser"; 5 | import { Settings } from "./services/Settings"; 6 | 7 | export interface EditorMockParams { 8 | text: string; 9 | cursor: { line: number; ch: number }; 10 | getAllFoldedLines?: () => number[]; 11 | } 12 | 13 | export function makeEditor(params: EditorMockParams): MyEditor { 14 | const text = params.text; 15 | const cursor = { ...params.cursor }; 16 | 17 | const editor: any = { 18 | getCursor: () => cursor, 19 | listSelections: () => [{ anchor: cursor, head: cursor }], 20 | getLine: (l: number) => text.split("\n")[l], 21 | lastLine: () => text.split("\n").length - 1, 22 | lineCount: () => text.split("\n").length, 23 | getAllFoldedLines: params.getAllFoldedLines || (() => []), 24 | }; 25 | 26 | return editor; 27 | } 28 | 29 | export function makeLogger(): Logger { 30 | const log = jest.fn(); 31 | 32 | const logger: any = { 33 | log, 34 | bind: jest 35 | .fn() 36 | .mockImplementation((method: string) => log.bind(null, method)), 37 | }; 38 | 39 | return logger; 40 | } 41 | 42 | export function makeSettings(): Settings { 43 | const settings: any = { 44 | stickCursor: "bullet-and-checkbox", 45 | }; 46 | return settings; 47 | } 48 | 49 | export function makeRoot(options: { 50 | editor: MyEditor; 51 | settings?: Settings; 52 | logger?: Logger; 53 | }) { 54 | const { logger, editor, settings } = { 55 | logger: makeLogger(), 56 | settings: makeSettings(), 57 | ...options, 58 | }; 59 | 60 | return new Parser(logger, settings).parse(editor); 61 | } 62 | -------------------------------------------------------------------------------- /specs/features/BackspaceBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # backspace should work as regular if it's last empty line 2 | 3 | - applyState: 4 | 5 | ```md 6 | - | 7 | ``` 8 | 9 | - keydown: `Backspace` 10 | - assertState: 11 | 12 | ```md 13 | -| 14 | ``` 15 | 16 | # backspace should work as regular if it's first line without children 17 | 18 | - applyState: 19 | 20 | ```md 21 | - |one 22 | - two 23 | ``` 24 | 25 | - keydown: `Backspace` 26 | - assertState: 27 | 28 | ```md 29 | -|one 30 | - two 31 | ``` 32 | 33 | # backspace should do nothing if it's first line with children 34 | 35 | - applyState: 36 | 37 | ```md 38 | - |one 39 | - two 40 | ``` 41 | 42 | - keydown: `Backspace` 43 | - assertState: 44 | 45 | ```md 46 | - |one 47 | - two 48 | ``` 49 | 50 | # backspace should remove symbol if it isn't empty line 51 | 52 | - applyState: 53 | 54 | ```md 55 | - qwe| 56 | ``` 57 | 58 | - keydown: `Backspace` 59 | - assertState: 60 | 61 | ```md 62 | - qw| 63 | ``` 64 | 65 | # backspace should remove list item if it's empty 66 | 67 | - applyState: 68 | 69 | ```md 70 | - one 71 | - | 72 | ``` 73 | 74 | - keydown: `Backspace` 75 | - assertState: 76 | 77 | ```md 78 | - one| 79 | ``` 80 | 81 | # backspace should remove note line if it's empty 82 | 83 | - applyState: 84 | 85 | ```md 86 | - one 87 | | 88 | ``` 89 | 90 | - keydown: `Backspace` 91 | - assertState: 92 | 93 | ```md 94 | - one| 95 | ``` 96 | 97 | # backspace should remove note line if it isn't empty and cursor on the line start 98 | 99 | - applyState: 100 | 101 | ```md 102 | - one 103 | |two 104 | ``` 105 | 106 | - keydown: `Backspace` 107 | - assertState: 108 | 109 | ```md 110 | - one|two 111 | ``` 112 | -------------------------------------------------------------------------------- /src/features/ArrowLeftAndCtrlArrowLeftBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { keymap } from "@codemirror/view"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { MyEditor } from "../editor"; 8 | import { MoveCursorToPreviousUnfoldedLine } from "../operations/MoveCursorToPreviousUnfoldedLine"; 9 | import { IMEDetector } from "../services/IMEDetector"; 10 | import { OperationPerformer } from "../services/OperationPerformer"; 11 | import { Settings } from "../services/Settings"; 12 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 13 | 14 | export class ArrowLeftAndCtrlArrowLeftBehaviourOverride implements Feature { 15 | constructor( 16 | private plugin: Plugin, 17 | private settings: Settings, 18 | private imeDetector: IMEDetector, 19 | private operationPerformer: OperationPerformer, 20 | ) {} 21 | 22 | async load() { 23 | this.plugin.registerEditorExtension( 24 | keymap.of([ 25 | { 26 | key: "ArrowLeft", 27 | run: createKeymapRunCallback({ 28 | check: this.check, 29 | run: this.run, 30 | }), 31 | }, 32 | { 33 | win: "c-ArrowLeft", 34 | linux: "c-ArrowLeft", 35 | run: createKeymapRunCallback({ 36 | check: this.check, 37 | run: this.run, 38 | }), 39 | }, 40 | ]), 41 | ); 42 | } 43 | 44 | async unload() {} 45 | 46 | private check = () => { 47 | return ( 48 | this.settings.keepCursorWithinContent !== "never" && 49 | !this.imeDetector.isOpened() 50 | ); 51 | }; 52 | 53 | private run = (editor: MyEditor) => { 54 | return this.operationPerformer.perform( 55 | (root) => new MoveCursorToPreviousUnfoldedLine(root), 56 | editor, 57 | ); 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/logic/utils/__tests__/calculateLimitedSelection.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state"; 2 | 3 | import { calculateLimitedSelection } from "../calculateLimitedSelection"; 4 | 5 | test("should limit selection if visible area is smaller", () => { 6 | const selection = EditorSelection.create([EditorSelection.range(10, 20)]); 7 | const visibleArea = [12, 18]; 8 | 9 | const newSelection = calculateLimitedSelection( 10 | selection, 11 | visibleArea[0], 12 | visibleArea[1], 13 | ); 14 | 15 | expect(newSelection.from).toBe(12); 16 | expect(newSelection.to).toBe(18); 17 | }); 18 | 19 | test("should limit selection if visible area ends before selection", () => { 20 | const selection = EditorSelection.create([EditorSelection.range(10, 20)]); 21 | const visibleArea = [1, 18]; 22 | 23 | const newSelection = calculateLimitedSelection( 24 | selection, 25 | visibleArea[0], 26 | visibleArea[1], 27 | ); 28 | 29 | expect(newSelection.from).toBe(10); 30 | expect(newSelection.to).toBe(18); 31 | }); 32 | 33 | test("should limit selection if visible area starts after selection", () => { 34 | const selection = EditorSelection.create([EditorSelection.range(10, 20)]); 35 | const visibleArea = [12, 30]; 36 | 37 | const newSelection = calculateLimitedSelection( 38 | selection, 39 | visibleArea[0], 40 | visibleArea[1], 41 | ); 42 | 43 | expect(newSelection.from).toBe(12); 44 | expect(newSelection.to).toBe(20); 45 | }); 46 | 47 | test("should not limit selection if visible area is bigger", () => { 48 | const selection = EditorSelection.create([EditorSelection.range(10, 20)]); 49 | const visibleArea = [1, 30]; 50 | 51 | const newSelection = calculateLimitedSelection( 52 | selection, 53 | visibleArea[0], 54 | visibleArea[1], 55 | ); 56 | 57 | expect(newSelection).toBeNull(); 58 | }); 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-pro-outliner", 3 | "version": "1.0.6", 4 | "description": "Work with your lists like in Workflowy, RoamResearch, or Tana, with zoom functionality.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.mjs -w --configWithTests", 8 | "build-with-tests": "rollup --config rollup.config.mjs --configWithTests", 9 | "build": "rollup --config rollup.config.mjs", 10 | "lint": "prettier --check src && eslint src", 11 | "format": "prettier --write src", 12 | "test": "jest --forceExit", 13 | "test:unit": "SKIP_OBSIDIAN=1 jest --forceExit src", 14 | "prepare": "husky" 15 | }, 16 | "author": "Ruben Khachaturov", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@babel/core": "^7.26.9", 20 | "@babel/preset-env": "^7.26.9", 21 | "@babel/preset-typescript": "^7.26.0", 22 | "@codemirror/language": "^6.10.8", 23 | "@codemirror/state": "^6.5.2", 24 | "@codemirror/view": "^6.36.3", 25 | "@eslint/js": "^9.21.0", 26 | "@rollup/plugin-commonjs": "^28.0.2", 27 | "@rollup/plugin-node-resolve": "^16.0.0", 28 | "@rollup/plugin-replace": "^6.0.2", 29 | "@rollup/plugin-typescript": "^12.1.2", 30 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 31 | "@types/diff": "^7.0.1", 32 | "@types/jest": "^29.5.14", 33 | "@types/node": "^22.13.8", 34 | "babel-jest": "^29.7.0", 35 | "classic-level": "^2.0.0", 36 | "debug": "^4.4.0", 37 | "eslint": "^9.21.0", 38 | "husky": "^9.1.7", 39 | "jest": "^29.7.0", 40 | "jest-environment-jsdom": "^29.7.0", 41 | "jest-environment-node": "^29.7.0", 42 | "obsidian": "latest", 43 | "prettier": "^3.5.2", 44 | "rollup": "^4.34.9", 45 | "tslib": "^2.8.1", 46 | "typescript": "^5.7.3", 47 | "typescript-eslint": "^8.25.0", 48 | "ws": "^8.18.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/features/ResetZoomWhenVisibleContentBoundariesViolatedFeature.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { EditorState } from "@codemirror/state"; 4 | import { EditorView } from "@codemirror/view"; 5 | 6 | import { Feature } from "./Feature"; 7 | import { getEditorViewFromEditorState } from "./zoom-utils/getEditorViewFromEditorState"; 8 | 9 | import { DetectVisibleContentBoundariesViolation } from "../logic/DetectVisibleContentBoundariesViolation"; 10 | import { Logger } from "../services/Logger"; 11 | 12 | export interface CalculateHiddenContentRanges { 13 | calculateHiddenContentRanges( 14 | state: EditorState, 15 | ): { from: number; to: number }[] | null; 16 | } 17 | 18 | export interface ZoomOut { 19 | zoomOut(view: EditorView): void; 20 | } 21 | 22 | export class ResetZoomWhenVisibleContentBoundariesViolatedFeature implements Feature { 23 | private detectVisibleContentBoundariesViolation = 24 | new DetectVisibleContentBoundariesViolation( 25 | this.calculateHiddenContentRanges, 26 | { 27 | visibleContentBoundariesViolated: (state) => 28 | this.visibleContentBoundariesViolated(state), 29 | }, 30 | ); 31 | 32 | constructor( 33 | private plugin: Plugin, 34 | private logger: Logger, 35 | private calculateHiddenContentRanges: CalculateHiddenContentRanges, 36 | private zoomOut: ZoomOut, 37 | ) {} 38 | 39 | async load() { 40 | this.plugin.registerEditorExtension( 41 | this.detectVisibleContentBoundariesViolation.getExtension(), 42 | ); 43 | } 44 | 45 | async unload() {} 46 | 47 | private visibleContentBoundariesViolated(state: EditorState) { 48 | const l = this.logger.bind( 49 | "ResetZoomWhenVisibleContentBoundariesViolatedFeature:visibleContentBoundariesViolated", 50 | ); 51 | l("visible content boundaries violated, zooming out"); 52 | this.zoomOut.zoomOut(getEditorViewFromEditorState(state)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /jest/obsidian-environment.js: -------------------------------------------------------------------------------- 1 | const { TestEnvironment } = require("jest-environment-node"); 2 | const WebSocket = require("ws"); 3 | 4 | let idSeq = 1; 5 | 6 | module.exports = class CustomEnvironment extends TestEnvironment { 7 | async setup() { 8 | await super.setup(); 9 | 10 | this.callbacks = new Map(); 11 | 12 | this.createCommand("applyState"); 13 | this.createCommand("simulateKeydown"); 14 | this.createCommand("insertText"); 15 | this.createCommand("executeCommandById"); 16 | this.createCommand("setSetting"); 17 | this.createCommand("resetSettings"); 18 | this.createCommand("parseState"); 19 | this.createCommand("getCurrentState"); 20 | this.createCommand("drag"); 21 | this.createCommand("move"); 22 | this.createCommand("drop"); 23 | } 24 | 25 | createCommand(type) { 26 | this.global[type] = (data) => this.runCommand(type, data); 27 | } 28 | 29 | async initWs() { 30 | this.ws = new WebSocket("ws://127.0.0.1:8080"); 31 | 32 | await new Promise((resolve) => this.ws.on("open", resolve)); 33 | 34 | this.ws.on("message", (message) => { 35 | const { id, data, error } = JSON.parse(message); 36 | const cb = this.callbacks.get(id); 37 | if (cb) { 38 | this.callbacks.delete(id); 39 | cb(error, data); 40 | } 41 | }); 42 | } 43 | 44 | async runCommand(type, data) { 45 | if (!this.ws) { 46 | await this.initWs(); 47 | } 48 | 49 | return new Promise((resolve, reject) => { 50 | const id = String(idSeq++); 51 | 52 | this.callbacks.set(id, (error, data) => { 53 | if (error) { 54 | reject(new Error(error)); 55 | } else { 56 | resolve(data); 57 | } 58 | }); 59 | 60 | this.ws.send(JSON.stringify({ id, type, data })); 61 | }); 62 | } 63 | 64 | async teardown() { 65 | if (this.ws) { 66 | this.ws.close(); 67 | } 68 | await super.teardown(); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /specs/ZoomFeature.spec.md: -------------------------------------------------------------------------------- 1 | # Should zoom in 2 | 3 | - applyState: 4 | 5 | ```md 6 | text 7 | 8 | # 1 9 | 10 | text 11 | 12 | ## 1.1| 13 | 14 | text 15 | 16 | # 2 17 | 18 | text 19 | ``` 20 | 21 | - execute: `obsidian-zoom:zoom-in` 22 | - assertState: 23 | 24 | ```md 25 | text #hidden 26 | #hidden 27 | # 1 #hidden 28 | #hidden 29 | text #hidden 30 | #hidden 31 | ## 1.1| 32 | 33 | text 34 | 35 | # 2 #hidden 36 | #hidden 37 | text #hidden 38 | ``` 39 | 40 | # Should zoom out 41 | 42 | - applyState: 43 | 44 | ```md 45 | text 46 | 47 | # 1 48 | 49 | text 50 | 51 | ## 1.1| 52 | 53 | text 54 | 55 | # 2 56 | 57 | text 58 | ``` 59 | 60 | - execute: `obsidian-zoom:zoom-in` 61 | - execute: `obsidian-zoom:zoom-out` 62 | - assertState: 63 | 64 | ```md 65 | text 66 | 67 | # 1 68 | 69 | text 70 | 71 | ## 1.1| 72 | 73 | text 74 | 75 | # 2 76 | 77 | text 78 | ``` 79 | 80 | # Should zoom out one level to parent 81 | 82 | - applyState: 83 | 84 | ```md 85 | text 86 | 87 | # 1 88 | 89 | text 90 | 91 | ## 1.1 92 | 93 | text 94 | 95 | ### 1.1.1| 96 | 97 | text 98 | 99 | # 2 100 | 101 | text 102 | ``` 103 | 104 | - execute: `obsidian-zoom:zoom-in` 105 | - execute: `obsidian-zoom:zoom-out-one-level` 106 | - assertState: 107 | 108 | ```md 109 | text #hidden 110 | #hidden 111 | # 1 #hidden 112 | #hidden 113 | text #hidden 114 | #hidden 115 | ## 1.1| 116 | 117 | text 118 | 119 | ### 1.1.1 120 | 121 | text 122 | 123 | # 2 #hidden 124 | #hidden 125 | text #hidden 126 | ``` 127 | 128 | # Should zoom out one level to document when at top level 129 | 130 | - applyState: 131 | 132 | ```md 133 | text 134 | 135 | # 1| 136 | 137 | text 138 | 139 | # 2 140 | 141 | text 142 | ``` 143 | 144 | - execute: `obsidian-zoom:zoom-in` 145 | - execute: `obsidian-zoom:zoom-out-one-level` 146 | - assertState: 147 | 148 | ```md 149 | text 150 | 151 | # 1| 152 | 153 | text 154 | 155 | # 2 156 | 157 | text 158 | ``` 159 | -------------------------------------------------------------------------------- /src/operations/MoveCursorToPreviousUnfoldedLine.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { ListLine, Position, Root } from "../root"; 4 | 5 | export class MoveCursorToPreviousUnfoldedLine implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleCursor()) { 23 | return; 24 | } 25 | 26 | const list = this.root.getListUnderCursor(); 27 | const cursor = this.root.getCursor(); 28 | const lines = list.getLinesInfo(); 29 | const lineNo = lines.findIndex((l) => { 30 | return ( 31 | cursor.ch === l.from.ch + list.getCheckboxLength() && 32 | cursor.line === l.from.line 33 | ); 34 | }); 35 | 36 | if (lineNo === 0) { 37 | this.moveCursorToPreviousUnfoldedItem(root, cursor); 38 | } else if (lineNo > 0) { 39 | this.moveCursorToPreviousNoteLine(root, lines, lineNo); 40 | } 41 | } 42 | 43 | private moveCursorToPreviousNoteLine( 44 | root: Root, 45 | lines: ListLine[], 46 | lineNo: number, 47 | ) { 48 | this.stopPropagation = true; 49 | this.updated = true; 50 | 51 | root.replaceCursor(lines[lineNo - 1].to); 52 | } 53 | 54 | private moveCursorToPreviousUnfoldedItem(root: Root, cursor: Position) { 55 | const prev = root.getListUnderLine(cursor.line - 1); 56 | 57 | if (!prev) { 58 | return; 59 | } 60 | 61 | this.stopPropagation = true; 62 | this.updated = true; 63 | 64 | if (prev.isFolded()) { 65 | const foldRoot = prev.getTopFoldRoot(); 66 | const firstLineEnd = foldRoot.getLinesInfo()[0].to; 67 | root.replaceCursor(firstLineEnd); 68 | } else { 69 | root.replaceCursor(prev.getLastLineContentEnd()); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /specs/LimitSelectionFeature.spec.md: -------------------------------------------------------------------------------- 1 | # Should limit selection on zooming in 2 | 3 | - applyState: 4 | 5 | ```md 6 | text 7 | 8 | # 1| 9 | 10 | text 11 | 12 | ## 1.1| 13 | 14 | text 15 | 16 | # 2 17 | 18 | text 19 | ``` 20 | 21 | - execute: `obsidian-zoom:zoom-in` 22 | - assertState: 23 | 24 | ```md 25 | text #hidden 26 | #hidden 27 | # 1 #hidden 28 | #hidden 29 | text #hidden 30 | #hidden 31 | |## 1.1| 32 | 33 | text 34 | 35 | # 2 #hidden 36 | #hidden 37 | text #hidden 38 | ``` 39 | 40 | # Should limit selection when zoomed in 41 | 42 | - platform: `darwin` 43 | - applyState: 44 | 45 | ```md 46 | text 47 | 48 | # 1 49 | 50 | text 51 | 52 | ## 1.1| 53 | 54 | text 55 | 56 | # 2 57 | 58 | text 59 | ``` 60 | 61 | - execute: `obsidian-zoom:zoom-in` 62 | - keydown: `Cmd-KeyA` 63 | - assertState: 64 | 65 | ```md 66 | text #hidden 67 | #hidden 68 | # 1 #hidden 69 | #hidden 70 | text #hidden 71 | #hidden 72 | |## 1.1 73 | 74 | text 75 | | 76 | # 2 #hidden 77 | #hidden 78 | text #hidden 79 | ``` 80 | 81 | # Should limit selection when zoomed in 82 | 83 | - platform: `linux` 84 | - applyState: 85 | 86 | ```md 87 | text 88 | 89 | # 1 90 | 91 | text 92 | 93 | ## 1.1| 94 | 95 | text 96 | 97 | # 2 98 | 99 | text 100 | ``` 101 | 102 | - execute: `obsidian-zoom:zoom-in` 103 | - keydown: `Ctrl-KeyA` 104 | - assertState: 105 | 106 | ```md 107 | text #hidden 108 | #hidden 109 | # 1 #hidden 110 | #hidden 111 | text #hidden 112 | #hidden 113 | |## 1.1 114 | 115 | text 116 | | 117 | # 2 #hidden 118 | #hidden 119 | text #hidden 120 | ``` 121 | 122 | # Should not have bug #39 123 | 124 | - applyState: 125 | 126 | ```md 127 | # h1| 128 | 129 | # h2 130 | ``` 131 | 132 | - execute: `obsidian-zoom:zoom-in` 133 | - keydown: `ArrowDown` 134 | - replaceSelection: `a` 135 | - replaceSelection: `b` 136 | - replaceSelection: `c` 137 | - assertState: 138 | 139 | ```md 140 | # h1 141 | abc| 142 | # h2 #hidden 143 | ``` 144 | -------------------------------------------------------------------------------- /src/features/ListsFoldingCommands.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin } from "obsidian"; 2 | 3 | import { Feature } from "./Feature"; 4 | 5 | import { MyEditor } from "../editor"; 6 | import { ObsidianSettings } from "../services/ObsidianSettings"; 7 | import { t } from "../services/i18n"; 8 | import { createEditorCallback } from "../utils/createEditorCallback"; 9 | 10 | export class ListsFoldingCommands implements Feature { 11 | constructor( 12 | private plugin: Plugin, 13 | private obsidianSettings: ObsidianSettings, 14 | ) {} 15 | 16 | async load() { 17 | this.plugin.addCommand({ 18 | id: "fold", 19 | icon: "chevrons-down-up", 20 | name: t("cmd.fold"), 21 | editorCallback: createEditorCallback(this.fold), 22 | hotkeys: [ 23 | { 24 | modifiers: ["Mod"], 25 | key: "ArrowUp", 26 | }, 27 | ], 28 | }); 29 | 30 | this.plugin.addCommand({ 31 | id: "unfold", 32 | icon: "chevrons-up-down", 33 | name: t("cmd.unfold"), 34 | editorCallback: createEditorCallback(this.unfold), 35 | hotkeys: [ 36 | { 37 | modifiers: ["Mod"], 38 | key: "ArrowDown", 39 | }, 40 | ], 41 | }); 42 | } 43 | 44 | async unload() {} 45 | 46 | private setFold(editor: MyEditor, type: "fold" | "unfold") { 47 | if (!this.obsidianSettings.getFoldSettings().foldIndent) { 48 | new Notice( 49 | `Unable to ${type} because folding is disabled. Please enable "Fold indent" in Obsidian settings.`, 50 | 5000, 51 | ); 52 | return true; 53 | } 54 | 55 | const cursor = editor.getCursor(); 56 | 57 | if (type === "fold") { 58 | editor.fold(cursor.line); 59 | } else { 60 | editor.unfold(cursor.line); 61 | } 62 | 63 | return true; 64 | } 65 | 66 | private fold = (editor: MyEditor) => { 67 | return this.setFold(editor, "fold"); 68 | }; 69 | 70 | private unfold = (editor: MyEditor) => { 71 | return this.setFold(editor, "unfold"); 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/operations/IndentList.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root, recalculateNumericBullets } from "../root"; 4 | 5 | export class IndentList implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor( 10 | private root: Root, 11 | private defaultIndentChars: string, 12 | ) {} 13 | 14 | shouldStopPropagation() { 15 | return this.stopPropagation; 16 | } 17 | 18 | shouldUpdate() { 19 | return this.updated; 20 | } 21 | 22 | perform() { 23 | const { root } = this; 24 | 25 | if (!root.hasSingleCursor()) { 26 | return; 27 | } 28 | 29 | this.stopPropagation = true; 30 | 31 | const list = root.getListUnderCursor(); 32 | const parent = list.getParent(); 33 | const prev = parent.getPrevSiblingOf(list); 34 | 35 | if (!prev) { 36 | return; 37 | } 38 | 39 | this.updated = true; 40 | 41 | const listStartLineBefore = root.getContentLinesRangeOf(list)[0]; 42 | 43 | const indentPos = list.getFirstLineIndent().length; 44 | let indentChars = ""; 45 | 46 | if (indentChars === "" && !prev.isEmpty()) { 47 | indentChars = prev 48 | .getChildren()[0] 49 | .getFirstLineIndent() 50 | .slice(prev.getFirstLineIndent().length); 51 | } 52 | 53 | if (indentChars === "") { 54 | indentChars = list 55 | .getFirstLineIndent() 56 | .slice(parent.getFirstLineIndent().length); 57 | } 58 | 59 | if (indentChars === "" && !list.isEmpty()) { 60 | indentChars = list.getChildren()[0].getFirstLineIndent(); 61 | } 62 | 63 | if (indentChars === "") { 64 | indentChars = this.defaultIndentChars; 65 | } 66 | 67 | parent.removeChild(list); 68 | prev.addAfterAll(list); 69 | list.indentContent(indentPos, indentChars); 70 | 71 | const listStartLineAfter = root.getContentLinesRangeOf(list)[0]; 72 | const lineDiff = listStartLineAfter - listStartLineBefore; 73 | 74 | const cursor = root.getCursor(); 75 | root.replaceCursor({ 76 | line: cursor.line + lineDiff, 77 | ch: cursor.ch + indentChars.length, 78 | }); 79 | 80 | recalculateNumericBullets(root); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Release 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | notes: 9 | description: Release notes (optional) 10 | type: string 11 | required: false 12 | default: '' 13 | 14 | env: 15 | NODE_VERSION: 22 16 | 17 | jobs: 18 | release: 19 | name: Release 20 | runs-on: ubuntu-24.04 21 | steps: 22 | - name: Generate Token 23 | uses: actions/create-github-app-token@v2 24 | id: app-token 25 | with: 26 | app-id: "${{ secrets.BOT_APP_ID }}" 27 | private-key: "${{ secrets.BOT_APP_PRIVATE_KEY }}" 28 | 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | with: 32 | token: "${{ steps.app-token.outputs.token }}" 33 | 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ env.NODE_VERSION }} 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - name: Build 43 | run: npm run build 44 | 45 | - name: Get version from manifest.json 46 | id: version 47 | run: | 48 | VERSION=$(node -p "require('./manifest.json').version") 49 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 50 | echo "Detected version: ${VERSION}" 51 | 52 | - name: Create GitHub Release 53 | uses: softprops/action-gh-release@v2 54 | with: 55 | token: "${{ steps.app-token.outputs.token }}" 56 | tag_name: ${{ steps.version.outputs.VERSION }} 57 | name: Pro Outliner v${{ steps.version.outputs.VERSION }} 58 | body: | 59 | ## Pro Outliner v${{ steps.version.outputs.VERSION }} 60 | 61 | ${{ inputs.notes }} 62 | 63 | 📋 **[View full CHANGELOG](https://github.com/mrkhachaturov/obsidian-pro-outliner/blob/main/CHANGELOG.md)** 64 | 65 | ### Installation 66 | 67 | 1. Download the files below (`main.js`, `manifest.json`, `styles.css`) 68 | 2. Create a folder: `/.obsidian/plugins/obsidian-pro-outliner/` 69 | 3. Move the downloaded files into that folder 70 | 4. Reload Obsidian 71 | 5. Enable the plugin in Settings → Community plugins 72 | files: | 73 | main.js 74 | manifest.json 75 | styles.css 76 | draft: false 77 | prerelease: false 78 | 79 | -------------------------------------------------------------------------------- /specs/features/EditorSelectionsBehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # cursor should be moved to list content 2 | 3 | - applyState: 4 | 5 | ```md 6 | |- one 7 | ``` 8 | 9 | - assertState: 10 | 11 | ```md 12 | - |one 13 | ``` 14 | 15 | # cursor should be moved to list content with an unchecked checkbox 16 | 17 | - applyState: 18 | 19 | ```md 20 | |- [ ] one 21 | ``` 22 | 23 | - assertState: 24 | 25 | ```md 26 | - [ ] |one 27 | ``` 28 | 29 | # cursor should be moved to list content with a checked checkbox 30 | 31 | - applyState: 32 | 33 | ```md 34 | |- [x] one 35 | ``` 36 | 37 | - assertState: 38 | 39 | ```md 40 | - [x] |one 41 | ``` 42 | 43 | # cursor should be moved to list content with a custom checkbox 44 | 45 | - applyState: 46 | 47 | ```md 48 | |- [!] one 49 | ``` 50 | 51 | - assertState: 52 | 53 | ```md 54 | - [!] |one 55 | ``` 56 | 57 | # cursor should not be moved to list content with a custom checkbox if stickCursor="bullet-only" 58 | 59 | - applyState: 60 | 61 | ```md 62 | | 63 | ``` 64 | 65 | - setting: `stickCursor="bullet-only"` 66 | - applyState: 67 | 68 | ```md 69 | |- [!] one 70 | ``` 71 | 72 | - assertState: 73 | 74 | ```md 75 | - |[!] one 76 | ``` 77 | 78 | # cursor should not be moved to list content if stickCursor="never" 79 | 80 | - applyState: 81 | 82 | ```md 83 | | 84 | ``` 85 | 86 | - setting: `stickCursor="never"` 87 | - applyState: 88 | 89 | ```md 90 | |- one 91 | ``` 92 | 93 | - assertState: 94 | 95 | ```md 96 | |- one 97 | ``` 98 | 99 | # cursor should be moved to list content after arrowup 100 | 101 | - applyState: 102 | 103 | ```md 104 | - one 105 | | 106 | ``` 107 | 108 | - keydown: `ArrowUp` 109 | - assertState: 110 | 111 | ```md 112 | - |one 113 | 114 | ``` 115 | 116 | # cursor should be moved to list content after arrowright 117 | 118 | - applyState: 119 | 120 | ```md 121 | - one| 122 | - two 123 | ``` 124 | 125 | - keydown: `ArrowRight` 126 | - assertState: 127 | 128 | ```md 129 | - one 130 | - |two 131 | ``` 132 | 133 | # cursor should be moved to next note line 134 | 135 | - applyState: 136 | 137 | ```md 138 | - one| 139 | note 140 | ``` 141 | 142 | - keydown: `ArrowRight` 143 | - assertState: 144 | 145 | ```md 146 | - one 147 | |note 148 | ``` 149 | 150 | # cursor should not be moved when printing wikilink 151 | 152 | - applyState: 153 | 154 | ```md 155 | - | 156 | ``` 157 | 158 | - insertText: `[` 159 | - insertText: `[` 160 | - assertState: 161 | 162 | ```md 163 | - [[|]] 164 | ``` 165 | -------------------------------------------------------------------------------- /jest/obsidian-expect.js: -------------------------------------------------------------------------------- 1 | const jestExpect = global.expect; 2 | 3 | function stateToString(state) { 4 | const lines = state.value.split("\n"); 5 | 6 | const sels = state.selections.reduce((acc, sel) => { 7 | acc.set(sel.anchor.line + "_" + sel.anchor.ch, "anchor"); 8 | acc.set(sel.head.line + "_" + sel.head.ch, "head"); 9 | return acc; 10 | }, new Map()); 11 | 12 | let res = ""; 13 | 14 | for (let l = 0; l < lines.length; l++) { 15 | const line = lines[l]; 16 | 17 | for (let c = 0; c <= line.length; c++) { 18 | if (sels.has(l + "_" + c)) { 19 | res += "|"; 20 | } 21 | if (c < line.length) { 22 | res += line[c]; 23 | } 24 | } 25 | 26 | if (state.folds.includes(l)) { 27 | res += " #folded"; 28 | } 29 | 30 | res += "\n"; 31 | } 32 | 33 | return res; 34 | } 35 | 36 | jestExpect.extend({ 37 | async toEqualEditorState(receivedState, expectedState) { 38 | const options = { 39 | comment: "Obsidian editor state equality", 40 | isNot: this.isNot, 41 | promise: this.promise, 42 | }; 43 | 44 | expectedState = await parseState(expectedState); 45 | 46 | const received = stateToString(receivedState); 47 | const expected = stateToString(expectedState); 48 | 49 | const pass = received === expected; 50 | 51 | const message = pass 52 | ? () => 53 | this.utils.matcherHint( 54 | "toEqualEditorState", 55 | undefined, 56 | undefined, 57 | options 58 | ) + 59 | "\n\n" + 60 | `Expected: not ${this.utils.printExpected(expected)}\n` + 61 | `Received: ${this.utils.printReceived(received)}` 62 | : () => { 63 | const diffString = this.utils.diff(expected, received, { 64 | expand: this.expand, 65 | }); 66 | return ( 67 | this.utils.matcherHint( 68 | "toEqualEditorState", 69 | undefined, 70 | undefined, 71 | options 72 | ) + 73 | "\n\n" + 74 | (diffString && diffString.includes("- Expect") 75 | ? `Difference:\n\n${diffString}` 76 | : `Expected: ${this.utils.printExpected(expected)}\n` + 77 | `Received: ${this.utils.printReceived(received)}`) 78 | ); 79 | }; 80 | 81 | return { 82 | pass, 83 | message, 84 | }; 85 | }, 86 | }); 87 | 88 | Array.prototype.last = function () { 89 | return this[this.length - 1]; 90 | }; 91 | -------------------------------------------------------------------------------- /specs/features/DragAndDrop.spec.md: -------------------------------------------------------------------------------- 1 | # list should move after dragging it with the mouse 2 | 3 | - setting: `dnd=true` 4 | - applyState: 5 | 6 | ```md 7 | - one 8 | - two 9 | - |three 10 | ``` 11 | 12 | - drag: `{"from": {"line": 2, "ch": 0}}` 13 | - move: `{"to": {"line": 0, "ch": 0}, "offsetX": 10, "offsetY": -10}` 14 | - drop 15 | - assertState: 16 | 17 | ```md 18 | - |three 19 | - one 20 | - two 21 | ``` 22 | 23 | # list should move with sublists after dragging it with the mouse 24 | 25 | - setting: `dnd=true` 26 | - applyState: 27 | 28 | ```md 29 | - one 30 | - two 31 | - |three 32 | - four 33 | ``` 34 | 35 | - drag: `{"from": {"line": 2, "ch": 0}}` 36 | - move: `{"to": {"line": 0, "ch": 0}, "offsetX": 10, "offsetY": -10}` 37 | - drop 38 | - assertState: 39 | 40 | ```md 41 | - |three 42 | - four 43 | - one 44 | - two 45 | ``` 46 | 47 | # cursor should keep position after moving the list 48 | 49 | - setting: `dnd=true` 50 | - applyState: 51 | 52 | ```md 53 | - one 54 | - two| 55 | - three 56 | ``` 57 | 58 | - drag: `{"from": {"line": 2, "ch": 0}}` 59 | - move: `{"to": {"line": 0, "ch": 0}, "offsetX": 10, "offsetY": -10}` 60 | - drop 61 | - assertState: 62 | 63 | ```md 64 | - three 65 | - one 66 | - two| 67 | ``` 68 | 69 | # list should move to the first position if the mouse is moved above all items 70 | 71 | - setting: `dnd=true` 72 | - applyState: 73 | 74 | ```md 75 | - one 76 | - two| 77 | - three 78 | ``` 79 | 80 | - drag: `{"from": {"line": 5, "ch": 0}}` 81 | - move: `{"to": {"line": 0, "ch": 0}}` 82 | - drop 83 | - assertState: 84 | 85 | ```md 86 | - three 87 | - one 88 | - two| 89 | ``` 90 | 91 | # list should move to the last position if the mouse is moved below all items 92 | 93 | - setting: `dnd=true` 94 | - applyState: 95 | 96 | ```md 97 | - one 98 | - two| 99 | - three 100 | 101 | 102 | 103 | ``` 104 | 105 | - drag: `{"from": {"line": 0, "ch": 0}}` 106 | - move: `{"to": {"line": 5, "ch": 0}}` 107 | - drop 108 | - assertState: 109 | 110 | ```md 111 | - two| 112 | - three 113 | - one 114 | 115 | 116 | 117 | ``` 118 | 119 | # list should move inside another list if the mouse is moved slightly to the right 120 | 121 | - setting: `dnd=true` 122 | - applyState: 123 | 124 | ```md 125 | - one 126 | - two| 127 | - three 128 | ``` 129 | 130 | - drag: `{"from": {"line": 0, "ch": 0}}` 131 | - move: `{"to": {"line": 2, "ch": 0}, "offsetX": 50, "offsetY": -10}` 132 | - drop 133 | - assertState: 134 | 135 | ```md 136 | - two| 137 | - one 138 | - three 139 | ``` 140 | -------------------------------------------------------------------------------- /src/features/EnterBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { Prec } from "@codemirror/state"; 4 | import { keymap } from "@codemirror/view"; 5 | 6 | import { Feature } from "./Feature"; 7 | 8 | import { MyEditor } from "../editor"; 9 | import { CreateNewItem } from "../operations/CreateNewItem"; 10 | import { OutdentListIfItsEmpty } from "../operations/OutdentListIfItsEmpty"; 11 | import { IMEDetector } from "../services/IMEDetector"; 12 | import { ObsidianSettings } from "../services/ObsidianSettings"; 13 | import { OperationPerformer } from "../services/OperationPerformer"; 14 | import { Parser } from "../services/Parser"; 15 | import { Settings } from "../services/Settings"; 16 | import { createKeymapRunCallback } from "../utils/createKeymapRunCallback"; 17 | 18 | export class EnterBehaviourOverride implements Feature { 19 | constructor( 20 | private plugin: Plugin, 21 | private settings: Settings, 22 | private imeDetector: IMEDetector, 23 | private obsidianSettings: ObsidianSettings, 24 | private parser: Parser, 25 | private operationPerformer: OperationPerformer, 26 | ) {} 27 | 28 | async load() { 29 | this.plugin.registerEditorExtension( 30 | Prec.highest( 31 | keymap.of([ 32 | { 33 | key: "Enter", 34 | run: createKeymapRunCallback({ 35 | check: this.check, 36 | run: this.run, 37 | }), 38 | }, 39 | ]), 40 | ), 41 | ); 42 | } 43 | 44 | async unload() {} 45 | 46 | private check = () => { 47 | return this.settings.overrideEnterBehaviour && !this.imeDetector.isOpened(); 48 | }; 49 | 50 | private run = (editor: MyEditor) => { 51 | const root = this.parser.parse(editor); 52 | 53 | if (!root) { 54 | return { 55 | shouldUpdate: false, 56 | shouldStopPropagation: false, 57 | }; 58 | } 59 | 60 | { 61 | const res = this.operationPerformer.eval( 62 | root, 63 | new OutdentListIfItsEmpty(root), 64 | editor, 65 | ); 66 | 67 | if (res.shouldStopPropagation) { 68 | return res; 69 | } 70 | } 71 | 72 | { 73 | const defaultIndentChars = this.obsidianSettings.getDefaultIndentChars(); 74 | const zoomRange = editor.getZoomRange(); 75 | const getZoomRange = { 76 | getZoomRange: () => zoomRange, 77 | }; 78 | 79 | const res = this.operationPerformer.eval( 80 | root, 81 | new CreateNewItem(root, defaultIndentChars, getZoomRange), 82 | editor, 83 | ); 84 | 85 | if (res.shouldUpdate && zoomRange) { 86 | editor.tryRefreshZoom(zoomRange.from.line); 87 | } 88 | 89 | return res; 90 | } 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/features/EditorSelectionsBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { EditorState, Transaction } from "@codemirror/state"; 4 | 5 | import { Feature } from "./Feature"; 6 | 7 | import { MyEditor, getEditorFromState } from "../editor"; 8 | import { ExpandSelectionToFullItems } from "../operations/ExpandSelectionToFullItems"; 9 | import { KeepCursorOutsideFoldedLines } from "../operations/KeepCursorOutsideFoldedLines"; 10 | import { KeepCursorWithinListContent } from "../operations/KeepCursorWithinListContent"; 11 | import { OperationPerformer } from "../services/OperationPerformer"; 12 | import { Parser } from "../services/Parser"; 13 | import { Settings } from "../services/Settings"; 14 | 15 | export class EditorSelectionsBehaviourOverride implements Feature { 16 | constructor( 17 | private plugin: Plugin, 18 | private settings: Settings, 19 | private parser: Parser, 20 | private operationPerformer: OperationPerformer, 21 | ) {} 22 | 23 | async load() { 24 | this.plugin.registerEditorExtension( 25 | EditorState.transactionExtender.of(this.transactionExtender), 26 | ); 27 | } 28 | 29 | async unload() {} 30 | 31 | private transactionExtender = (tr: Transaction): null => { 32 | if (this.settings.keepCursorWithinContent === "never" || !tr.selection) { 33 | return null; 34 | } 35 | 36 | const editor = getEditorFromState(tr.startState); 37 | 38 | setTimeout(() => { 39 | this.handleSelectionsChanges(editor); 40 | }, 0); 41 | 42 | return null; 43 | }; 44 | 45 | private handleSelectionsChanges = (editor: MyEditor) => { 46 | const root = this.parser.parse(editor); 47 | 48 | if (!root) { 49 | return; 50 | } 51 | 52 | { 53 | const { shouldStopPropagation } = this.operationPerformer.eval( 54 | root, 55 | new KeepCursorOutsideFoldedLines(root), 56 | editor, 57 | ); 58 | 59 | if (shouldStopPropagation) { 60 | return; 61 | } 62 | } 63 | 64 | // Only try to expand selection if it spans multiple lines (actual selection, not cursor) 65 | if (this.settings.expandSelection) { 66 | const selections = root.getSelections(); 67 | if ( 68 | selections.length === 1 && 69 | selections[0].anchor.line !== selections[0].head.line 70 | ) { 71 | const { shouldStopPropagation } = this.operationPerformer.eval( 72 | root, 73 | new ExpandSelectionToFullItems(root), 74 | editor, 75 | ); 76 | 77 | if (shouldStopPropagation) { 78 | return; 79 | } 80 | } 81 | } 82 | 83 | this.operationPerformer.eval( 84 | root, 85 | new KeepCursorWithinListContent(root), 86 | editor, 87 | ); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /specs/DefaultObsidianBehaviour.spec.md: -------------------------------------------------------------------------------- 1 | # Cmd-Shift-Left should select content only 2 | 3 | - platform: `darwin` 4 | - applyState: 5 | 6 | ```md 7 | - one| 8 | ``` 9 | 10 | - keydown: `Cmd-Shift-ArrowLeft` 11 | - assertState: 12 | 13 | ```md 14 | - |one| 15 | ``` 16 | 17 | # Shift-Home should select content only 18 | 19 | - platform: `linux` 20 | - applyState: 21 | 22 | ```md 23 | - one| 24 | ``` 25 | 26 | - keydown: `Shift-Home` 27 | - assertState: 28 | 29 | ```md 30 | - |one| 31 | ``` 32 | 33 | # Cmd-Shift-Left should select content only excluding checkbox 34 | 35 | - platform: `darwin` 36 | - applyState: 37 | 38 | ```md 39 | - [ ] one| 40 | ``` 41 | 42 | - keydown: `Cmd-Shift-ArrowLeft` 43 | - assertState: 44 | 45 | ```md 46 | - [ ] |one| 47 | ``` 48 | 49 | # Shift-Home should select content only excluding checkbox 50 | 51 | - platform: `linux` 52 | - applyState: 53 | 54 | ```md 55 | - [ ] one| 56 | ``` 57 | 58 | - keydown: `Shift-Home` 59 | - assertState: 60 | 61 | ```md 62 | - [ ] |one| 63 | ``` 64 | 65 | # Cmd-Shift-Left should select content not including custom checkboxes 66 | 67 | - platform: `darwin` 68 | - setting: `stickCursor="bullet-and-checkbox"` 69 | - applyState: 70 | 71 | ```md 72 | - [!] one| 73 | ``` 74 | 75 | - keydown: `Cmd-Shift-ArrowLeft` 76 | - assertState: 77 | 78 | ```md 79 | - [!] |one| 80 | ``` 81 | 82 | # Shift-Home should select content not including custom checkboxes 83 | 84 | - platform: `linux` 85 | - setting: `stickCursor="bullet-and-checkbox"` 86 | - applyState: 87 | 88 | ```md 89 | - [!] one| 90 | ``` 91 | 92 | - keydown: `Shift-Home` 93 | - assertState: 94 | 95 | ```md 96 | - [!] |one| 97 | ``` 98 | 99 | # Cmd-Shift-Left should select one note line only 100 | 101 | - platform: `darwin` 102 | - applyState: 103 | 104 | ```md 105 | - one 106 | note| 107 | ``` 108 | 109 | - keydown: `Cmd-Shift-ArrowLeft` 110 | - assertState: 111 | 112 | ```md 113 | - one 114 | |note| 115 | ``` 116 | 117 | # Shift-Home should select one note line only 118 | 119 | - platform: `linux` 120 | - applyState: 121 | 122 | ```md 123 | - one 124 | note| 125 | ``` 126 | 127 | - keydown: `Shift-Home` 128 | - assertState: 129 | 130 | ```md 131 | - one 132 | |note| 133 | ``` 134 | 135 | # shift-enter should create note 136 | 137 | - applyState: 138 | 139 | ```md 140 | - one| 141 | - two 142 | ``` 143 | 144 | - keydown: `Shift-Enter` 145 | - assertState: 146 | 147 | ```md 148 | - one 149 | | 150 | - two 151 | ``` 152 | 153 | # shift-enter should continue note 154 | 155 | - applyState: 156 | 157 | ```md 158 | - one 159 | note| 160 | ``` 161 | 162 | - keydown: `Shift-Enter` 163 | - assertState: 164 | 165 | ```md 166 | - one 167 | note 168 | | 169 | ``` 170 | 171 | # shift-enter should split note 172 | 173 | - applyState: 174 | 175 | ```md 176 | - one 177 | no|te 178 | ``` 179 | 180 | - keydown: `Shift-Enter` 181 | - assertState: 182 | 183 | ```md 184 | - one 185 | no 186 | |te 187 | ``` 188 | -------------------------------------------------------------------------------- /specs/features/CtrlAAndCmdABehaviourOverride.spec.md: -------------------------------------------------------------------------------- 1 | # cmd-a should select list item content 2 | 3 | - platform: `darwin` 4 | - applyState: 5 | 6 | ```md 7 | - one 8 | - two| 9 | ``` 10 | 11 | - keydown: `Cmd-KeyA` 12 | - assertState: 13 | 14 | ```md 15 | - one 16 | - |two| 17 | ``` 18 | 19 | # ctrl-a should select list item content 20 | 21 | - platform: `linux` 22 | - applyState: 23 | 24 | ```md 25 | - one 26 | - two| 27 | ``` 28 | 29 | - keydown: `Ctrl-KeyA` 30 | - assertState: 31 | 32 | ```md 33 | - one 34 | - |two| 35 | ``` 36 | 37 | # cmd-a should select list item content excluding checkbox 38 | 39 | - platform: `darwin` 40 | - applyState: 41 | 42 | ```md 43 | - one 44 | - [ ] two| 45 | ``` 46 | 47 | - keydown: `Cmd-KeyA` 48 | - assertState: 49 | 50 | ```md 51 | - one 52 | - [ ] |two| 53 | ``` 54 | 55 | # ctrl-a should select list item content excluding checkbox 56 | 57 | - platform: `linux` 58 | - applyState: 59 | 60 | ```md 61 | - one 62 | - [ ] two| 63 | ``` 64 | 65 | - keydown: `Ctrl-KeyA` 66 | - assertState: 67 | 68 | ```md 69 | - one 70 | - [ ] |two| 71 | ``` 72 | 73 | # cmd-a should select list item content excluding custom checkbox 74 | 75 | - platform: `darwin` 76 | - applyState: 77 | 78 | ```md 79 | - one 80 | - [!] two| 81 | ``` 82 | 83 | - keydown: `Cmd-KeyA` 84 | - assertState: 85 | 86 | ```md 87 | - one 88 | - [!] |two| 89 | ``` 90 | 91 | # ctrl-a should select list item content excluding custom checkbox 92 | 93 | - platform: `linux` 94 | - applyState: 95 | 96 | ```md 97 | - one 98 | - [!] two| 99 | ``` 100 | 101 | - keydown: `Ctrl-KeyA` 102 | - assertState: 103 | 104 | ```md 105 | - one 106 | - [!] |two| 107 | ``` 108 | 109 | # cmd-a should select list item content with notes 110 | 111 | - platform: `darwin` 112 | - applyState: 113 | 114 | ```md 115 | - one 116 | - two| 117 | notes 118 | ``` 119 | 120 | - keydown: `Cmd-KeyA` 121 | - assertState: 122 | 123 | ```md 124 | - one 125 | - |two 126 | notes| 127 | ``` 128 | 129 | # ctrl-a should select list item content with notes 130 | 131 | - platform: `linux` 132 | - applyState: 133 | 134 | ```md 135 | - one 136 | - two| 137 | notes 138 | ``` 139 | 140 | - keydown: `Ctrl-KeyA` 141 | - assertState: 142 | 143 | ```md 144 | - one 145 | - |two 146 | notes| 147 | ``` 148 | 149 | # cmd-a should select list whole list after second invoke 150 | 151 | - platform: `darwin` 152 | - applyState: 153 | 154 | ```md 155 | a 156 | - one 157 | - two| 158 | b 159 | ``` 160 | 161 | - keydown: `Cmd-KeyA` 162 | - keydown: `Cmd-KeyA` 163 | - assertState: 164 | 165 | ```md 166 | a 167 | |- one 168 | - two| 169 | b 170 | ``` 171 | 172 | # ctrl-a should select list whole list after second invoke 173 | 174 | - platform: `linux` 175 | - applyState: 176 | 177 | ```md 178 | a 179 | - one 180 | - two| 181 | b 182 | ``` 183 | 184 | - keydown: `Ctrl-KeyA` 185 | - keydown: `Ctrl-KeyA` 186 | - assertState: 187 | 188 | ```md 189 | a 190 | |- one 191 | - two| 192 | b 193 | ``` 194 | -------------------------------------------------------------------------------- /src/features/SystemInfo.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Plugin } from "obsidian"; 2 | 3 | import { Feature } from "./Feature"; 4 | 5 | import { Settings } from "../services/Settings"; 6 | import { t } from "../services/i18n"; 7 | 8 | interface AppHiddenProps { 9 | internalPlugins: { 10 | config: { [key: string]: boolean }; 11 | }; 12 | isMobile: boolean; 13 | plugins: { 14 | enabledPlugins: Set; 15 | manifests: { [key: string]: { version: string } }; 16 | }; 17 | vault: { 18 | config: object; 19 | }; 20 | } 21 | 22 | class SystemInfoModal extends Modal { 23 | constructor( 24 | app: App, 25 | private settings: Settings, 26 | ) { 27 | super(app); 28 | } 29 | 30 | async onOpen() { 31 | this.titleEl.setText("System Information"); 32 | 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | const app = this.app as any as AppHiddenProps; 35 | 36 | const data = { 37 | process: { 38 | arch: process.arch, 39 | platform: process.platform, 40 | }, 41 | app: { 42 | internalPlugins: { 43 | config: app.internalPlugins.config, 44 | }, 45 | isMobile: app.isMobile, 46 | plugins: { 47 | enabledPlugins: Array.from(app.plugins.enabledPlugins), 48 | manifests: Object.keys(app.plugins.manifests).reduce( 49 | (acc, key) => { 50 | acc[key] = { 51 | version: app.plugins.manifests[key].version, 52 | }; 53 | return acc; 54 | }, 55 | {} as { [key: string]: { version: string } }, 56 | ), 57 | }, 58 | vault: { 59 | config: app.vault.config, 60 | }, 61 | }, 62 | plugin: { 63 | settings: { values: this.settings.getValues() }, 64 | }, 65 | }; 66 | 67 | const text = JSON.stringify(data, null, 2); 68 | 69 | const pre = this.contentEl.createEl("pre"); 70 | pre.setText(text); 71 | pre.setCssStyles({ 72 | overflow: "scroll", 73 | maxHeight: "300px", 74 | }); 75 | 76 | const button = this.contentEl.createEl("button"); 77 | button.setText("Copy and Close"); 78 | button.onClickEvent(() => { 79 | navigator.clipboard.writeText("```json\n" + text + "\n```"); 80 | this.close(); 81 | }); 82 | } 83 | } 84 | 85 | export class SystemInfo implements Feature { 86 | constructor( 87 | private plugin: Plugin, 88 | private settings: Settings, 89 | ) {} 90 | 91 | async load() { 92 | this.plugin.addCommand({ 93 | id: "system-info", 94 | name: t("cmd.show-system-info"), 95 | callback: this.callback, 96 | hotkeys: [ 97 | { 98 | modifiers: ["Mod", "Shift", "Alt"], 99 | key: "I", 100 | }, 101 | ], 102 | }); 103 | } 104 | 105 | async unload() {} 106 | 107 | private callback = () => { 108 | const modal = new SystemInfoModal(this.plugin.app, this.settings); 109 | modal.open(); 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/operations/ExpandSelectionToFullItems.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { List, Root, cmpPos, maxPos, minPos } from "../root"; 4 | 5 | export class ExpandSelectionToFullItems implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | /** 20 | * Check if listA is an ancestor of listB 21 | */ 22 | private isAncestor(listA: List, listB: List): boolean { 23 | let current = listB.getParent(); 24 | while (current) { 25 | if (current === listA) { 26 | return true; 27 | } 28 | current = current.getParent(); 29 | } 30 | return false; 31 | } 32 | 33 | perform() { 34 | const { root } = this; 35 | 36 | if (!root.hasSingleSelection()) { 37 | return; 38 | } 39 | 40 | const selection = root.getSelections()[0]; 41 | const { anchor, head } = selection; 42 | 43 | // Only expand if selection spans multiple lines 44 | if (anchor.line === head.line) { 45 | return; 46 | } 47 | 48 | const [rootStart, rootEnd] = root.getContentRange(); 49 | 50 | const selectionFrom = minPos(anchor, head); 51 | const selectionTo = maxPos(anchor, head); 52 | 53 | // Check if selection is within the list bounds 54 | if ( 55 | selectionFrom.line < rootStart.line || 56 | selectionTo.line > rootEnd.line 57 | ) { 58 | return; 59 | } 60 | 61 | const listAtFrom = root.getListUnderLine(selectionFrom.line); 62 | const listAtTo = root.getListUnderLine(selectionTo.line); 63 | 64 | if (!listAtFrom || !listAtTo) { 65 | return; 66 | } 67 | 68 | // Calculate the expanded range 69 | const expandedStart = listAtFrom.getFirstLineContentStartAfterCheckbox(); 70 | 71 | // If listAtFrom is an ancestor of listAtTo, expand to include ALL children of listAtFrom 72 | // This handles the case: selecting from parent down to any child should select all children 73 | let expandedEnd; 74 | if (this.isAncestor(listAtFrom, listAtTo)) { 75 | // Expand to include all descendants of the parent 76 | expandedEnd = listAtFrom.getContentEndIncludingChildren(); 77 | } else { 78 | expandedEnd = listAtTo.getContentEndIncludingChildren(); 79 | } 80 | 81 | // Check if selection is already expanded (to avoid infinite loops) 82 | if ( 83 | cmpPos(selectionFrom, expandedStart) === 0 && 84 | cmpPos(selectionTo, expandedEnd) === 0 85 | ) { 86 | return; 87 | } 88 | 89 | // Preserve selection direction (anchor/head order) 90 | const isForward = cmpPos(anchor, head) <= 0; 91 | 92 | this.stopPropagation = true; 93 | this.updated = true; 94 | 95 | if (isForward) { 96 | root.replaceSelections([{ anchor: expandedStart, head: expandedEnd }]); 97 | } else { 98 | root.replaceSelections([{ anchor: expandedEnd, head: expandedStart }]); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/features/ListsMovementCommands.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | 3 | import { Feature } from "./Feature"; 4 | 5 | import { MyEditor } from "../editor"; 6 | import { IndentList } from "../operations/IndentList"; 7 | import { MoveListDown } from "../operations/MoveListDown"; 8 | import { MoveListUp } from "../operations/MoveListUp"; 9 | import { OutdentList } from "../operations/OutdentList"; 10 | import { ObsidianSettings } from "../services/ObsidianSettings"; 11 | import { OperationPerformer } from "../services/OperationPerformer"; 12 | import { t } from "../services/i18n"; 13 | import { createEditorCallback } from "../utils/createEditorCallback"; 14 | 15 | export class ListsMovementCommands implements Feature { 16 | constructor( 17 | private plugin: Plugin, 18 | private obsidianSettings: ObsidianSettings, 19 | private operationPerformer: OperationPerformer, 20 | ) {} 21 | 22 | async load() { 23 | this.plugin.addCommand({ 24 | id: "move-list-item-up", 25 | icon: "arrow-up", 26 | name: t("cmd.move-list-up"), 27 | editorCallback: createEditorCallback(this.moveListUp), 28 | hotkeys: [ 29 | { 30 | modifiers: ["Mod", "Shift"], 31 | key: "ArrowUp", 32 | }, 33 | ], 34 | }); 35 | 36 | this.plugin.addCommand({ 37 | id: "move-list-item-down", 38 | icon: "arrow-down", 39 | name: t("cmd.move-list-down"), 40 | editorCallback: createEditorCallback(this.moveListDown), 41 | hotkeys: [ 42 | { 43 | modifiers: ["Mod", "Shift"], 44 | key: "ArrowDown", 45 | }, 46 | ], 47 | }); 48 | 49 | this.plugin.addCommand({ 50 | id: "indent-list", 51 | icon: "indent", 52 | name: t("cmd.indent-list"), 53 | editorCallback: createEditorCallback(this.indentList), 54 | hotkeys: [], 55 | }); 56 | 57 | this.plugin.addCommand({ 58 | id: "outdent-list", 59 | icon: "outdent", 60 | name: t("cmd.outdent-list"), 61 | editorCallback: createEditorCallback(this.outdentList), 62 | hotkeys: [], 63 | }); 64 | } 65 | 66 | async unload() {} 67 | 68 | private moveListDown = (editor: MyEditor) => { 69 | const { shouldStopPropagation } = this.operationPerformer.perform( 70 | (root) => new MoveListDown(root), 71 | editor, 72 | ); 73 | 74 | return shouldStopPropagation; 75 | }; 76 | 77 | private moveListUp = (editor: MyEditor) => { 78 | const { shouldStopPropagation } = this.operationPerformer.perform( 79 | (root) => new MoveListUp(root), 80 | editor, 81 | ); 82 | 83 | return shouldStopPropagation; 84 | }; 85 | 86 | private indentList = (editor: MyEditor) => { 87 | const { shouldStopPropagation } = this.operationPerformer.perform( 88 | (root) => 89 | new IndentList(root, this.obsidianSettings.getDefaultIndentChars()), 90 | editor, 91 | ); 92 | 93 | return shouldStopPropagation; 94 | }; 95 | 96 | private outdentList = (editor: MyEditor) => { 97 | const { shouldStopPropagation } = this.operationPerformer.perform( 98 | (root) => new OutdentList(root), 99 | editor, 100 | ); 101 | 102 | return shouldStopPropagation; 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/operations/SelectAllContent.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { Root, maxPos, minPos } from "../root"; 4 | 5 | export class SelectAllContent implements Operation { 6 | private stopPropagation = false; 7 | private updated = false; 8 | 9 | constructor(private root: Root) {} 10 | 11 | shouldStopPropagation() { 12 | return this.stopPropagation; 13 | } 14 | 15 | shouldUpdate() { 16 | return this.updated; 17 | } 18 | 19 | perform() { 20 | const { root } = this; 21 | 22 | if (!root.hasSingleSelection()) { 23 | return; 24 | } 25 | 26 | const selection = root.getSelections()[0]; 27 | const [rootStart, rootEnd] = root.getContentRange(); 28 | 29 | const selectionFrom = minPos(selection.anchor, selection.head); 30 | const selectionTo = maxPos(selection.anchor, selection.head); 31 | 32 | if ( 33 | selectionFrom.line < rootStart.line || 34 | selectionTo.line > rootEnd.line 35 | ) { 36 | return false; 37 | } 38 | 39 | if ( 40 | selectionFrom.line === rootStart.line && 41 | selectionFrom.ch === rootStart.ch && 42 | selectionTo.line === rootEnd.line && 43 | selectionTo.ch === rootEnd.ch 44 | ) { 45 | return false; 46 | } 47 | 48 | const list = root.getListUnderCursor(); 49 | const contentStart = list.getFirstLineContentStartAfterCheckbox(); 50 | const contentEnd = list.getLastLineContentEnd(); 51 | const listUnderSelectionFrom = root.getListUnderLine(selectionFrom.line); 52 | const listStart = 53 | listUnderSelectionFrom.getFirstLineContentStartAfterCheckbox(); 54 | const listEnd = listUnderSelectionFrom.getContentEndIncludingChildren(); 55 | 56 | this.stopPropagation = true; 57 | this.updated = true; 58 | 59 | if ( 60 | selectionFrom.line === contentStart.line && 61 | selectionFrom.ch === contentStart.ch && 62 | selectionTo.line === contentEnd.line && 63 | selectionTo.ch === contentEnd.ch 64 | ) { 65 | if (list.getChildren().length) { 66 | // select sub lists 67 | root.replaceSelections([ 68 | { anchor: contentStart, head: list.getContentEndIncludingChildren() }, 69 | ]); 70 | } else { 71 | // select whole list 72 | root.replaceSelections([{ anchor: rootStart, head: rootEnd }]); 73 | } 74 | } else if ( 75 | listStart.ch == selectionFrom.ch && 76 | listEnd.line == selectionTo.line && 77 | listEnd.ch == selectionTo.ch 78 | ) { 79 | // select whole list 80 | root.replaceSelections([{ anchor: rootStart, head: rootEnd }]); 81 | } else if ( 82 | (selectionFrom.line > contentStart.line || 83 | (selectionFrom.line == contentStart.line && 84 | selectionFrom.ch >= contentStart.ch)) && 85 | (selectionTo.line < contentEnd.line || 86 | (selectionTo.line == contentEnd.line && 87 | selectionTo.ch <= contentEnd.ch)) 88 | ) { 89 | // select whole line 90 | root.replaceSelections([{ anchor: contentStart, head: contentEnd }]); 91 | } else { 92 | this.stopPropagation = false; 93 | this.updated = false; 94 | return false; 95 | } 96 | 97 | return true; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/operations/__tests__/DeleteTillCurrentLineContentStart.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { DeleteTillCurrentLineContentStart } from "../DeleteTillCurrentLineContentStart"; 3 | 4 | test("should delete content from cursor to start of the line content and move cursor to content start", () => { 5 | const root = makeRoot({ 6 | editor: makeEditor({ 7 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 8 | cursor: { line: 1, ch: 5 }, 9 | }), 10 | settings: makeSettings(), 11 | }); 12 | 13 | const op = new DeleteTillCurrentLineContentStart(root); 14 | op.perform(); 15 | 16 | expect(root.print()).toBe( 17 | "- item 1\n- m 2\n - item 2.1\n - item 2.2\n- item 3", 18 | ); 19 | expect(root.getCursor().line).toBe(1); 20 | expect(root.getCursor().ch).toBe(2); 21 | }); 22 | 23 | test("should delete all content when cursor is at the end of line", () => { 24 | const root = makeRoot({ 25 | editor: makeEditor({ 26 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 27 | cursor: { line: 1, ch: 8 }, 28 | }), 29 | settings: makeSettings(), 30 | }); 31 | 32 | const op = new DeleteTillCurrentLineContentStart(root); 33 | op.perform(); 34 | 35 | expect(root.print()).toBe( 36 | "- item 1\n- \n - item 2.1\n - item 2.2\n- item 3", 37 | ); 38 | expect(root.getCursor().line).toBe(1); 39 | expect(root.getCursor().ch).toBe(2); 40 | }); 41 | 42 | test("should do nothing if cursor is already at the start of line content", () => { 43 | const root = makeRoot({ 44 | editor: makeEditor({ 45 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 46 | cursor: { line: 1, ch: 2 }, 47 | }), 48 | settings: makeSettings(), 49 | }); 50 | 51 | const op = new DeleteTillCurrentLineContentStart(root); 52 | op.perform(); 53 | 54 | expect(root.print()).toBe( 55 | "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3", 56 | ); 57 | expect(root.getCursor().line).toBe(1); 58 | expect(root.getCursor().ch).toBe(2); 59 | }); 60 | 61 | test("should not do anything if there are multiple selections", () => { 62 | const editor = makeEditor({ 63 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 64 | cursor: { line: 1, ch: 5 }, 65 | }); 66 | 67 | // Mock multiple selections 68 | editor.listSelections = () => [ 69 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 70 | { anchor: { line: 1, ch: 5 }, head: { line: 1, ch: 5 } }, 71 | ]; 72 | 73 | const root = makeRoot({ 74 | editor, 75 | settings: makeSettings(), 76 | }); 77 | 78 | const op = new DeleteTillCurrentLineContentStart(root); 79 | op.perform(); 80 | 81 | // Should not change the text 82 | expect(root.print()).toBe( 83 | "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3", 84 | ); 85 | }); 86 | 87 | test("should stop propagation and update editor", () => { 88 | const root = makeRoot({ 89 | editor: makeEditor({ 90 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 91 | cursor: { line: 1, ch: 5 }, 92 | }), 93 | settings: makeSettings(), 94 | }); 95 | 96 | const op = new DeleteTillCurrentLineContentStart(root); 97 | op.perform(); 98 | 99 | expect(op.shouldStopPropagation()).toBe(true); 100 | expect(op.shouldUpdate()).toBe(true); 101 | }); 102 | -------------------------------------------------------------------------------- /src/operations/DeleteTillPreviousLineContentEnd.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { 4 | List, 5 | ListLine, 6 | Position, 7 | Root, 8 | recalculateNumericBullets, 9 | } from "../root"; 10 | 11 | export class DeleteTillPreviousLineContentEnd implements Operation { 12 | private stopPropagation = false; 13 | private updated = false; 14 | 15 | constructor(private root: Root) {} 16 | 17 | shouldStopPropagation() { 18 | return this.stopPropagation; 19 | } 20 | 21 | shouldUpdate() { 22 | return this.updated; 23 | } 24 | 25 | perform() { 26 | const { root } = this; 27 | 28 | if (!root.hasSingleCursor()) { 29 | return; 30 | } 31 | 32 | const list = root.getListUnderCursor(); 33 | const cursor = root.getCursor(); 34 | const lines = list.getLinesInfo(); 35 | 36 | const lineNo = lines.findIndex( 37 | (l) => cursor.ch === l.from.ch && cursor.line === l.from.line, 38 | ); 39 | 40 | if (lineNo === 0) { 41 | this.mergeWithPreviousItem(root, cursor, list); 42 | } else if (lineNo > 0) { 43 | this.mergeNotes(root, cursor, list, lines, lineNo); 44 | } 45 | } 46 | 47 | private mergeNotes( 48 | root: Root, 49 | cursor: Position, 50 | list: List, 51 | lines: ListLine[], 52 | lineNo: number, 53 | ) { 54 | this.stopPropagation = true; 55 | this.updated = true; 56 | 57 | const prevLineNo = lineNo - 1; 58 | 59 | root.replaceCursor({ 60 | line: cursor.line - 1, 61 | ch: lines[prevLineNo].text.length + lines[prevLineNo].from.ch, 62 | }); 63 | 64 | lines[prevLineNo].text += lines[lineNo].text; 65 | lines.splice(lineNo, 1); 66 | 67 | list.replaceLines(lines.map((l) => l.text)); 68 | } 69 | 70 | private mergeWithPreviousItem(root: Root, cursor: Position, list: List) { 71 | if (root.getChildren()[0] === list && list.isEmpty()) { 72 | return; 73 | } 74 | 75 | this.stopPropagation = true; 76 | 77 | const prev = root.getListUnderLine(cursor.line - 1); 78 | 79 | if (!prev) { 80 | return; 81 | } 82 | 83 | const bothAreEmpty = prev.isEmpty() && list.isEmpty(); 84 | const prevIsEmptyAndSameLevel = 85 | prev.isEmpty() && !list.isEmpty() && prev.getLevel() === list.getLevel(); 86 | const listIsEmptyAndPrevIsParent = 87 | list.isEmpty() && prev.getLevel() === list.getLevel() - 1; 88 | 89 | if (bothAreEmpty || prevIsEmptyAndSameLevel || listIsEmptyAndPrevIsParent) { 90 | this.updated = true; 91 | 92 | const parent = list.getParent(); 93 | const prevEnd = prev.getLastLineContentEnd(); 94 | 95 | if (!prev.getNotesIndent() && list.getNotesIndent()) { 96 | prev.setNotesIndent( 97 | prev.getFirstLineIndent() + 98 | list.getNotesIndent().slice(list.getFirstLineIndent().length), 99 | ); 100 | } 101 | 102 | const oldLines = prev.getLines(); 103 | const newLines = list.getLines(); 104 | oldLines[oldLines.length - 1] += newLines[0]; 105 | const resultLines = oldLines.concat(newLines.slice(1)); 106 | 107 | prev.replaceLines(resultLines); 108 | parent.removeChild(list); 109 | 110 | for (const c of list.getChildren()) { 111 | list.removeChild(c); 112 | prev.addAfterAll(c); 113 | } 114 | 115 | root.replaceCursor(prevEnd); 116 | 117 | recalculateNumericBullets(root); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/root/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Position, isRangesIntersects, recalculateNumericBullets } from ".."; 2 | 3 | import { makeEditor, makeRoot } from "../../__mocks__"; 4 | 5 | function parseRanges(str: string): { 6 | a: [Position, Position]; 7 | b: [Position, Position]; 8 | } { 9 | return { 10 | a: [ 11 | { line: 0, ch: str.indexOf("[") }, 12 | { line: 0, ch: str.indexOf("]") }, 13 | ], 14 | b: [ 15 | { line: 0, ch: str.indexOf("(") }, 16 | { line: 0, ch: str.indexOf(")") }, 17 | ], 18 | }; 19 | } 20 | 21 | describe("isRangesIntersects", () => { 22 | const cases = [ 23 | ["[--(-]--)", true], 24 | ["(--[-)--]", true], 25 | ["[--(--)--]", true], 26 | ["(--[--]--)", true], 27 | ["[--](--)", false], 28 | ["(--)[--]", false], 29 | ]; 30 | 31 | test.each(cases)( 32 | "when ranges are '%s' then result is %s", 33 | (ranges: string, result: boolean) => { 34 | const { a, b } = parseRanges(ranges); 35 | 36 | expect(isRangesIntersects(a, b)).toBe(result); 37 | }, 38 | ); 39 | }); 40 | 41 | describe("recalculateNumericBullets", () => { 42 | test("should return list under line", () => { 43 | const root = makeRoot({ 44 | editor: makeEditor({ 45 | text: "4. one\n\t3. two\n\t2. three\n1. four", 46 | cursor: { line: 0, ch: 0 }, 47 | }), 48 | }); 49 | 50 | recalculateNumericBullets(root); 51 | 52 | expect(root.print()).toBe("1. one\n\t1. two\n\t2. three\n2. four"); 53 | }); 54 | }); 55 | 56 | describe("Root", () => { 57 | describe("getListUnderLine", () => { 58 | test("should return list under line", () => { 59 | const root = makeRoot({ 60 | editor: makeEditor({ 61 | text: "- one\n\t- two\n- three", 62 | cursor: { line: 0, ch: 0 }, 63 | }), 64 | }); 65 | 66 | const list = root.getListUnderLine(1); 67 | 68 | expect(list).toBeDefined(); 69 | expect(list.print()).toBe("\t- two\n"); 70 | }); 71 | 72 | test("should return list under line when line is note", () => { 73 | const root = makeRoot({ 74 | editor: makeEditor({ 75 | text: "- one\n\tnote1\n\t- two\n\t\tnote2\n- three", 76 | cursor: { line: 0, ch: 0 }, 77 | }), 78 | }); 79 | 80 | const list = root.getListUnderLine(3); 81 | 82 | expect(list).toBeDefined(); 83 | expect(list.print()).toBe("\t- two\n\t\tnote2\n"); 84 | }); 85 | }); 86 | 87 | describe("getContentLinesRangeOf", () => { 88 | test("should return range of list", () => { 89 | const root = makeRoot({ 90 | editor: makeEditor({ 91 | text: "- one\n\t- two\n- three", 92 | cursor: { line: 0, ch: 0 }, 93 | }), 94 | }); 95 | 96 | const range = root.getContentLinesRangeOf( 97 | root.getChildren()[0].getChildren()[0], 98 | ); 99 | 100 | expect(range).toStrictEqual([1, 1]); 101 | }); 102 | 103 | test("should return range of list when list has notes", () => { 104 | const root = makeRoot({ 105 | editor: makeEditor({ 106 | text: "- one\n\tnote1\n\t- two\n\t\tnote2\n- three", 107 | cursor: { line: 0, ch: 0 }, 108 | }), 109 | }); 110 | 111 | const range = root.getContentLinesRangeOf( 112 | root.getChildren()[0].getChildren()[0], 113 | ); 114 | 115 | expect(range).toStrictEqual([2, 3]); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/features/ReleaseNotesAnnouncement.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownRenderer, Modal, Plugin } from "obsidian"; 2 | 3 | import { Feature } from "./Feature"; 4 | 5 | import { Settings } from "../services/Settings"; 6 | 7 | class ReleaseNotesModal extends Modal { 8 | constructor( 9 | private plugin: Plugin, 10 | private title: string, 11 | private content: string, 12 | private cb: () => void, 13 | ) { 14 | super(plugin.app); 15 | } 16 | 17 | async onOpen() { 18 | this.titleEl.setText(this.title); 19 | 20 | MarkdownRenderer.renderMarkdown( 21 | this.content, 22 | this.contentEl, 23 | "", 24 | this.plugin, 25 | ); 26 | } 27 | 28 | onClose() { 29 | this.cb(); 30 | } 31 | } 32 | 33 | function compareReleases(a: string, b: string) { 34 | const [aMajor, aMinor, aPatch] = a.split(".", 3).map(Number); 35 | const [bMajor, bMinor, bPatch] = b.split(".", 3).map(Number); 36 | 37 | if (aMajor === bMajor) { 38 | if (aMinor === bMinor) { 39 | return aPatch - bPatch; 40 | } 41 | 42 | return aMinor - bMinor; 43 | } 44 | 45 | return aMajor - bMajor; 46 | } 47 | 48 | function parseChangelog() { 49 | const markdown = CHANGELOG_MD; 50 | const releaseNotes: [string, string][] = []; 51 | let version; 52 | let content = ""; 53 | 54 | for (const line of markdown.split("\n")) { 55 | const versionHeaderMatches = /^#+\s+(\d+\.\d+\.\d+)$/.exec(line); 56 | if (versionHeaderMatches) { 57 | if (version && content.trim().length > 0) { 58 | releaseNotes.push([version, content]); 59 | } 60 | version = versionHeaderMatches[1]; 61 | content = line; 62 | content += "\n"; 63 | } else { 64 | content += line; 65 | content += "\n"; 66 | } 67 | } 68 | 69 | if (version && content.trim().length > 0) { 70 | releaseNotes.push([version, content]); 71 | } 72 | 73 | return releaseNotes; 74 | } 75 | 76 | export class ReleaseNotesAnnouncement implements Feature { 77 | private modal: ReleaseNotesModal | null = null; 78 | 79 | constructor( 80 | private plugin: Plugin, 81 | private settings: Settings, 82 | ) {} 83 | 84 | async load() { 85 | this.plugin.addCommand({ 86 | id: "show-release-notes", 87 | name: "Show Release Notes", 88 | callback: this.showModal, 89 | }); 90 | 91 | this.showModal(this.settings.previousRelease); 92 | } 93 | 94 | async unload() { 95 | if (!this.modal) { 96 | return; 97 | } 98 | 99 | const modal = this.modal; 100 | this.modal = null; 101 | modal.close(); 102 | } 103 | 104 | private showModal = (previousRelease: string | null = null) => { 105 | let releaseNotes = ""; 106 | for (const [version, content] of parseChangelog()) { 107 | if (compareReleases(version, previousRelease || "0.0.0") > 0) { 108 | releaseNotes += content; 109 | } 110 | } 111 | 112 | if (releaseNotes.trim().length === 0) { 113 | return; 114 | } 115 | 116 | const modalTitle = `Welcome to Obsidian Outliner ${PLUGIN_VERSION}`; 117 | 118 | this.modal = new ReleaseNotesModal( 119 | this.plugin, 120 | modalTitle, 121 | releaseNotes, 122 | this.handleClose, 123 | ); 124 | this.modal.open(); 125 | }; 126 | 127 | private handleClose = async () => { 128 | if (!this.modal) { 129 | return; 130 | } 131 | 132 | this.settings.previousRelease = PLUGIN_VERSION; 133 | await this.settings.save(); 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /src/operations/__tests__/OutdentListIfItsEmpty.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { OutdentListIfItsEmpty } from "../OutdentListIfItsEmpty"; 3 | 4 | test("should outdent empty list item", () => { 5 | const root = makeRoot({ 6 | editor: makeEditor({ 7 | text: "- one\n - \n - three", 8 | cursor: { line: 1, ch: 4 }, 9 | }), 10 | settings: makeSettings(), 11 | }); 12 | 13 | const op = new OutdentListIfItsEmpty(root); 14 | op.perform(); 15 | 16 | expect(root.print()).toBe("- one\n- \n - three"); 17 | expect(op.shouldStopPropagation()).toBe(true); 18 | expect(op.shouldUpdate()).toBe(true); 19 | }); 20 | 21 | test("should outdent empty checkbox", () => { 22 | const root = makeRoot({ 23 | editor: makeEditor({ 24 | text: "- one\n - [ ] \n - three", 25 | cursor: { line: 1, ch: 8 }, 26 | }), 27 | settings: makeSettings(), 28 | }); 29 | 30 | const op = new OutdentListIfItsEmpty(root); 31 | op.perform(); 32 | 33 | expect(root.print()).toBe("- one\n- [ ] \n - three"); 34 | expect(op.shouldStopPropagation()).toBe(true); 35 | expect(op.shouldUpdate()).toBe(true); 36 | }); 37 | 38 | test("should not outdent non-empty list item", () => { 39 | const root = makeRoot({ 40 | editor: makeEditor({ 41 | text: "- one\n - two\n - three", 42 | cursor: { line: 1, ch: 6 }, 43 | }), 44 | settings: makeSettings(), 45 | }); 46 | 47 | const op = new OutdentListIfItsEmpty(root); 48 | op.perform(); 49 | 50 | expect(root.print()).toBe("- one\n - two\n - three"); 51 | expect(op.shouldStopPropagation()).toBe(false); 52 | expect(op.shouldUpdate()).toBe(false); 53 | }); 54 | 55 | test("should not outdent item with multiple lines", () => { 56 | const root = makeRoot({ 57 | editor: makeEditor({ 58 | text: "- one\n - \n note\n - three", 59 | cursor: { line: 1, ch: 4 }, 60 | }), 61 | settings: makeSettings(), 62 | }); 63 | 64 | const op = new OutdentListIfItsEmpty(root); 65 | op.perform(); 66 | 67 | expect(root.print()).toBe("- one\n - \n note\n - three"); 68 | expect(op.shouldStopPropagation()).toBe(false); 69 | expect(op.shouldUpdate()).toBe(false); 70 | }); 71 | 72 | test("should not outdent if list level is 1", () => { 73 | const root = makeRoot({ 74 | editor: makeEditor({ 75 | text: "- \n- two", 76 | cursor: { line: 0, ch: 2 }, 77 | }), 78 | settings: makeSettings(), 79 | }); 80 | 81 | const op = new OutdentListIfItsEmpty(root); 82 | op.perform(); 83 | 84 | expect(root.print()).toBe("- \n- two"); 85 | expect(op.shouldStopPropagation()).toBe(false); 86 | expect(op.shouldUpdate()).toBe(false); 87 | }); 88 | 89 | test("should not outdent if there are multiple selections", () => { 90 | const editor = makeEditor({ 91 | text: "- one\n - \n - three", 92 | cursor: { line: 1, ch: 4 }, 93 | }); 94 | 95 | // Mock multiple selections 96 | editor.listSelections = () => [ 97 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 98 | { anchor: { line: 1, ch: 4 }, head: { line: 1, ch: 4 } }, 99 | ]; 100 | 101 | const root = makeRoot({ 102 | editor, 103 | settings: makeSettings(), 104 | }); 105 | 106 | const op = new OutdentListIfItsEmpty(root); 107 | op.perform(); 108 | 109 | expect(root.print()).toBe("- one\n - \n - three"); 110 | expect(op.shouldStopPropagation()).toBe(false); 111 | expect(op.shouldUpdate()).toBe(false); 112 | }); 113 | -------------------------------------------------------------------------------- /src/logic/utils/__tests__/calculateVisibleContentBoundariesViolation.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state"; 2 | 3 | import { calculateVisibleContentBoundariesViolation } from "../calculateVisibleContentBoundariesViolation"; 4 | 5 | const state = EditorState.create({ doc: "line1\nline2\nline3" }); 6 | const hiddenRanges = [ 7 | { from: 0, to: 5 }, 8 | { from: 12, to: 17 }, 9 | ]; 10 | 11 | test("should calculate correctly when changes are touching area before visible content", () => { 12 | const tr = state.update({ changes: { from: 0, to: 1, insert: "X" } }); 13 | 14 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 15 | 16 | expect(res.touchedBefore).toBeTruthy(); 17 | expect(res.touchedAfter).toBeFalsy(); 18 | expect(res.touchedOutside).toBeTruthy(); 19 | expect(res.touchedInside).toBeFalsy(); 20 | }); 21 | 22 | test("should calculate correctly when changes are touching area after visible content", () => { 23 | const tr = state.update({ changes: { from: 12, to: 13, insert: "X" } }); 24 | 25 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 26 | 27 | expect(res.touchedBefore).toBeFalsy(); 28 | expect(res.touchedAfter).toBeTruthy(); 29 | expect(res.touchedOutside).toBeTruthy(); 30 | expect(res.touchedInside).toBeFalsy(); 31 | }); 32 | 33 | test("should calculate correctly when changes are touching visible content", () => { 34 | const tr = state.update({ changes: { from: 6, to: 7, insert: "X" } }); 35 | 36 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 37 | 38 | expect(res.touchedBefore).toBeFalsy(); 39 | expect(res.touchedAfter).toBeFalsy(); 40 | expect(res.touchedOutside).toBeFalsy(); 41 | expect(res.touchedInside).toBeTruthy(); 42 | }); 43 | 44 | test("should calculate correctly when changes are crossing first boundary of visible content", () => { 45 | const tr = state.update({ changes: { from: 4, to: 7, insert: "X" } }); 46 | 47 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 48 | 49 | expect(res.touchedBefore).toBeTruthy(); 50 | expect(res.touchedAfter).toBeFalsy(); 51 | expect(res.touchedOutside).toBeTruthy(); 52 | expect(res.touchedInside).toBeTruthy(); 53 | }); 54 | 55 | test("should calculate correctly when changes are crossing second boundary of visible content", () => { 56 | const tr = state.update({ changes: { from: 8, to: 13, insert: "X" } }); 57 | 58 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 59 | 60 | expect(res.touchedBefore).toBeFalsy(); 61 | expect(res.touchedAfter).toBeTruthy(); 62 | expect(res.touchedOutside).toBeTruthy(); 63 | expect(res.touchedInside).toBeTruthy(); 64 | }); 65 | 66 | test("should calculate correctly when changes are removing newline just before first boundary of visible content", () => { 67 | const tr = state.update({ changes: { from: 5, to: 6, insert: "" } }); 68 | 69 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 70 | 71 | expect(res.touchedBefore).toBeTruthy(); 72 | expect(res.touchedAfter).toBeFalsy(); 73 | expect(res.touchedOutside).toBeTruthy(); 74 | expect(res.touchedInside).toBeTruthy(); 75 | }); 76 | 77 | test("should calculate correctly when changes are removing newline just after second boundary of visible content", () => { 78 | const tr = state.update({ changes: { from: 11, to: 12, insert: "" } }); 79 | 80 | const res = calculateVisibleContentBoundariesViolation(tr, hiddenRanges); 81 | 82 | expect(res.touchedBefore).toBeFalsy(); 83 | expect(res.touchedAfter).toBeTruthy(); 84 | expect(res.touchedOutside).toBeTruthy(); 85 | expect(res.touchedInside).toBeTruthy(); 86 | }); 87 | -------------------------------------------------------------------------------- /src/logic/KeepOnlyZoomedContentVisible.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Extension, StateField } from "@codemirror/state"; 2 | import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; 3 | 4 | import { zoomInEffect, zoomOutEffect } from "./utils/effects"; 5 | import { rangeSetToArray } from "./utils/rangeSetToArray"; 6 | 7 | import { Logger } from "../services/Logger"; 8 | 9 | const zoomMarkHidden = Decoration.replace({ block: true }); 10 | 11 | const zoomStateField = StateField.define({ 12 | create: () => { 13 | return Decoration.none; 14 | }, 15 | 16 | update: (value, tr) => { 17 | value = value.map(tr.changes); 18 | 19 | for (const e of tr.effects) { 20 | if (e.is(zoomInEffect)) { 21 | value = value.update({ filter: () => false }); 22 | 23 | if (e.value.from > 0) { 24 | value = value.update({ 25 | add: [zoomMarkHidden.range(0, e.value.from - 1)], 26 | }); 27 | } 28 | 29 | if (e.value.to < tr.newDoc.length) { 30 | value = value.update({ 31 | add: [zoomMarkHidden.range(e.value.to + 1, tr.newDoc.length)], 32 | }); 33 | } 34 | } 35 | 36 | if (e.is(zoomOutEffect)) { 37 | value = value.update({ filter: () => false }); 38 | } 39 | } 40 | 41 | return value; 42 | }, 43 | 44 | provide: (zoomStateField) => EditorView.decorations.from(zoomStateField), 45 | }); 46 | 47 | export class KeepOnlyZoomedContentVisible { 48 | constructor(private logger: Logger) {} 49 | 50 | public getExtension(): Extension { 51 | return zoomStateField; 52 | } 53 | 54 | public calculateHiddenContentRanges(state: EditorState) { 55 | return rangeSetToArray(state.field(zoomStateField)); 56 | } 57 | 58 | public calculateVisibleContentRange(state: EditorState) { 59 | const hidden = this.calculateHiddenContentRanges(state); 60 | 61 | if (hidden.length === 1) { 62 | const [a] = hidden; 63 | 64 | if (a.from === 0) { 65 | return { from: a.to + 1, to: state.doc.length }; 66 | } else { 67 | return { from: 0, to: a.from - 1 }; 68 | } 69 | } 70 | 71 | if (hidden.length === 2) { 72 | const [a, b] = hidden; 73 | 74 | return { from: a.to + 1, to: b.from - 1 }; 75 | } 76 | 77 | return null; 78 | } 79 | 80 | public keepOnlyZoomedContentVisible( 81 | view: EditorView, 82 | from: number, 83 | to: number, 84 | options: { scrollIntoView?: boolean } = {}, 85 | ) { 86 | const { scrollIntoView } = { ...{ scrollIntoView: true }, ...options }; 87 | 88 | const effect = zoomInEffect.of({ from, to }); 89 | 90 | this.logger.log( 91 | "KeepOnlyZoomedContent:keepOnlyZoomedContentVisible", 92 | "keep only zoomed content visible", 93 | effect.value.from, 94 | effect.value.to, 95 | ); 96 | 97 | view.dispatch({ 98 | effects: [effect], 99 | }); 100 | 101 | if (scrollIntoView) { 102 | view.dispatch({ 103 | effects: [ 104 | EditorView.scrollIntoView(view.state.selection.main, { 105 | y: "start", 106 | }), 107 | ], 108 | }); 109 | } 110 | } 111 | 112 | public showAllContent(view: EditorView) { 113 | this.logger.log("KeepOnlyZoomedContent:showAllContent", "show all content"); 114 | 115 | view.dispatch({ effects: [zoomOutEffect.of()] }); 116 | view.dispatch({ 117 | effects: [ 118 | EditorView.scrollIntoView(view.state.selection.main, { 119 | y: "center", 120 | }), 121 | ], 122 | }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /specs/features/ListsMovementCommands.spec.md: -------------------------------------------------------------------------------- 1 | # obsidian-outliner:move-list-item-down should move line down 2 | 3 | - applyState: 4 | 5 | ```md 6 | - one| 7 | - two 8 | ``` 9 | 10 | - execute: `obsidian-outliner:move-list-item-down` 11 | - assertState: 12 | 13 | ```md 14 | - two 15 | - one| 16 | ``` 17 | 18 | # obsidian-outliner:move-list-item-down should move children down 19 | 20 | - applyState: 21 | 22 | ```md 23 | - one| 24 | - one one 25 | - two 26 | ``` 27 | 28 | - execute: `obsidian-outliner:move-list-item-down` 29 | - assertState: 30 | 31 | ```md 32 | - two 33 | - one| 34 | - one one 35 | ``` 36 | 37 | # obsidian-outliner:move-list-item-up should move line up 38 | 39 | - applyState: 40 | 41 | ```md 42 | - one 43 | - two| 44 | ``` 45 | 46 | - execute: `obsidian-outliner:move-list-item-up` 47 | - assertState: 48 | 49 | ```md 50 | - two| 51 | - one 52 | ``` 53 | 54 | # obsidian-outliner:move-list-item-up should move children up 55 | 56 | - applyState: 57 | 58 | ```md 59 | - two 60 | - one| 61 | - one one 62 | ``` 63 | 64 | - execute: `obsidian-outliner:move-list-item-up` 65 | - assertState: 66 | 67 | ```md 68 | - one| 69 | - one one 70 | - two 71 | ``` 72 | 73 | # obsidian-outliner:indent-list should indent line 74 | 75 | - applyState: 76 | 77 | ```md 78 | - qwe 79 | - qwe| 80 | ``` 81 | 82 | - execute: `obsidian-outliner:indent-list` 83 | - assertState: 84 | 85 | ```md 86 | - qwe 87 | - qwe| 88 | ``` 89 | 90 | # obsidian-outliner:indent-list should indent children 91 | 92 | - applyState: 93 | 94 | ```md 95 | - qwe 96 | - qwe| 97 | - qwe 98 | ``` 99 | 100 | - execute: `obsidian-outliner:indent-list` 101 | - assertState: 102 | 103 | ```md 104 | - qwe 105 | - qwe| 106 | - qwe 107 | ``` 108 | 109 | # obsidian-outliner:indent-list should not indent line if it's no parent 110 | 111 | - applyState: 112 | 113 | ```md 114 | - qwe 115 | - qwe| 116 | ``` 117 | 118 | - execute: `obsidian-outliner:indent-list` 119 | - assertState: 120 | 121 | ```md 122 | - qwe 123 | - qwe| 124 | ``` 125 | 126 | # obsidian-outliner:indent-list should keep cursor at the same text position 127 | 128 | - applyState: 129 | 130 | ```md 131 | - qwe 132 | - qwe 133 | - q|we 134 | ``` 135 | 136 | - execute: `obsidian-outliner:indent-list` 137 | - assertState: 138 | 139 | ```md 140 | - qwe 141 | - qwe 142 | - q|we 143 | ``` 144 | 145 | # obsidian-outliner:indent-list should keep numeration 146 | 147 | - applyState: 148 | 149 | ```md 150 | 1. one 151 | 1. two 152 | 2. three| 153 | 3. four 154 | ``` 155 | 156 | - execute: `obsidian-outliner:indent-list` 157 | - assertState: 158 | 159 | ```md 160 | 1. one 161 | 1. two 162 | 1. three| 163 | 2. four 164 | ``` 165 | 166 | # obsidian-outliner:outdent-list should outdent line 167 | 168 | - applyState: 169 | 170 | ```md 171 | - qwe 172 | - qwe| 173 | ``` 174 | 175 | - execute: `obsidian-outliner:outdent-list` 176 | - assertState: 177 | 178 | ```md 179 | - qwe 180 | - qwe| 181 | ``` 182 | 183 | # obsidian-outliner:outdent-list should outdent children 184 | 185 | - applyState: 186 | 187 | ```md 188 | - qwe 189 | - qwe| 190 | - qwe 191 | ``` 192 | 193 | - execute: `obsidian-outliner:outdent-list` 194 | - assertState: 195 | 196 | ```md 197 | - qwe 198 | - qwe| 199 | - qwe 200 | ``` 201 | 202 | # obsidian-outliner:outdent-list should outdent in case #144 203 | 204 | - applyState: 205 | 206 | ```md 207 | - qwe 208 | - qwe 209 | - qwe 210 | - qwe 211 | - qwe| 212 | ``` 213 | 214 | - execute: `obsidian-outliner:outdent-list` 215 | - assertState: 216 | 217 | ```md 218 | - qwe 219 | - qwe 220 | - qwe 221 | - qwe 222 | - qwe| 223 | ``` 224 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.6 (2025-12-09) 4 | 5 | ### New Features 6 | - **Style Settings integration** — Customize vertical indentation lines appearance via [Style Settings](https://github.com/mgmeyers/obsidian-style-settings) plugin (color, width, hover color, offsets, rounded ends, etc.) 7 | 8 | ### Bug Fixes 9 | - Fixed vertical lines click action not working with custom themes 10 | - Fixed double-click issue on vertical lines causing incorrect rendering 11 | - Fixed vertical lines appearing at wrong position on document open 12 | - Exposed zoom API for backward compatibility with internal components 13 | 14 | ### Known Issues 15 | - Vertical lines may occasionally render incorrectly after fold/unfold operations (click elsewhere to refresh) - fix in progress 16 | 17 | ## 1.0.5 (2025-12-08) 18 | 19 | ### Bug Fixes 20 | - Fixed vim O command inserting below instead of above when current item has nested children 21 | 22 | ## 1.0.4 (2025-12-07) 23 | 24 | ### Improvements 25 | - **Formatted breadcrumbs** - Wikilinks now display only their display text (e.g., `[[2025-12-05|Dec 5th]]` shows as `Dec 5th`) 26 | - **Clean breadcrumbs** - Removed Creases plugin fold markers (`%% fold %%`) and other Obsidian comments from breadcrumbs 27 | - **Block ID cleanup** - All block IDs (both plugin and native Obsidian format) are now hidden from breadcrumbs 28 | 29 | ## 1.0.3 (2025-12-07) 30 | 31 | ### Bug Fixes 32 | - Fixed expand selection to full list items when selecting from parent to child items 33 | - Fixed block IDs visibility in mirrored content when "Show block IDs" option is off 34 | - Fixed debug logs appearing when debug mode is disabled 35 | 36 | ### Improvements 37 | - Added "Paste as Block Link" command - pastes a wikilink to the original block 38 | - Added setting to toggle visibility of block IDs and mirror markers 39 | 40 | ## 1.0.2 (2025-12-07) 41 | 42 | ### Bug Fixes 43 | - Fixed mirror paste indentation when pasting at nested levels 44 | - Fixed sync duplicates issue when original content was updated 45 | - Fixed block ID adjacency issue (now requires space before `^`) 46 | 47 | ## 1.0.1 (2025-12-06) 48 | 49 | ### Features 50 | - **Linked Copies (Mirrors)** - Create synchronized copies of list items across documents 51 | - **i18n Support** - Added Russian language translations for all commands and settings 52 | - **Go to Original** command - Navigate from mirror to original block 53 | - **Break Mirror Link** command - Convert mirror to regular copy 54 | 55 | ### Bug Fixes 56 | - Fixed mirror markers visibility in editor 57 | - Fixed orphaned block ID cleanup when all mirrors are deleted 58 | 59 | ## 1.0.0 (2025-12-06) 60 | 61 | Initial release combining [Outliner](https://github.com/vslinko/obsidian-outliner) and [Zoom](https://github.com/vslinko/obsidian-zoom) into a single unified plugin. 62 | 63 | ### Features from Obsidian Outliner 64 | - Move lists up/down with keyboard shortcuts 65 | - Indent/outdent lists with Tab/Shift+Tab 66 | - Vertical indentation lines 67 | - Stick cursor to content 68 | - Enhanced Enter key behavior 69 | - Fold/unfold lists 70 | - Enhanced Ctrl+A/Cmd+A selection 71 | - Drag-and-Drop support 72 | - Better list styles 73 | 74 | ### Features from Obsidian Zoom 75 | - Zoom into headings and lists 76 | - Zoom out to entire document 77 | - Click-to-zoom on bullets 78 | - Breadcrumb navigation 79 | 80 | ### New Enhancements 81 | - **Auto-expand selection** - When selecting text across multiple bullets, selection automatically expands to cover full list items including children 82 | - **Zoom out one level** - New command (Cmd+Option+. / Ctrl+Alt+.) to step up one level at a time 83 | - **Compact breadcrumbs** - Collapsible breadcrumb with expand/collapse animation 84 | - **Hover breadcrumb expand** - Hover to preview full title, auto-collapse when clicking outside 85 | 86 | -------------------------------------------------------------------------------- /src/services/__tests__/ChangesApplicator.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { makeEditor, makeRoot } from "../../__mocks__"; 3 | import { MyEditor } from "../../editor"; 4 | import { List, Root } from "../../root"; 5 | import { ChangesApplicator } from "../ChangesApplicator"; 6 | 7 | describe("changesApplicator", () => { 8 | test("should not touch folded lists if they are not changed", () => { 9 | const { actions, editor, prevRoot, newRoot } = makeArgs({ 10 | editor: makeEditor({ 11 | text: ` 12 | - 1 13 | - 2 14 | - 3 15 | - [ ] 4 16 | - 5 17 | `, 18 | cursor: { line: 4, ch: 9 }, 19 | getAllFoldedLines: () => [2], 20 | }), 21 | 22 | changes: (root) => { 23 | root 24 | .getChildren()[0] 25 | .addAfterAll(new List(root, " ", "-", "[ ]", " ", "[ ] ", false)); 26 | root.replaceCursor({ line: 5, ch: 8 }); 27 | }, 28 | }); 29 | const changesApplicator = new ChangesApplicator(); 30 | 31 | changesApplicator.apply(editor, prevRoot, newRoot); 32 | 33 | expect(actions).toStrictEqual([ 34 | ["getRange", ...newRoot.getContentRange()], 35 | [ 36 | "replaceRange", 37 | " - [ ] 4\n - [ ] ", 38 | { line: 4, ch: 0 }, 39 | { line: 4, ch: 9 }, 40 | ], 41 | [ 42 | "setSelections", 43 | [{ anchor: { line: 5, ch: 8 }, head: { line: 5, ch: 8 } }], 44 | ], 45 | ]); 46 | }); 47 | 48 | test("should touch folded lists if they are changed", () => { 49 | const { actions, editor, prevRoot, newRoot } = makeArgs({ 50 | editor: makeEditor({ 51 | text: ` 52 | - 1 53 | - 2 54 | - 3 55 | - [ ] 4 56 | - 5 57 | `, 58 | cursor: { line: 5, ch: 3 }, 59 | getAllFoldedLines: () => [2], 60 | }), 61 | 62 | changes: (root) => { 63 | const list5 = root.getChildren()[1]; 64 | const list5Parent = list5.getParent(); 65 | list5Parent.removeChild(list5); 66 | list5Parent.addBeforeAll(list5); 67 | root.replaceCursor({ line: 1, ch: 3 }); 68 | }, 69 | }); 70 | const changesApplicator = new ChangesApplicator(); 71 | 72 | changesApplicator.apply(editor, prevRoot, newRoot); 73 | 74 | expect(actions).toStrictEqual([ 75 | ["getRange", ...newRoot.getContentRange()], 76 | ["unfold", 2], 77 | [ 78 | "replaceRange", 79 | "- 5\n- 1\n - 2\n - 3\n - [ ] 4", 80 | { line: 1, ch: 0 }, 81 | { line: 5, ch: 3 }, 82 | ], 83 | ["fold", 3], 84 | [ 85 | "setSelections", 86 | [{ anchor: { line: 1, ch: 3 }, head: { line: 1, ch: 3 } }], 87 | ], 88 | ]); 89 | }); 90 | }); 91 | 92 | function makeArgs(opts: { editor: MyEditor; changes: (root: Root) => void }) { 93 | const actions: any = []; 94 | const prevRoot = makeRoot({ 95 | editor: opts.editor, 96 | }); 97 | const newRoot = prevRoot.clone(); 98 | opts.changes(newRoot); 99 | const mockedEditor: MyEditor = { 100 | getRange: (...args: any[]) => { 101 | actions.push(["getRange", ...args]); 102 | return prevRoot.print(); 103 | }, 104 | unfold: (...args: any[]) => { 105 | actions.push(["unfold", ...args]); 106 | }, 107 | replaceRange: (...args: any[]) => { 108 | actions.push(["replaceRange", ...args]); 109 | }, 110 | setSelections: (...args: any[]) => { 111 | actions.push(["setSelections", ...args]); 112 | }, 113 | fold: (...args: any[]) => { 114 | actions.push(["fold", ...args]); 115 | }, 116 | } as any; 117 | 118 | return { 119 | actions, 120 | editor: mockedEditor, 121 | prevRoot, 122 | newRoot, 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /src/operations/__tests__/KeepCursorOutsideFoldedLines.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { KeepCursorOutsideFoldedLines } from "../KeepCursorOutsideFoldedLines"; 3 | 4 | test("should move cursor to the end of the first line if cursor is inside folded content", () => { 5 | const editor = makeEditor({ 6 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 7 | cursor: { line: 2, ch: 5 }, 8 | getAllFoldedLines: () => [0], 9 | }); 10 | 11 | const root = makeRoot({ 12 | editor, 13 | settings: makeSettings(), 14 | }); 15 | 16 | // Mock fold state 17 | const listUnderCursor = root.getListUnderCursor(); 18 | listUnderCursor.isFolded = () => true; 19 | 20 | const foldRoot = listUnderCursor; 21 | foldRoot.getLinesInfo = () => [ 22 | { 23 | text: "- item 1", 24 | from: { line: 0, ch: 0 }, 25 | to: { line: 0, ch: 7 }, 26 | }, 27 | ]; 28 | 29 | listUnderCursor.getTopFoldRoot = () => foldRoot; 30 | 31 | const op = new KeepCursorOutsideFoldedLines(root); 32 | op.perform(); 33 | 34 | expect(op.shouldStopPropagation()).toBe(true); 35 | expect(op.shouldUpdate()).toBe(true); 36 | expect(root.getCursor().line).toBe(0); 37 | expect(root.getCursor().ch).toBe(7); 38 | }); 39 | 40 | test("should not move cursor if it's not inside folded content", () => { 41 | const editor = makeEditor({ 42 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 43 | cursor: { line: 0, ch: 5 }, 44 | getAllFoldedLines: () => [0], 45 | }); 46 | 47 | const root = makeRoot({ 48 | editor, 49 | settings: makeSettings(), 50 | }); 51 | 52 | // Mock fold state 53 | const listUnderCursor = root.getListUnderCursor(); 54 | listUnderCursor.isFolded = () => true; 55 | 56 | const foldRoot = listUnderCursor; 57 | foldRoot.getLinesInfo = () => [ 58 | { 59 | text: "- item 1", 60 | from: { line: 0, ch: 0 }, 61 | to: { line: 0, ch: 7 }, 62 | }, 63 | ]; 64 | 65 | listUnderCursor.getTopFoldRoot = () => foldRoot; 66 | 67 | const op = new KeepCursorOutsideFoldedLines(root); 68 | op.perform(); 69 | 70 | expect(op.shouldStopPropagation()).toBe(false); 71 | expect(op.shouldUpdate()).toBe(false); 72 | expect(root.getCursor().line).toBe(0); 73 | expect(root.getCursor().ch).toBe(5); 74 | }); 75 | 76 | test("should not do anything if list is not folded", () => { 77 | const editor = makeEditor({ 78 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 79 | cursor: { line: 2, ch: 5 }, 80 | }); 81 | 82 | const root = makeRoot({ 83 | editor, 84 | settings: makeSettings(), 85 | }); 86 | 87 | // Mock fold state 88 | const listUnderCursor = root.getListUnderCursor(); 89 | listUnderCursor.isFolded = () => false; 90 | 91 | const op = new KeepCursorOutsideFoldedLines(root); 92 | op.perform(); 93 | 94 | expect(op.shouldStopPropagation()).toBe(false); 95 | expect(op.shouldUpdate()).toBe(false); 96 | }); 97 | 98 | test("should not do anything if there are multiple cursors", () => { 99 | const editor = makeEditor({ 100 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 101 | cursor: { line: 2, ch: 5 }, 102 | }); 103 | 104 | // Mock multiple cursors 105 | editor.listSelections = () => [ 106 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 107 | { anchor: { line: 2, ch: 5 }, head: { line: 2, ch: 5 } }, 108 | ]; 109 | 110 | const root = makeRoot({ 111 | editor, 112 | settings: makeSettings(), 113 | }); 114 | 115 | const op = new KeepCursorOutsideFoldedLines(root); 116 | op.perform(); 117 | 118 | expect(op.shouldStopPropagation()).toBe(false); 119 | expect(op.shouldUpdate()).toBe(false); 120 | }); 121 | -------------------------------------------------------------------------------- /src/operations/MoveListToDifferentPosition.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "./Operation"; 2 | 3 | import { List, Root, recalculateNumericBullets } from "../root"; 4 | 5 | interface CursorAnchor { 6 | cursorList: List; 7 | lineDiff: number; 8 | chDiff: number; 9 | } 10 | 11 | export class MoveListToDifferentPosition implements Operation { 12 | private stopPropagation = false; 13 | private updated = false; 14 | 15 | constructor( 16 | private root: Root, 17 | private listToMove: List, 18 | private placeToMove: List, 19 | private whereToMove: "before" | "after" | "inside", 20 | private defaultIndentChars: string, 21 | ) {} 22 | 23 | shouldStopPropagation() { 24 | return this.stopPropagation; 25 | } 26 | 27 | shouldUpdate() { 28 | return this.updated; 29 | } 30 | 31 | perform() { 32 | if (this.listToMove === this.placeToMove) { 33 | return; 34 | } 35 | 36 | this.stopPropagation = true; 37 | this.updated = true; 38 | 39 | const cursorAnchor = this.calculateCursorAnchor(); 40 | this.moveList(); 41 | this.changeIndent(); 42 | this.restoreCursor(cursorAnchor); 43 | recalculateNumericBullets(this.root); 44 | } 45 | 46 | private calculateCursorAnchor(): CursorAnchor { 47 | const cursorLine = this.root.getCursor().line; 48 | 49 | const lines = [ 50 | this.listToMove.getFirstLineContentStart().line, 51 | this.listToMove.getLastLineContentEnd().line, 52 | this.placeToMove.getFirstLineContentStart().line, 53 | this.placeToMove.getLastLineContentEnd().line, 54 | ]; 55 | const listStartLine = Math.min(...lines); 56 | const listEndLine = Math.max(...lines); 57 | 58 | if (cursorLine < listStartLine || cursorLine > listEndLine) { 59 | return null; 60 | } 61 | 62 | const cursor = this.root.getCursor(); 63 | const cursorList = this.root.getListUnderLine(cursor.line); 64 | const cursorListStart = cursorList.getFirstLineContentStart(); 65 | const lineDiff = cursor.line - cursorListStart.line; 66 | const chDiff = cursor.ch - cursorListStart.ch; 67 | 68 | return { cursorList, lineDiff, chDiff }; 69 | } 70 | 71 | private moveList() { 72 | this.listToMove.getParent().removeChild(this.listToMove); 73 | 74 | switch (this.whereToMove) { 75 | case "before": 76 | this.placeToMove 77 | .getParent() 78 | .addBefore(this.placeToMove, this.listToMove); 79 | break; 80 | 81 | case "after": 82 | this.placeToMove 83 | .getParent() 84 | .addAfter(this.placeToMove, this.listToMove); 85 | break; 86 | 87 | case "inside": 88 | this.placeToMove.addBeforeAll(this.listToMove); 89 | break; 90 | } 91 | } 92 | 93 | private changeIndent() { 94 | const oldIndent = this.listToMove.getFirstLineIndent(); 95 | const newIndent = 96 | this.whereToMove === "inside" 97 | ? this.placeToMove.getFirstLineIndent() + this.defaultIndentChars 98 | : this.placeToMove.getFirstLineIndent(); 99 | this.listToMove.unindentContent(0, oldIndent.length); 100 | this.listToMove.indentContent(0, newIndent); 101 | } 102 | 103 | private restoreCursor(cursorAnchor: CursorAnchor) { 104 | if (cursorAnchor) { 105 | const cursorListStart = 106 | cursorAnchor.cursorList.getFirstLineContentStart(); 107 | 108 | this.root.replaceCursor({ 109 | line: cursorListStart.line + cursorAnchor.lineDiff, 110 | ch: cursorListStart.ch + cursorAnchor.chDiff, 111 | }); 112 | } else { 113 | // When you move a list, the screen scrolls to the cursor. 114 | // It is better to move the cursor into the viewport than let the screen scroll. 115 | this.root.replaceCursor(this.listToMove.getLastLineContentEnd()); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/operations/__tests__/OutdentList.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { OutdentList } from "../OutdentList"; 3 | 4 | describe("OutdentList operation", () => { 5 | test("should outdent a list item to its parent's level", () => { 6 | const root = makeRoot({ 7 | editor: makeEditor({ 8 | text: "- parent\n - child\n - grandchild\n", 9 | cursor: { line: 2, ch: 9 }, 10 | }), 11 | settings: makeSettings(), 12 | }); 13 | 14 | const op = new OutdentList(root); 15 | op.perform(); 16 | 17 | expect(root.print()).toBe("- parent\n - child\n - grandchild"); 18 | expect(root.getCursor().line).toBe(2); 19 | expect(root.getCursor().ch).toBe(7); // cursor moves back by the indent difference 20 | }); 21 | 22 | test("should outdent a list item with its children", () => { 23 | const root = makeRoot({ 24 | editor: makeEditor({ 25 | text: "- parent\n - child\n - grandchild\n - great-grandchild\n", 26 | cursor: { line: 2, ch: 9 }, 27 | }), 28 | settings: makeSettings(), 29 | }); 30 | 31 | const op = new OutdentList(root); 32 | op.perform(); 33 | 34 | expect(root.print()).toBe( 35 | "- parent\n - child\n - grandchild\n - great-grandchild", 36 | ); 37 | expect(root.getCursor().line).toBe(2); 38 | expect(root.getCursor().ch).toBe(7); 39 | }); 40 | 41 | test("should not outdent a list item at the root level", () => { 42 | const root = makeRoot({ 43 | editor: makeEditor({ 44 | text: "- item 1\n- item 2\n- item 3\n", 45 | cursor: { line: 1, ch: 5 }, 46 | }), 47 | settings: makeSettings(), 48 | }); 49 | 50 | const op = new OutdentList(root); 51 | op.perform(); 52 | 53 | expect(root.print()).toBe("- item 1\n- item 2\n- item 3"); 54 | expect(root.getCursor().line).toBe(1); 55 | expect(root.getCursor().ch).toBe(5); 56 | expect(op.shouldUpdate()).toBe(false); 57 | }); 58 | 59 | test("should recalculate numeric bullets after outdention", () => { 60 | const root = makeRoot({ 61 | editor: makeEditor({ 62 | text: "1. parent\n 1. child\n 2. second child\n 1. grandchild\n", 63 | cursor: { line: 3, ch: 11 }, 64 | }), 65 | settings: makeSettings(), 66 | }); 67 | 68 | const op = new OutdentList(root); 69 | op.perform(); 70 | 71 | // The numeric bullet for the outdented item should be adjusted 72 | const result = root.print(); 73 | const lines = result.split("\n"); 74 | expect(lines[3].trim()).toMatch(/^3\. grandchild$/); 75 | }); 76 | 77 | test("should do nothing if there are multiple selections", () => { 78 | const editor = makeEditor({ 79 | text: "- parent\n - child\n - grandchild\n", 80 | cursor: { line: 2, ch: 9 }, 81 | }); 82 | 83 | // Mock multiple selections 84 | editor.listSelections = () => [ 85 | { anchor: { line: 1, ch: 3 }, head: { line: 1, ch: 3 } }, 86 | { anchor: { line: 2, ch: 9 }, head: { line: 2, ch: 9 } }, 87 | ]; 88 | 89 | const root = makeRoot({ 90 | editor, 91 | settings: makeSettings(), 92 | }); 93 | 94 | const op = new OutdentList(root); 95 | op.perform(); 96 | 97 | expect(root.print()).toBe("- parent\n - child\n - grandchild"); 98 | expect(op.shouldStopPropagation()).toBe(false); 99 | expect(op.shouldUpdate()).toBe(false); 100 | }); 101 | 102 | test("should stop propagation and update editor when successful", () => { 103 | const root = makeRoot({ 104 | editor: makeEditor({ 105 | text: "- parent\n - child\n - grandchild\n", 106 | cursor: { line: 2, ch: 9 }, 107 | }), 108 | settings: makeSettings(), 109 | }); 110 | 111 | const op = new OutdentList(root); 112 | op.perform(); 113 | 114 | expect(op.shouldStopPropagation()).toBe(true); 115 | expect(op.shouldUpdate()).toBe(true); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/services/ChangesApplicator.ts: -------------------------------------------------------------------------------- 1 | import { MyEditor } from "../editor"; 2 | import { List, Position, Root, isRangesIntersects } from "../root"; 3 | 4 | export class ChangesApplicator { 5 | apply(editor: MyEditor, prevRoot: Root, newRoot: Root) { 6 | const changes = this.calculateChanges(editor, prevRoot, newRoot); 7 | if (changes) { 8 | const { replacement, changeFrom, changeTo } = changes; 9 | 10 | const { unfold, fold } = this.calculateFoldingOprations( 11 | prevRoot, 12 | newRoot, 13 | changeFrom, 14 | changeTo, 15 | ); 16 | 17 | for (const line of unfold) { 18 | editor.unfold(line); 19 | } 20 | 21 | editor.replaceRange(replacement, changeFrom, changeTo); 22 | 23 | for (const line of fold) { 24 | editor.fold(line); 25 | } 26 | } 27 | 28 | editor.setSelections(newRoot.getSelections()); 29 | } 30 | 31 | private calculateChanges(editor: MyEditor, prevRoot: Root, newRoot: Root) { 32 | const rootRange = prevRoot.getContentRange(); 33 | const oldString = editor.getRange(rootRange[0], rootRange[1]); 34 | const newString = newRoot.print(); 35 | 36 | const changeFrom = { ...rootRange[0] }; 37 | const changeTo = { ...rootRange[1] }; 38 | let oldTmp = oldString; 39 | let newTmp = newString; 40 | 41 | while (true) { 42 | const nlIndex = oldTmp.lastIndexOf("\n"); 43 | 44 | if (nlIndex < 0) { 45 | break; 46 | } 47 | 48 | const oldLine = oldTmp.slice(nlIndex); 49 | const newLine = newTmp.slice(-oldLine.length); 50 | 51 | if (oldLine !== newLine) { 52 | break; 53 | } 54 | 55 | oldTmp = oldTmp.slice(0, -oldLine.length); 56 | newTmp = newTmp.slice(0, -oldLine.length); 57 | const nlIndex2 = oldTmp.lastIndexOf("\n"); 58 | changeTo.ch = 59 | nlIndex2 >= 0 ? oldTmp.length - nlIndex2 - 1 : oldTmp.length; 60 | changeTo.line--; 61 | } 62 | 63 | while (true) { 64 | const nlIndex = oldTmp.indexOf("\n"); 65 | 66 | if (nlIndex < 0) { 67 | break; 68 | } 69 | 70 | const oldLine = oldTmp.slice(0, nlIndex + 1); 71 | const newLine = newTmp.slice(0, oldLine.length); 72 | 73 | if (oldLine !== newLine) { 74 | break; 75 | } 76 | 77 | changeFrom.line++; 78 | oldTmp = oldTmp.slice(oldLine.length); 79 | newTmp = newTmp.slice(oldLine.length); 80 | } 81 | 82 | if (oldTmp === newTmp) { 83 | return null; 84 | } 85 | 86 | return { 87 | replacement: newTmp, 88 | changeFrom, 89 | changeTo, 90 | }; 91 | } 92 | 93 | private calculateFoldingOprations( 94 | prevRoot: Root, 95 | newRoot: Root, 96 | changeFrom: Position, 97 | changeTo: Position, 98 | ) { 99 | const changedRange: [Position, Position] = [changeFrom, changeTo]; 100 | 101 | const prevLists = getAllChildren(prevRoot); 102 | const newLists = getAllChildren(newRoot); 103 | 104 | const unfold: number[] = []; 105 | const fold: number[] = []; 106 | 107 | for (const prevList of prevLists.values()) { 108 | if (!prevList.isFoldRoot()) { 109 | continue; 110 | } 111 | 112 | const newList = newLists.get(prevList.getID()); 113 | 114 | if (!newList) { 115 | continue; 116 | } 117 | 118 | const prevListRange: [Position, Position] = [ 119 | prevList.getFirstLineContentStart(), 120 | prevList.getContentEndIncludingChildren(), 121 | ]; 122 | 123 | if (isRangesIntersects(prevListRange, changedRange)) { 124 | unfold.push(prevList.getFirstLineContentStart().line); 125 | fold.push(newList.getFirstLineContentStart().line); 126 | } 127 | } 128 | 129 | unfold.sort((a, b) => b - a); 130 | fold.sort((a, b) => b - a); 131 | 132 | return { unfold, fold }; 133 | } 134 | } 135 | 136 | function getAllChildrenReduceFn(acc: Map, child: List) { 137 | acc.set(child.getID(), child); 138 | child.getChildren().reduce(getAllChildrenReduceFn, acc); 139 | 140 | return acc; 141 | } 142 | 143 | function getAllChildren(root: Root): Map { 144 | return root.getChildren().reduce(getAllChildrenReduceFn, new Map()); 145 | } 146 | -------------------------------------------------------------------------------- /src/operations/__tests__/DeleteTillPreviousLineContentEnd.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { DeleteTillPreviousLineContentEnd } from "../DeleteTillPreviousLineContentEnd"; 3 | 4 | test("should merge current line with previous line when cursor is at start of line content", () => { 5 | const root = makeRoot({ 6 | editor: makeEditor({ 7 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 8 | cursor: { line: 2, ch: 6 }, 9 | }), 10 | settings: makeSettings(), 11 | }); 12 | 13 | const op = new DeleteTillPreviousLineContentEnd(root); 14 | op.perform(); 15 | 16 | expect(root.print()).toBe( 17 | "- item 1\n- item 2item 2.1\n - item 2.2\n- item 3", 18 | ); 19 | expect(root.getCursor().line).toBe(1); 20 | expect(root.getCursor().ch).toBe(8); 21 | }); 22 | 23 | test("should merge with previous note line", () => { 24 | const root = makeRoot({ 25 | editor: makeEditor({ 26 | text: "- item 1\n note for item 1\n more notes\n- item 2\n", 27 | cursor: { line: 2, ch: 2 }, 28 | }), 29 | settings: makeSettings(), 30 | }); 31 | 32 | const op = new DeleteTillPreviousLineContentEnd(root); 33 | op.perform(); 34 | 35 | expect(root.print()).toBe("- item 1\n note for item 1more notes\n- item 2"); 36 | expect(root.getCursor().line).toBe(1); 37 | expect(root.getCursor().ch).toBe(17); 38 | }); 39 | 40 | test("should merge empty bullets with previous bullet", () => { 41 | const root = makeRoot({ 42 | editor: makeEditor({ 43 | text: "- item 1\n- \n- item 3\n", 44 | cursor: { line: 1, ch: 2 }, 45 | }), 46 | settings: makeSettings(), 47 | }); 48 | 49 | const op = new DeleteTillPreviousLineContentEnd(root); 50 | op.perform(); 51 | 52 | expect(root.print()).toBe("- item 1\n- item 3"); 53 | expect(root.getCursor().line).toBe(0); 54 | expect(root.getCursor().ch).toBe(8); 55 | }); 56 | 57 | test("should merge child bullet with parent if child is empty", () => { 58 | const root = makeRoot({ 59 | editor: makeEditor({ 60 | text: "- item 1\n - \n- item 3\n", 61 | cursor: { line: 1, ch: 6 }, 62 | }), 63 | settings: makeSettings(), 64 | }); 65 | 66 | const op = new DeleteTillPreviousLineContentEnd(root); 67 | op.perform(); 68 | 69 | expect(root.print()).toBe("- item 1\n- item 3"); 70 | expect(root.getCursor().line).toBe(0); 71 | expect(root.getCursor().ch).toBe(8); 72 | }); 73 | 74 | test("should not do anything if there are multiple selections", () => { 75 | const editor = makeEditor({ 76 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 77 | cursor: { line: 2, ch: 6 }, 78 | }); 79 | 80 | // Mock multiple selections 81 | editor.listSelections = () => [ 82 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 83 | { anchor: { line: 2, ch: 6 }, head: { line: 2, ch: 6 } }, 84 | ]; 85 | 86 | const root = makeRoot({ 87 | editor, 88 | settings: makeSettings(), 89 | }); 90 | 91 | const op = new DeleteTillPreviousLineContentEnd(root); 92 | op.perform(); 93 | 94 | // Should not change the text 95 | expect(root.print()).toBe( 96 | "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3", 97 | ); 98 | }); 99 | 100 | test("should not merge the first item if it's the only one in the document", () => { 101 | const root = makeRoot({ 102 | editor: makeEditor({ 103 | text: "- item 1", 104 | cursor: { line: 0, ch: 2 }, 105 | }), 106 | settings: makeSettings(), 107 | }); 108 | 109 | const op = new DeleteTillPreviousLineContentEnd(root); 110 | op.perform(); 111 | 112 | expect(root.print()).toBe("- item 1"); 113 | expect(root.getCursor().line).toBe(0); 114 | expect(root.getCursor().ch).toBe(2); 115 | }); 116 | 117 | test("should stop propagation and update editor when merging", () => { 118 | const root = makeRoot({ 119 | editor: makeEditor({ 120 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 121 | cursor: { line: 2, ch: 6 }, 122 | }), 123 | settings: makeSettings(), 124 | }); 125 | 126 | const op = new DeleteTillPreviousLineContentEnd(root); 127 | op.perform(); 128 | 129 | expect(op.shouldStopPropagation()).toBe(true); 130 | expect(op.shouldUpdate()).toBe(true); 131 | }); 132 | -------------------------------------------------------------------------------- /src/logic/RenderNavigationHeader.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | 3 | import { StateEffect, StateField } from "@codemirror/state"; 4 | import { EditorView, showPanel } from "@codemirror/view"; 5 | 6 | import { renderHeader } from "./utils/renderHeader"; 7 | 8 | import { Logger } from "../services/Logger"; 9 | 10 | export interface Breadcrumb { 11 | title: string; 12 | pos: number | null; 13 | } 14 | 15 | export interface MirrorSource { 16 | file: TFile; 17 | blockId: string; 18 | fileName: string; 19 | } 20 | 21 | export interface ZoomIn { 22 | zoomIn(view: EditorView, pos: number): void; 23 | } 24 | 25 | export interface ZoomOut { 26 | zoomOut(view: EditorView): void; 27 | } 28 | 29 | export interface NavigateToFile { 30 | navigateToFile(file: TFile, blockId: string, pos?: number | null): void; 31 | } 32 | 33 | interface HeaderState { 34 | breadcrumbs: Breadcrumb[]; 35 | mirrorSource?: MirrorSource; 36 | onClick: (view: EditorView, pos: number | null) => void; 37 | onBreadcrumbClick?: (view: EditorView, pos: number | null) => void; 38 | } 39 | 40 | const showHeaderEffect = StateEffect.define(); 41 | const hideHeaderEffect = StateEffect.define(); 42 | 43 | const headerState = StateField.define({ 44 | create: () => null, 45 | update: (value, tr) => { 46 | for (const e of tr.effects) { 47 | if (e.is(showHeaderEffect)) { 48 | value = e.value; 49 | } 50 | if (e.is(hideHeaderEffect)) { 51 | value = null; 52 | } 53 | } 54 | return value; 55 | }, 56 | provide: (f) => 57 | showPanel.from(f, (state) => { 58 | if (!state) { 59 | return null; 60 | } 61 | 62 | return (view) => ({ 63 | top: true, 64 | dom: renderHeader(view.dom.ownerDocument, { 65 | breadcrumbs: state.breadcrumbs, 66 | mirrorSource: state.mirrorSource, 67 | onClick: (pos) => { 68 | // If mirrorSource exists, use onBreadcrumbClick (navigates to original) 69 | // Otherwise use normal onClick (zoom in current file) 70 | if (state.mirrorSource && state.onBreadcrumbClick) { 71 | state.onBreadcrumbClick(view, pos); 72 | } else { 73 | state.onClick(view, pos); 74 | } 75 | }, 76 | }), 77 | }); 78 | }), 79 | }); 80 | 81 | export class RenderNavigationHeader { 82 | private navigateToFile: NavigateToFile | null = null; 83 | 84 | getExtension() { 85 | return headerState; 86 | } 87 | 88 | constructor( 89 | private logger: Logger, 90 | private zoomIn: ZoomIn, 91 | private zoomOut: ZoomOut, 92 | ) {} 93 | 94 | public setNavigateToFile(navigateToFile: NavigateToFile) { 95 | this.navigateToFile = navigateToFile; 96 | } 97 | 98 | public showHeader( 99 | view: EditorView, 100 | breadcrumbs: Breadcrumb[], 101 | mirrorSource?: MirrorSource, 102 | ) { 103 | const l = this.logger.bind("ToggleNavigationHeaderLogic:showHeader"); 104 | l("show header", { mirrorSource: mirrorSource?.fileName }); 105 | 106 | view.dispatch({ 107 | effects: [ 108 | showHeaderEffect.of({ 109 | breadcrumbs, 110 | mirrorSource, 111 | onClick: this.onClick, 112 | onBreadcrumbClick: mirrorSource 113 | ? this.onBreadcrumbClickForMirror 114 | : undefined, 115 | }), 116 | ], 117 | }); 118 | } 119 | 120 | public hideHeader(view: EditorView) { 121 | const l = this.logger.bind("ToggleNavigationHeaderLogic:hideHeader"); 122 | l("hide header"); 123 | 124 | view.dispatch({ 125 | effects: [hideHeaderEffect.of()], 126 | }); 127 | } 128 | 129 | private onClick = (view: EditorView, pos: number | null) => { 130 | if (pos === null) { 131 | this.zoomOut.zoomOut(view); 132 | } else { 133 | this.zoomIn.zoomIn(view, pos); 134 | } 135 | }; 136 | 137 | private onBreadcrumbClickForMirror = ( 138 | view: EditorView, 139 | pos: number | null, 140 | ) => { 141 | const l = this.logger.bind( 142 | "ToggleNavigationHeaderLogic:onBreadcrumbClickForMirror", 143 | ); 144 | l("navigating to original", { pos }); 145 | 146 | // Get current mirror source from state 147 | const state = view.state.field(headerState); 148 | if (state?.mirrorSource && this.navigateToFile) { 149 | // Navigate to the original file at the specified position 150 | this.navigateToFile.navigateToFile( 151 | state.mirrorSource.file, 152 | state.mirrorSource.blockId, 153 | pos, 154 | ); 155 | } 156 | }; 157 | } 158 | -------------------------------------------------------------------------------- /src/operations/__tests__/MoveCursorToPreviousUnfoldedLine.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { MoveCursorToPreviousUnfoldedLine } from "../MoveCursorToPreviousUnfoldedLine"; 3 | 4 | test("should move cursor to end of previous note line in the same list", () => { 5 | const root = makeRoot({ 6 | editor: makeEditor({ 7 | text: "- item 1\n note for item 1\n more notes\n- item 2\n", 8 | cursor: { line: 2, ch: 2 }, 9 | }), 10 | settings: makeSettings(), 11 | }); 12 | 13 | const op = new MoveCursorToPreviousUnfoldedLine(root); 14 | op.perform(); 15 | 16 | expect(op.shouldStopPropagation()).toBe(true); 17 | expect(op.shouldUpdate()).toBe(true); 18 | expect(root.getCursor().line).toBe(1); 19 | expect(root.getCursor().ch).toBe(17); 20 | }); 21 | 22 | test("should move cursor to end of previous list item", () => { 23 | const root = makeRoot({ 24 | editor: makeEditor({ 25 | text: "- item 1\n- item 2\n- item 3\n", 26 | cursor: { line: 1, ch: 2 }, 27 | }), 28 | settings: makeSettings(), 29 | }); 30 | 31 | // Make sure cursor is at content start + checkbox length 32 | root.getListUnderCursor().getCheckboxLength = () => 0; 33 | 34 | const op = new MoveCursorToPreviousUnfoldedLine(root); 35 | op.perform(); 36 | 37 | expect(op.shouldStopPropagation()).toBe(true); 38 | expect(op.shouldUpdate()).toBe(true); 39 | expect(root.getCursor().line).toBe(0); 40 | expect(root.getCursor().ch).toBe(8); 41 | }); 42 | 43 | test("should move cursor to end of first line in previous folded list", () => { 44 | const editor = makeEditor({ 45 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2\n", 46 | cursor: { line: 3, ch: 2 }, 47 | getAllFoldedLines: () => [0], 48 | }); 49 | 50 | const root = makeRoot({ 51 | editor, 52 | settings: makeSettings(), 53 | }); 54 | 55 | // Setup previous list as folded 56 | const prevList = root.getListUnderLine(0); 57 | prevList.isFolded = () => true; 58 | 59 | const foldRoot = prevList; 60 | foldRoot.getLinesInfo = () => [ 61 | { 62 | text: "- item 1", 63 | from: { line: 0, ch: 0 }, 64 | to: { line: 0, ch: 8 }, 65 | }, 66 | ]; 67 | 68 | prevList.getTopFoldRoot = () => foldRoot; 69 | 70 | // Setup current list for correct cursor check 71 | root.getListUnderCursor().getCheckboxLength = () => 0; 72 | 73 | const op = new MoveCursorToPreviousUnfoldedLine(root); 74 | op.perform(); 75 | 76 | expect(op.shouldStopPropagation()).toBe(true); 77 | expect(op.shouldUpdate()).toBe(true); 78 | expect(root.getCursor().line).toBe(0); 79 | expect(root.getCursor().ch).toBe(8); 80 | }); 81 | 82 | test("should do nothing when cursor is not at the beginning of content", () => { 83 | const root = makeRoot({ 84 | editor: makeEditor({ 85 | text: "- item 1\n- item 2\n- item 3\n", 86 | cursor: { line: 1, ch: 5 }, 87 | }), 88 | settings: makeSettings(), 89 | }); 90 | 91 | const op = new MoveCursorToPreviousUnfoldedLine(root); 92 | op.perform(); 93 | 94 | expect(op.shouldStopPropagation()).toBe(false); 95 | expect(op.shouldUpdate()).toBe(false); 96 | expect(root.getCursor().line).toBe(1); 97 | expect(root.getCursor().ch).toBe(5); 98 | }); 99 | 100 | test("should do nothing when there is no previous line", () => { 101 | const root = makeRoot({ 102 | editor: makeEditor({ 103 | text: "- item 1\n- item 2\n- item 3\n", 104 | cursor: { line: 0, ch: 2 }, 105 | }), 106 | settings: makeSettings(), 107 | }); 108 | 109 | root.getListUnderCursor().getCheckboxLength = () => 0; 110 | 111 | const op = new MoveCursorToPreviousUnfoldedLine(root); 112 | op.perform(); 113 | 114 | expect(op.shouldStopPropagation()).toBe(false); 115 | expect(op.shouldUpdate()).toBe(false); 116 | expect(root.getCursor().line).toBe(0); 117 | expect(root.getCursor().ch).toBe(2); 118 | }); 119 | 120 | test("should do nothing when there are multiple selections", () => { 121 | const editor = makeEditor({ 122 | text: "- item 1\n- item 2\n- item 3\n", 123 | cursor: { line: 1, ch: 2 }, 124 | }); 125 | 126 | // Mock multiple selections 127 | editor.listSelections = () => [ 128 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 129 | { anchor: { line: 1, ch: 2 }, head: { line: 1, ch: 2 } }, 130 | ]; 131 | 132 | const root = makeRoot({ 133 | editor, 134 | settings: makeSettings(), 135 | }); 136 | 137 | root.getListUnderCursor().getCheckboxLength = () => 0; 138 | 139 | const op = new MoveCursorToPreviousUnfoldedLine(root); 140 | op.perform(); 141 | 142 | expect(op.shouldStopPropagation()).toBe(false); 143 | expect(op.shouldUpdate()).toBe(false); 144 | }); 145 | -------------------------------------------------------------------------------- /src/features/VimOBehaviourOverride.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, Notice, Plugin } from "obsidian"; 2 | 3 | import { MyEditor } from "src/editor"; 4 | import { CreateNewItem } from "src/operations/CreateNewItem"; 5 | import { ObsidianSettings } from "src/services/ObsidianSettings"; 6 | import { OperationPerformer } from "src/services/OperationPerformer"; 7 | import { Parser } from "src/services/Parser"; 8 | import { Settings } from "src/services/Settings"; 9 | 10 | import { Feature } from "./Feature"; 11 | 12 | declare global { 13 | type CM = object; 14 | 15 | interface Vim { 16 | defineAction(name: string, fn: (cm: CM, args: T) => void): void; 17 | 18 | handleEx(cm: CM, command: string): void; 19 | 20 | enterInsertMode(cm: CM): void; 21 | 22 | mapCommand( 23 | keys: string, 24 | type: string, 25 | name: string, 26 | args: Record, 27 | extra: Record, 28 | ): void; 29 | } 30 | 31 | interface Window { 32 | CodeMirrorAdapter?: { 33 | Vim?: Vim; 34 | }; 35 | } 36 | } 37 | 38 | export class VimOBehaviourOverride implements Feature { 39 | private inited = false; 40 | 41 | constructor( 42 | private plugin: Plugin, 43 | private settings: Settings, 44 | private obsidianSettings: ObsidianSettings, 45 | private parser: Parser, 46 | private operationPerformer: OperationPerformer, 47 | ) {} 48 | 49 | async load() { 50 | this.settings.onChange(this.handleSettingsChange); 51 | this.handleSettingsChange(); 52 | } 53 | 54 | private handleSettingsChange = () => { 55 | if (!this.settings.overrideVimOBehaviour) { 56 | return; 57 | } 58 | 59 | if (!window.CodeMirrorAdapter || !window.CodeMirrorAdapter.Vim) { 60 | console.error("Vim adapter not found"); 61 | return; 62 | } 63 | 64 | const vim = window.CodeMirrorAdapter.Vim; 65 | const plugin = this.plugin; 66 | const parser = this.parser; 67 | const obsidianSettings = this.obsidianSettings; 68 | const operationPerformer = this.operationPerformer; 69 | const settings = this.settings; 70 | 71 | vim.defineAction( 72 | "insertLineAfterBullet", 73 | (cm, operatorArgs: { after: boolean }) => { 74 | // Move the cursor to the end of the line 75 | vim.handleEx(cm, "normal! A"); 76 | 77 | if (!settings.overrideVimOBehaviour) { 78 | if (operatorArgs.after) { 79 | vim.handleEx(cm, "normal! o"); 80 | } else { 81 | vim.handleEx(cm, "normal! O"); 82 | } 83 | vim.enterInsertMode(cm); 84 | return; 85 | } 86 | 87 | const view = plugin.app.workspace.getActiveViewOfType(MarkdownView); 88 | const editor = new MyEditor(view.editor); 89 | const root = parser.parse(editor); 90 | 91 | if (!root) { 92 | if (operatorArgs.after) { 93 | vim.handleEx(cm, "normal! o"); 94 | } else { 95 | vim.handleEx(cm, "normal! O"); 96 | } 97 | vim.enterInsertMode(cm); 98 | return; 99 | } 100 | 101 | const defaultIndentChars = obsidianSettings.getDefaultIndentChars(); 102 | const zoomRange = editor.getZoomRange(); 103 | const getZoomRange = { 104 | getZoomRange: () => zoomRange, 105 | }; 106 | 107 | const res = operationPerformer.eval( 108 | root, 109 | new CreateNewItem( 110 | root, 111 | defaultIndentChars, 112 | getZoomRange, 113 | operatorArgs.after, 114 | ), 115 | editor, 116 | ); 117 | 118 | if (res.shouldUpdate && zoomRange) { 119 | editor.tryRefreshZoom(zoomRange.from.line); 120 | } 121 | 122 | // Ensure the editor is always left in insert mode 123 | vim.enterInsertMode(cm); 124 | }, 125 | ); 126 | 127 | vim.mapCommand( 128 | "o", 129 | "action", 130 | "insertLineAfterBullet", 131 | {}, 132 | { 133 | isEdit: true, 134 | context: "normal", 135 | interlaceInsertRepeat: true, 136 | actionArgs: { after: true }, 137 | }, 138 | ); 139 | 140 | vim.mapCommand( 141 | "O", 142 | "action", 143 | "insertLineAfterBullet", 144 | {}, 145 | { 146 | isEdit: true, 147 | context: "normal", 148 | interlaceInsertRepeat: true, 149 | actionArgs: { after: false }, 150 | }, 151 | ); 152 | 153 | this.inited = true; 154 | }; 155 | 156 | async unload() { 157 | if (!this.inited) { 158 | return; 159 | } 160 | 161 | new Notice( 162 | `To fully unload obsidian-outliner plugin, please restart the app`, 163 | 5000, 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/operations/__tests__/MoveListUp.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { MoveListUp } from "../MoveListUp"; 3 | 4 | describe("MoveListUp operation", () => { 5 | test("should move a list item up before its sibling", () => { 6 | const root = makeRoot({ 7 | editor: makeEditor({ 8 | text: "- item 1\n- item 2\n- item 3\n", 9 | cursor: { line: 2, ch: 5 }, 10 | }), 11 | settings: makeSettings(), 12 | }); 13 | 14 | const op = new MoveListUp(root); 15 | op.perform(); 16 | 17 | expect(root.print()).toBe("- item 1\n- item 3\n- item 2"); 18 | expect(root.getCursor().line).toBe(1); 19 | expect(root.getCursor().ch).toBe(5); 20 | }); 21 | 22 | test("should move a list item up to previous parent's level if it's the first child", () => { 23 | const root = makeRoot({ 24 | editor: makeEditor({ 25 | text: "- item 1\n- item 2\n - item 2.1\n - item 2.2\n- item 3\n", 26 | cursor: { line: 3, ch: 7 }, 27 | }), 28 | settings: makeSettings(), 29 | }); 30 | 31 | const op = new MoveListUp(root); 32 | op.perform(); 33 | 34 | expect(root.print()).toBe( 35 | "- item 1\n- item 2\n - item 2.2\n - item 2.1\n- item 3", 36 | ); 37 | expect(root.getCursor().line).toBe(2); 38 | expect(root.getCursor().ch).toBe(7); 39 | }); 40 | 41 | test("should not move a list item up if it's the first item in the list", () => { 42 | const root = makeRoot({ 43 | editor: makeEditor({ 44 | text: "- item 1\n- item 2\n- item 3\n", 45 | cursor: { line: 0, ch: 5 }, 46 | }), 47 | settings: makeSettings(), 48 | }); 49 | 50 | const op = new MoveListUp(root); 51 | op.perform(); 52 | 53 | expect(root.print()).toBe("- item 1\n- item 2\n- item 3"); 54 | expect(root.getCursor().line).toBe(0); 55 | expect(root.getCursor().ch).toBe(5); 56 | }); 57 | 58 | test("should move a list item up to the previous parent item if it's the first child", () => { 59 | const root = makeRoot({ 60 | editor: makeEditor({ 61 | text: "- item 1\n - item 1.1\n- item 2\n - item 2.1\n", 62 | cursor: { line: 3, ch: 7 }, 63 | }), 64 | settings: makeSettings(), 65 | }); 66 | 67 | const op = new MoveListUp(root); 68 | op.perform(); 69 | 70 | expect(root.print()).toBe("- item 1\n - item 1.1\n - item 2.1\n- item 2"); 71 | expect(root.getCursor().line).toBe(2); 72 | expect(root.getCursor().ch).toBe(7); 73 | }); 74 | 75 | test("should recalculate numeric bullets after moving", () => { 76 | const root = makeRoot({ 77 | editor: makeEditor({ 78 | text: "1. item 1\n2. item 2\n3. item 3\n", 79 | cursor: { line: 2, ch: 5 }, 80 | }), 81 | settings: makeSettings(), 82 | }); 83 | 84 | const op = new MoveListUp(root); 85 | op.perform(); 86 | 87 | expect(root.print()).toBe("1. item 1\n2. item 3\n3. item 2"); 88 | expect(root.getCursor().line).toBe(1); 89 | expect(root.getCursor().ch).toBe(5); 90 | }); 91 | 92 | test("should not do anything if there are multiple selections", () => { 93 | const editor = makeEditor({ 94 | text: "- item 1\n- item 2\n- item 3\n", 95 | cursor: { line: 2, ch: 5 }, 96 | }); 97 | 98 | // Mock multiple selections 99 | editor.listSelections = () => [ 100 | { anchor: { line: 1, ch: 3 }, head: { line: 1, ch: 3 } }, 101 | { anchor: { line: 2, ch: 5 }, head: { line: 2, ch: 5 } }, 102 | ]; 103 | 104 | const root = makeRoot({ 105 | editor, 106 | settings: makeSettings(), 107 | }); 108 | 109 | const op = new MoveListUp(root); 110 | op.perform(); 111 | 112 | expect(root.print()).toBe("- item 1\n- item 2\n- item 3"); 113 | }); 114 | 115 | test("should stop propagation and update editor when successful", () => { 116 | const root = makeRoot({ 117 | editor: makeEditor({ 118 | text: "- item 1\n- item 2\n- item 3\n", 119 | cursor: { line: 2, ch: 5 }, 120 | }), 121 | settings: makeSettings(), 122 | }); 123 | 124 | const op = new MoveListUp(root); 125 | op.perform(); 126 | 127 | expect(op.shouldStopPropagation()).toBe(true); 128 | expect(op.shouldUpdate()).toBe(true); 129 | }); 130 | 131 | test("should stop propagation but not update editor when operation fails", () => { 132 | const root = makeRoot({ 133 | editor: makeEditor({ 134 | text: "- item 1\n- item 2\n- item 3\n", 135 | cursor: { line: 0, ch: 5 }, 136 | }), 137 | settings: makeSettings(), 138 | }); 139 | 140 | const op = new MoveListUp(root); 141 | op.perform(); 142 | 143 | expect(op.shouldStopPropagation()).toBe(true); 144 | expect(op.shouldUpdate()).toBe(false); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/editor/index.ts: -------------------------------------------------------------------------------- 1 | import { Editor, editorInfoField } from "obsidian"; 2 | 3 | import { 4 | foldEffect, 5 | foldable, 6 | foldedRanges, 7 | unfoldEffect, 8 | } from "@codemirror/language"; 9 | import { EditorState } from "@codemirror/state"; 10 | import { EditorView, runScopeHandlers } from "@codemirror/view"; 11 | 12 | export class MyEditorPosition { 13 | line: number; 14 | ch: number; 15 | } 16 | 17 | export class MyEditorRange { 18 | from: MyEditorPosition; 19 | to: MyEditorPosition; 20 | } 21 | 22 | export class MyEditorSelection { 23 | anchor: MyEditorPosition; 24 | head: MyEditorPosition; 25 | } 26 | 27 | export function getEditorFromState(state: EditorState) { 28 | const { editor } = state.field(editorInfoField); 29 | 30 | if (!editor) { 31 | return null; 32 | } 33 | 34 | return new MyEditor(editor); 35 | } 36 | 37 | declare global { 38 | interface Window { 39 | ObsidianZoomPlugin?: { 40 | getZoomRange(e: Editor): MyEditorRange; 41 | zoomOut(e: Editor): void; 42 | zoomIn(e: Editor, line: number): void; 43 | refreshZoom?(e: Editor): void; 44 | }; 45 | } 46 | } 47 | 48 | function foldInside(view: EditorView, from: number, to: number) { 49 | let found: { from: number; to: number } | null = null; 50 | foldedRanges(view.state).between(from, to, (from, to) => { 51 | if (!found || found.from > from) found = { from, to }; 52 | }); 53 | return found; 54 | } 55 | 56 | export class MyEditor { 57 | private view: EditorView; 58 | 59 | constructor(private e: Editor) { 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | this.view = (this.e as any).cm; 62 | } 63 | 64 | getCursor(): MyEditorPosition { 65 | return this.e.getCursor(); 66 | } 67 | 68 | getLine(n: number): string { 69 | return this.e.getLine(n); 70 | } 71 | 72 | lastLine(): number { 73 | return this.e.lastLine(); 74 | } 75 | 76 | listSelections(): MyEditorSelection[] { 77 | return this.e.listSelections(); 78 | } 79 | 80 | getRange(from: MyEditorPosition, to: MyEditorPosition): string { 81 | return this.e.getRange(from, to); 82 | } 83 | 84 | replaceRange( 85 | replacement: string, 86 | from: MyEditorPosition, 87 | to: MyEditorPosition, 88 | ): void { 89 | return this.e.replaceRange(replacement, from, to); 90 | } 91 | 92 | setSelections(selections: MyEditorSelection[]): void { 93 | this.e.setSelections(selections); 94 | } 95 | 96 | setValue(text: string): void { 97 | this.e.setValue(text); 98 | } 99 | 100 | getValue(): string { 101 | return this.e.getValue(); 102 | } 103 | 104 | offsetToPos(offset: number): MyEditorPosition { 105 | return this.e.offsetToPos(offset); 106 | } 107 | 108 | posToOffset(pos: MyEditorPosition): number { 109 | return this.e.posToOffset(pos); 110 | } 111 | 112 | fold(n: number): void { 113 | const { view } = this; 114 | const l = view.lineBlockAt(view.state.doc.line(n + 1).from); 115 | const range = foldable(view.state, l.from, l.to); 116 | 117 | if (!range || range.from === range.to) { 118 | return; 119 | } 120 | 121 | view.dispatch({ effects: [foldEffect.of(range)] }); 122 | } 123 | 124 | unfold(n: number): void { 125 | const { view } = this; 126 | const l = view.lineBlockAt(view.state.doc.line(n + 1).from); 127 | const range = foldInside(view, l.from, l.to); 128 | 129 | if (!range) { 130 | return; 131 | } 132 | 133 | view.dispatch({ effects: [unfoldEffect.of(range)] }); 134 | } 135 | 136 | getAllFoldedLines(): number[] { 137 | const c = foldedRanges(this.view.state).iter(); 138 | const res: number[] = []; 139 | while (c.value) { 140 | res.push(this.offsetToPos(c.from).line); 141 | c.next(); 142 | } 143 | return res; 144 | } 145 | 146 | triggerOnKeyDown(e: KeyboardEvent): void { 147 | runScopeHandlers(this.view, e, "editor"); 148 | } 149 | 150 | getZoomRange(): MyEditorRange | null { 151 | if (!window.ObsidianZoomPlugin) { 152 | return null; 153 | } 154 | 155 | return window.ObsidianZoomPlugin.getZoomRange(this.e); 156 | } 157 | 158 | zoomOut() { 159 | if (!window.ObsidianZoomPlugin) { 160 | return; 161 | } 162 | 163 | window.ObsidianZoomPlugin.zoomOut(this.e); 164 | } 165 | 166 | zoomIn(line: number) { 167 | if (!window.ObsidianZoomPlugin) { 168 | return; 169 | } 170 | 171 | window.ObsidianZoomPlugin.zoomIn(this.e, line); 172 | } 173 | 174 | tryRefreshZoom(line: number) { 175 | if (!window.ObsidianZoomPlugin) { 176 | return; 177 | } 178 | 179 | if (window.ObsidianZoomPlugin.refreshZoom) { 180 | window.ObsidianZoomPlugin.refreshZoom(this.e); 181 | } else { 182 | window.ObsidianZoomPlugin.zoomIn(this.e, line); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/operations/__tests__/KeepCursorWithinListContent.test.ts: -------------------------------------------------------------------------------- 1 | import { makeEditor, makeRoot, makeSettings } from "../../__mocks__"; 2 | import { KeepCursorWithinListContent } from "../KeepCursorWithinListContent"; 3 | 4 | test("should move cursor to the start of content if cursor is before content start", () => { 5 | const root = makeRoot({ 6 | editor: makeEditor({ 7 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 8 | cursor: { line: 0, ch: 0 }, // Cursor before the bullet 9 | }), 10 | settings: makeSettings(), 11 | }); 12 | 13 | const op = new KeepCursorWithinListContent(root); 14 | op.perform(); 15 | 16 | expect(op.shouldStopPropagation()).toBe(true); 17 | expect(op.shouldUpdate()).toBe(true); 18 | expect(root.getCursor().line).toBe(0); 19 | expect(root.getCursor().ch).toBe(2); // At the start of content after bullet 20 | }); 21 | 22 | test("should move cursor to the start of content if cursor is on the bullet", () => { 23 | const root = makeRoot({ 24 | editor: makeEditor({ 25 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 26 | cursor: { line: 0, ch: 1 }, // Cursor on the bullet 27 | }), 28 | settings: makeSettings(), 29 | }); 30 | 31 | const op = new KeepCursorWithinListContent(root); 32 | op.perform(); 33 | 34 | expect(op.shouldStopPropagation()).toBe(true); 35 | expect(op.shouldUpdate()).toBe(true); 36 | expect(root.getCursor().line).toBe(0); 37 | expect(root.getCursor().ch).toBe(2); // At the start of content after bullet 38 | }); 39 | 40 | test("should mock getFirstLineContentStartAfterCheckbox appropriately", () => { 41 | const root = makeRoot({ 42 | editor: makeEditor({ 43 | text: "- [ ] task with checkbox\n - item 1.1\n - item 1.2\n- item 2", 44 | cursor: { line: 0, ch: 3 }, // Cursor inside the checkbox 45 | }), 46 | settings: makeSettings(), 47 | }); 48 | 49 | // Mock the getFirstLineContentStartAfterCheckbox method 50 | const listUnderCursor = root.getListUnderCursor(); 51 | const originalMethod = listUnderCursor.getFirstLineContentStartAfterCheckbox; 52 | listUnderCursor.getFirstLineContentStartAfterCheckbox = jest 53 | .fn() 54 | .mockReturnValue({ 55 | line: 0, 56 | ch: 6, 57 | }); 58 | 59 | const op = new KeepCursorWithinListContent(root); 60 | op.perform(); 61 | 62 | expect( 63 | listUnderCursor.getFirstLineContentStartAfterCheckbox, 64 | ).toHaveBeenCalled(); 65 | expect(op.shouldStopPropagation()).toBe(true); 66 | expect(op.shouldUpdate()).toBe(true); 67 | expect(root.getCursor().line).toBe(0); 68 | expect(root.getCursor().ch).toBe(6); // The mocked position after checkbox 69 | 70 | // Restore the original method 71 | listUnderCursor.getFirstLineContentStartAfterCheckbox = originalMethod; 72 | }); 73 | 74 | test("should move cursor to the start of indented notes content if cursor is before note indent", () => { 75 | const root = makeRoot({ 76 | editor: makeEditor({ 77 | text: "- item 1\n note line\n another note\n- item 2", 78 | cursor: { line: 1, ch: 0 }, // Cursor before note indent 79 | }), 80 | settings: makeSettings(), 81 | }); 82 | 83 | const op = new KeepCursorWithinListContent(root); 84 | op.perform(); 85 | 86 | expect(op.shouldStopPropagation()).toBe(true); 87 | expect(op.shouldUpdate()).toBe(true); 88 | expect(root.getCursor().line).toBe(1); 89 | expect(root.getCursor().ch).toBe(2); // At the start of note's indentation 90 | }); 91 | 92 | test("should not do anything if cursor is already within content", () => { 93 | const root = makeRoot({ 94 | editor: makeEditor({ 95 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 96 | cursor: { line: 0, ch: 5 }, // Cursor within content 97 | }), 98 | settings: makeSettings(), 99 | }); 100 | 101 | const op = new KeepCursorWithinListContent(root); 102 | op.perform(); 103 | 104 | expect(op.shouldStopPropagation()).toBe(false); 105 | expect(op.shouldUpdate()).toBe(false); 106 | expect(root.getCursor().line).toBe(0); 107 | expect(root.getCursor().ch).toBe(5); // Unchanged 108 | }); 109 | 110 | test("should not do anything if there are multiple cursors", () => { 111 | const editor = makeEditor({ 112 | text: "- item 1\n - item 1.1\n - item 1.2\n- item 2", 113 | cursor: { line: 0, ch: 0 }, 114 | }); 115 | 116 | // Mock multiple cursors 117 | editor.listSelections = () => [ 118 | { anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 0 } }, 119 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 0 } }, 120 | ]; 121 | 122 | const root = makeRoot({ 123 | editor, 124 | settings: makeSettings(), 125 | }); 126 | 127 | const op = new KeepCursorWithinListContent(root); 128 | op.perform(); 129 | 130 | expect(op.shouldStopPropagation()).toBe(false); 131 | expect(op.shouldUpdate()).toBe(false); 132 | }); 133 | --------------------------------------------------------------------------------