├── .npmrc ├── .eslintignore ├── tests ├── __mocks__ │ ├── main.ts │ ├── obsidian.ts │ └── createMockEditor.ts ├── manual │ ├── checkbox-reorder-preserve-number │ │ ├── test.md │ │ ├── expected.md │ │ └── instructions.md │ └── README.md ├── MockEditor.test.ts ├── debug-extra-line.test.ts ├── paste-handler.test.ts ├── Renumberer.test.ts ├── utils.test.ts ├── checkbox.test.ts └── checkbox-blocks.test.ts ├── resources ├── smart_paste.gif ├── smart_paste.mp4 ├── regular_paste.gif ├── regular_paste.mp4 ├── checkbox_example.gif ├── checkbox_example.mkv ├── renumbering_example.gif └── renumbering_example.mkv ├── styles.css ├── .editorconfig ├── versions.json ├── .gitignore ├── manifest.json ├── tsconfig.json ├── main.css ├── version-bump.mjs ├── LICENSE ├── package.json ├── jest.config.js ├── esbuild.config.mjs ├── eslint.config.mjs ├── src ├── types.ts ├── command-registration.ts ├── SettingsManager.ts ├── pasteAndDropHandler.ts ├── utils.ts ├── Renumberer.ts ├── settings-tab.ts └── checkbox.ts ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js -------------------------------------------------------------------------------- /tests/__mocks__/main.ts: -------------------------------------------------------------------------------- 1 | jest.mock("main", () => {}); 2 | 3 | export {}; 4 | -------------------------------------------------------------------------------- /tests/manual/checkbox-reorder-preserve-number/test.md: -------------------------------------------------------------------------------- 1 | 1. [ ] Task A 2 | 2. [ ] Task B 3 | 3. [ ] Task C 4 | -------------------------------------------------------------------------------- /tests/manual/checkbox-reorder-preserve-number/expected.md: -------------------------------------------------------------------------------- 1 | 1. [ ] Task B 2 | 2. [ ] Task C 3 | 3. [x] Task A 4 | -------------------------------------------------------------------------------- /resources/smart_paste.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/smart_paste.gif -------------------------------------------------------------------------------- /resources/smart_paste.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/smart_paste.mp4 -------------------------------------------------------------------------------- /resources/regular_paste.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/regular_paste.gif -------------------------------------------------------------------------------- /resources/regular_paste.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/regular_paste.mp4 -------------------------------------------------------------------------------- /resources/checkbox_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/checkbox_example.gif -------------------------------------------------------------------------------- /resources/checkbox_example.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/checkbox_example.mkv -------------------------------------------------------------------------------- /resources/renumbering_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/renumbering_example.gif -------------------------------------------------------------------------------- /resources/renumbering_example.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OmriLeviGit/Auto-List-Management-Obsidian/HEAD/resources/renumbering_example.mkv -------------------------------------------------------------------------------- /tests/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | // __mocks__/obsidian.js 2 | const EditorTransaction = jest.fn(); 3 | const MarkdownView = jest.fn(); 4 | 5 | export { EditorTransaction, MarkdownView }; 6 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .setting-enabled { 2 | opacity: 1; 3 | pointer-events: auto; 4 | } 5 | 6 | .setting-disabled { 7 | opacity: 0.5; 8 | pointer-events: none; 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | tab_width = 4 10 | max_line_length = 120 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.1.0": "0.15.0", 3 | "1.2.0": "0.15.0", 4 | "2.0.0": "0.15.0", 5 | "2.0.1": "0.15.0", 6 | "2.0.2": "0.15.0", 7 | "2.0.3": "0.15.0", 8 | "2.0.4": "0.15.0", 9 | "2.0.5": "0.15.0", 10 | "2.0.6": "0.15.0", 11 | "2.0.7": "0.15.0", 12 | "2.0.8": "0.15.0", 13 | "2.0.9": "0.15.0", 14 | "2.0.10": "0.15.0", 15 | "2.0.11": "0.15.0" 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | coverage -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "automatic-renumbering", 3 | "name": "Automatic List Management", 4 | "author": "Omri Levi", 5 | "description": "Automatically reorders checklists and numbered lists as you edit them.", 6 | "authorUrl": "https://github.com/OmriLeviGit/Auto-List-Management-Obsidian", 7 | "fundingUrl": "https://buymeacoffee.com/omrilevi", 8 | "version": "2.0.11", 9 | "minAppVersion": "0.15.0", 10 | "isDesktopOnly": true 11 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "esModuleInterop": true, 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": ["DOM", "ES5", "ES6", "ES7"] 16 | }, 17 | "include": ["**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /main.css: -------------------------------------------------------------------------------- 1 | /* styles.css */ 2 | .setting-enabled { 3 | opacity: 1; 4 | pointer-events: auto; 5 | } 6 | .setting-disabled { 7 | opacity: 0.5; 8 | pointer-events: none; 9 | } 10 | /*# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsic3R5bGVzLmNzcyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLnNldHRpbmctZW5hYmxlZCB7XHJcbiAgICBvcGFjaXR5OiAxO1xyXG4gICAgcG9pbnRlci1ldmVudHM6IGF1dG87XHJcbn1cclxuXHJcbi5zZXR0aW5nLWRpc2FibGVkIHtcclxuICAgIG9wYWNpdHk6IDAuNTtcclxuICAgIHBvaW50ZXItZXZlbnRzOiBub25lO1xyXG59XHJcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBQSxDQUFDO0FBQ0csV0FBUztBQUNULGtCQUFnQjtBQUNwQjtBQUVBLENBQUM7QUFDRyxXQUFTO0FBQ1Qsa0JBQWdCO0FBQ3BCOyIsCiAgIm5hbWVzIjogW10KfQo= */ 11 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Omri Levi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automatic-list-management", 3 | "version": "2.0.11", 4 | "description": "Automatically reorders checklists and numbered lists as you edit them.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "test": "jest", 11 | "test:coverage": "jest --coverage" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@types/jest": "^29.5.12", 18 | "@types/node": "^16.11.6", 19 | "@typescript-eslint/eslint-plugin": "5.29.0", 20 | "@typescript-eslint/parser": "5.29.0", 21 | "builtin-modules": "3.3.0", 22 | "esbuild": "^0.25.0", 23 | "jest": "^29.7.0", 24 | "obsidian": "latest", 25 | "ts-jest": "^29.2.4", 26 | "tslib": "2.4.0", 27 | "typescript": "^4.7.4" 28 | }, 29 | "dependencies": { 30 | "async-mutex": "^0.5.0", 31 | "micromatch": "^4.0.8" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | moduleNameMapper: { 6 | "^src/(.*)$": "/src/$1", 7 | "^main$": "/tests/__mocks__/main.ts", 8 | }, 9 | transform: { 10 | "^.+\\.tsx?$": ["ts-jest", {}], 11 | }, 12 | testRegex: "(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 13 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 14 | testPathIgnorePatterns: ["/node_modules/", "/__mocks__/"], 15 | 16 | // Coverage settings 17 | collectCoverage: process.argv.includes("--coverage"), // Enable coverage when --coverage flag is present 18 | coverageDirectory: "./coverage", // Output directory for coverage reports 19 | coverageReporters: ["text", "lcov"], // Formats for coverage reports 20 | coveragePathIgnorePatterns: ["/node_modules/", "/tests/", "/src/SettingsManager", "src/pasteAndDropHandler"], // paste handler mostly contains obsidian functionality 21 | coverageThreshold: { 22 | global: { 23 | branches: 80, 24 | functions: 90, 25 | lines: 90, 26 | statements: 90, 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /tests/manual/checkbox-reorder-preserve-number/instructions.md: -------------------------------------------------------------------------------- 1 | # Checkbox Reorder Preserves First Number 2 | 3 | ## What This Tests 4 | 5 | Verifies that the first line's number is preserved during checkbox reordering to prevent visual glitches before automatic renumbering runs. 6 | 7 | ## Settings Required 8 | 9 | - Live renumbering: **Enabled** 10 | - Live checkbox reordering: **Enabled** 11 | - Checked items at bottom: **Enabled** 12 | 13 | ## Steps 14 | 15 | 1. Open `test.md` 16 | 2. Check the first checkbox (toggle `[ ]` to `[x]`) 17 | 3. Compare the result in `test.md` with `expected.md` 18 | 19 | ## Expected Result 20 | 21 | The list should immediately match `expected.md` with no visual glitches. 22 | Specifically, the first line should show "1. [ ] Task B" (not "2. [ ] Task B"). 23 | 24 | ## Bug Behavior (What Should NOT Happen) 25 | 26 | Without the fix, when you check the first checkbox, the reordering runs before renumbering. Task B (originally line 2) becomes the new first line while keeping its "2." number: 27 | 28 | **Bugged behavior:** 29 | ``` 30 | 2. [ ] Task B ← Wrong! Should be "1." 31 | 3. [ ] Task C 32 | 4. [x] Task A 33 | ``` 34 | 35 | The list should start at "1." not "2." 36 | 37 | > Note that obisidian's default smart lists cause this bug regardless 38 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === "production"; 12 | 13 | const context = await esbuild.context({ 14 | banner: { 15 | js: banner, 16 | }, 17 | entryPoints: ["main.ts"], 18 | bundle: true, 19 | external: [ 20 | "obsidian", 21 | "electron", 22 | "@codemirror/autocomplete", 23 | "@codemirror/collab", 24 | "@codemirror/commands", 25 | "@codemirror/language", 26 | "@codemirror/lint", 27 | "@codemirror/search", 28 | "@codemirror/state", 29 | "@codemirror/view", 30 | "@lezer/common", 31 | "@lezer/highlight", 32 | "@lezer/lr", 33 | ...builtins, 34 | ], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } 49 | -------------------------------------------------------------------------------- /tests/manual/README.md: -------------------------------------------------------------------------------- 1 | # Manual Tests 2 | 3 | This folder contains manual tests that need to be performed in Obsidian. 4 | 5 | ## Why Manual Tests? 6 | 7 | Some plugin functionalities cannot be tested with Playwright or programmatic tests because: 8 | 9 | - Playwright doesn't support Obsidian's editor API well 10 | - No public API exists for certain editor behaviors (e.g., checkbox toggling, live updates) 11 | - Edge cases involving file boundaries (first/last lines) need verification 12 | - Testing behavior with empty lines at start/end of files 13 | 14 | ## Structure 15 | 16 | Each test is in its own subfolder containing: 17 | 18 | - `instructions.md` - What to test and how to test it 19 | - `test.md` - The file to perform the test on (contains initial state, clean content only) 20 | - `expected.md` - The expected result after performing the test (clean content only) 21 | 22 | ## How to Use 23 | 24 | 1. Open the test folder in Obsidian 25 | 2. Read `instructions.md` to understand the test 26 | 3. Work in `test.md` following the instructions 27 | 4. Compare the result in `test.md` (actual) with `expected.md` 28 | 29 | ## Adding New Tests 30 | 31 | 1. Create a new subfolder with a descriptive name (e.g., `checkbox-reorder-issue-123`) 32 | 2. Create `instructions.md` with test description and steps 33 | 3. Create `test.md` with the initial state (clean content only) 34 | 4. Create `expected.md` with the expected outcome (clean content only) 35 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import globals from "globals"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | import js from "@eslint/js"; 7 | import { FlatCompat } from "@eslint/eslintrc"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | { 19 | ignores: ["**/node_modules/", "**/main.js"], 20 | }, 21 | ...compat.extends( 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/eslint-recommended", 24 | "plugin:@typescript-eslint/recommended" 25 | ), 26 | { 27 | plugins: { 28 | "@typescript-eslint": typescriptEslint, 29 | }, 30 | 31 | languageOptions: { 32 | globals: { 33 | ...globals.node, 34 | }, 35 | 36 | parser: tsParser, 37 | ecmaVersion: 5, 38 | sourceType: "module", 39 | }, 40 | 41 | rules: { 42 | "no-unused-vars": "off", 43 | 44 | "@typescript-eslint/no-unused-vars": [ 45 | "error", 46 | { 47 | args: "none", 48 | }, 49 | ], 50 | 51 | "@typescript-eslint/ban-ts-comment": "off", 52 | "no-prototype-builtins": "off", 53 | "@typescript-eslint/no-empty-function": "off", 54 | }, 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /tests/MockEditor.test.ts: -------------------------------------------------------------------------------- 1 | import "./__mocks__/main"; 2 | import { createMockEditor, MockEditor } from "./__mocks__/createMockEditor"; 3 | 4 | import { EditorChange } from "obsidian"; 5 | 6 | describe("Mock editor tests", () => { 7 | const content = ["First line", "Second line", "Third line"]; 8 | let mockEditor: MockEditor; 9 | 10 | beforeEach(() => { 11 | mockEditor = createMockEditor(content); 12 | }); 13 | 14 | test("getLine returns correct content for valid index", () => { 15 | expect(mockEditor.getLine(0)).toBe("First line"); 16 | }); 17 | 18 | test("getLine throws error for negative index", () => { 19 | expect(() => mockEditor.getLine(-1)).toThrow("getLine: index is out of bound"); 20 | }); 21 | 22 | test("setLine sets a line correctly", () => { 23 | mockEditor.setLine(0, "Modified line"); 24 | expect(mockEditor.getLine(0)).toBe("Modified line"); 25 | }); 26 | 27 | test("setLine throws error for out-of-bounds index", () => { 28 | expect(() => mockEditor.setLine(3, "Modified line")).toThrow("setLine: index is out of bound"); 29 | }); 30 | 31 | test("transaction", () => { 32 | const changes: EditorChange[] = []; 33 | 34 | for (let i = 0; i < mockEditor.lastLine(); i++) { 35 | const change: EditorChange = { 36 | from: { line: i, ch: 0 }, 37 | to: { line: i, ch: mockEditor.getLine(i).length }, 38 | text: `iter: ${i}`, 39 | }; 40 | 41 | changes.push(change); 42 | } 43 | 44 | mockEditor.transaction({ changes }); 45 | 46 | for (let i = 0; i < changes.length; i++) { 47 | expect(mockEditor.getLine(i)).toBe(`iter: ${i}`); 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorChange } from "obsidian"; 2 | 3 | interface RenumberingStrategy { 4 | renumber(editor: Editor, startLine: number, isLocal?: boolean): PendingChanges; 5 | } 6 | 7 | interface PendingChanges { 8 | changes: EditorChange[]; 9 | endIndex: number; 10 | } 11 | 12 | interface ChangeResult { 13 | changes: EditorChange[]; 14 | revisitIndices: number[]; 15 | endIndex: number; 16 | } 17 | 18 | interface LineInfo { 19 | spaceCharsNum: number; // number of space characters (\t or ' ' both count as 1) 20 | spaceIndent: number; // the indentation size, i.e. if \t is set to be 4 then '\t ' is an indent of 5 21 | number: number | undefined; 22 | textOffset: number; 23 | checkboxChar: string | undefined; 24 | } 25 | 26 | interface PluginSettings { 27 | renumbering: RenumberingSettings; 28 | checklist: ChecklistSettings; 29 | indentSize: number; 30 | } 31 | 32 | interface RenumberingSettings { 33 | liveUpdate: boolean; 34 | smartPasting: boolean; 35 | startsFromOne: boolean; 36 | } 37 | 38 | interface ChecklistSettings { 39 | liveUpdate: boolean; 40 | checkedItemsAtBottom: boolean; 41 | sortSpecialChars: boolean; 42 | charsToDelete: string; 43 | hierarchicalReordering: boolean; 44 | } 45 | 46 | interface ReorderResult { 47 | start: number; 48 | limit: number; 49 | } 50 | 51 | interface ChecklistBlock { 52 | parentLine: string; // The checkbox line itself 53 | parentInfo: LineInfo; // Parsed info of the parent line 54 | childLines: string[]; // All lines indented under this checkbox (any content type) 55 | } 56 | 57 | export type { 58 | RenumberingStrategy, 59 | PendingChanges, 60 | ChangeResult, 61 | LineInfo, 62 | PluginSettings, 63 | RenumberingSettings, 64 | ChecklistSettings, 65 | ReorderResult, 66 | ChecklistBlock, 67 | }; 68 | -------------------------------------------------------------------------------- /tests/__mocks__/createMockEditor.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorTransaction } from "obsidian"; 2 | export const createMockEditor = (initialContent: string[]) => { 3 | const content = [...initialContent]; 4 | 5 | const editor = { 6 | getLine: jest.fn().mockImplementation((n: number): string => { 7 | if (n < 0 || content.length <= n) { 8 | throw new Error(`getLine: index is out of bound: ${n}`); 9 | } 10 | return content[n]; 11 | }), 12 | setLine: jest.fn().mockImplementation((n: number, text: string) => { 13 | if (n < 0 || content.length <= n) { 14 | throw new Error(`setLine: index is out of bound: ${n}`); 15 | } 16 | content[n] = text; 17 | }), 18 | lastLine: jest.fn().mockImplementation((): number | string => { 19 | return content.length - 1; 20 | }), 21 | 22 | transaction: jest.fn().mockImplementation((tx: EditorTransaction) => { 23 | if (!tx.changes) return; 24 | 25 | const sortedChanges = [...tx.changes].sort((a, b) => b.from.line - a.from.line || b.from.ch - a.from.ch); 26 | 27 | sortedChanges.forEach((change) => { 28 | const fromLine = change.from.line; 29 | const toLine = change.to?.line ?? fromLine; 30 | const fromCh = change.from.ch; 31 | const toCh = change.to?.ch ?? fromCh; 32 | 33 | // Handle multi-line changes 34 | if (fromLine === toLine) { 35 | // Single-line change 36 | const line = content[fromLine]; 37 | const newLine = line.substring(0, fromCh) + change.text + line.substring(toCh); 38 | content[fromLine] = newLine; 39 | } else { 40 | // Multi-line change 41 | const newLines = change.text.split("\n"); 42 | const firstLinePart = content[fromLine].substring(0, fromCh); 43 | let lastLinePart; 44 | if (toLine === content.length) { 45 | lastLinePart = ""; 46 | } else { 47 | lastLinePart = content[toLine].substring(toCh); 48 | } 49 | 50 | newLines[0] = firstLinePart + newLines[0]; 51 | newLines[newLines.length - 1] += lastLinePart; 52 | 53 | content.splice(fromLine, toLine - fromLine + 1, ...newLines); 54 | } 55 | }); 56 | 57 | return true; 58 | }), 59 | 60 | // transaction: jest.fn().mockImplementation((tx: EditorTransaction) => { 61 | // const changes = tx.changes; 62 | // if (changes == undefined) { 63 | // return; 64 | // } 65 | 66 | // changes.forEach((change) => { 67 | // editor.setLine(change.from.line, change.text); 68 | // }); 69 | // }), 70 | }; 71 | 72 | return new Proxy({} as Editor, { 73 | get: (target, prop) => { 74 | if (prop in editor) { 75 | return editor[prop as keyof typeof editor]; 76 | } 77 | return jest.fn(); 78 | }, 79 | }); 80 | 81 | // return editor; 82 | }; 83 | 84 | export type MockEditor = ReturnType; 85 | -------------------------------------------------------------------------------- /tests/debug-extra-line.test.ts: -------------------------------------------------------------------------------- 1 | import { getLineInfo } from "src/utils"; 2 | import { createMockEditor } from "./__mocks__/createMockEditor"; 3 | import "./__mocks__/main"; 4 | 5 | import { reorderChecklist } from "src/checkbox"; 6 | import SettingsManager from "src/SettingsManager"; 7 | 8 | describe("Debug: One line too many issue", () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 12 | }); 13 | 14 | test("Simple case - 3 items, check middle one", () => { 15 | const content = [ 16 | "- [ ] Task A", 17 | "- [x] Task B", 18 | "- [ ] Task C", 19 | ]; 20 | 21 | const editor = createMockEditor(content); 22 | console.log("BEFORE:"); 23 | for (let i = 0; i <= editor.lastLine(); i++) { 24 | console.log(` Line ${i}: "${editor.getLine(i)}"`); 25 | } 26 | 27 | reorderChecklist(editor, 1); 28 | 29 | console.log("\nAFTER:"); 30 | for (let i = 0; i <= editor.lastLine(); i++) { 31 | console.log(` Line ${i}: "${editor.getLine(i)}"`); 32 | } 33 | console.log(`Total lines: ${editor.lastLine() + 1}`); 34 | 35 | expect(editor.lastLine() + 1).toBe(3); 36 | expect(editor.getLine(0)).toBe("- [ ] Task A"); 37 | expect(editor.getLine(1)).toBe("- [ ] Task C"); 38 | expect(editor.getLine(2)).toBe("- [x] Task B"); 39 | }); 40 | 41 | test("With text after checklist", () => { 42 | const content = [ 43 | "- [ ] Task A", 44 | "- [x] Task B", 45 | "- [ ] Task C", 46 | "Some text after", 47 | ]; 48 | 49 | const editor = createMockEditor(content); 50 | console.log("BEFORE:"); 51 | for (let i = 0; i <= editor.lastLine(); i++) { 52 | console.log(` Line ${i}: "${editor.getLine(i)}"`); 53 | } 54 | 55 | reorderChecklist(editor, 1); 56 | 57 | console.log("\nAFTER:"); 58 | for (let i = 0; i <= editor.lastLine(); i++) { 59 | console.log(` Line ${i}: "${editor.getLine(i)}"`); 60 | } 61 | console.log(`Total lines: ${editor.lastLine() + 1}`); 62 | 63 | expect(editor.lastLine() + 1).toBe(4); 64 | expect(editor.getLine(0)).toBe("- [ ] Task A"); 65 | expect(editor.getLine(1)).toBe("- [ ] Task C"); 66 | expect(editor.getLine(2)).toBe("- [x] Task B"); 67 | expect(editor.getLine(3)).toBe("Some text after"); 68 | }); 69 | 70 | test("With empty line after checklist", () => { 71 | const content = [ 72 | "- [ ] Task A", 73 | "- [x] Task B", 74 | "- [ ] Task C", 75 | "", 76 | ]; 77 | 78 | const editor = createMockEditor(content); 79 | console.log("BEFORE:"); 80 | for (let i = 0; i <= editor.lastLine(); i++) { 81 | console.log(` Line ${i}: "${editor.getLine(i)}"`); 82 | } 83 | 84 | reorderChecklist(editor, 1); 85 | 86 | console.log("\nAFTER:"); 87 | for (let i = 0; i <= editor.lastLine(); i++) { 88 | console.log(` Line ${i}: "${editor.getLine(i)}"`); 89 | } 90 | console.log(`Total lines: ${editor.lastLine() + 1}`); 91 | 92 | expect(editor.lastLine() + 1).toBe(4); 93 | expect(editor.getLine(0)).toBe("- [ ] Task A"); 94 | expect(editor.getLine(1)).toBe("- [ ] Task C"); 95 | expect(editor.getLine(2)).toBe("- [x] Task B"); 96 | expect(editor.getLine(3)).toBe(""); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/command-registration.ts: -------------------------------------------------------------------------------- 1 | import AutoReordering from "../main"; 2 | import { Editor, Notice } from "obsidian"; 3 | import { reorderChecklist, deleteChecked } from "./checkbox"; 4 | import SettingsManager from "./SettingsManager"; 5 | 6 | export function registerCommands(plugin: AutoReordering) { 7 | plugin.addCommand({ 8 | id: "1-reneumber-selection", 9 | name: "Renumber lists: in selection or at cursor", 10 | editorCallback: (editor: Editor) => { 11 | const { anchor, head } = editor.listSelections()[0]; 12 | const startLine = Math.min(anchor.line, head.line); 13 | const endLine = Math.max(anchor.line, head.line) + 1; 14 | 15 | plugin.getRenumberer().renumber(editor, startLine, endLine); 16 | }, 17 | }); 18 | 19 | plugin.addCommand({ 20 | id: "2-renumber-entire-note", 21 | name: "Renumber lists: entire note", 22 | editorCallback: (editor: Editor) => { 23 | plugin.getRenumberer().renumber(editor, 0, editor.lastLine() + 1); 24 | }, 25 | }); 26 | 27 | plugin.addCommand({ 28 | id: "3-checklist-at-cursor", 29 | name: "Reorder checkboxes: in selection or at cursor", 30 | editorCallback: (editor: Editor) => { 31 | const posToReturn = editor.getCursor(); 32 | const renumberer = plugin.getRenumberer(); 33 | 34 | const { anchor, head } = editor.listSelections()[0]; 35 | const startLine = Math.min(anchor.line, head.line); 36 | const endLine = Math.max(anchor.line, head.line) + 1; 37 | 38 | const reorderResult = reorderChecklist(editor, startLine, endLine); 39 | 40 | if (SettingsManager.getInstance().getLiveNumberingUpdate() === true) { 41 | if (reorderResult !== undefined) { 42 | renumberer.renumber(editor, reorderResult.start, reorderResult.limit); 43 | } 44 | } 45 | 46 | plugin.updateCursorPosition(editor, posToReturn, reorderResult); 47 | }, 48 | }); 49 | 50 | plugin.addCommand({ 51 | id: "4-checklist-entire-note", 52 | name: "Reorder checkboxes: entire note", 53 | editorCallback: (editor: Editor) => { 54 | const lineToReturn = editor.getCursor().line; 55 | const renumberer = plugin.getRenumberer(); 56 | 57 | const reorderResult = reorderChecklist(editor, 0, editor.lastLine() + 1); 58 | 59 | if (SettingsManager.getInstance().getLiveNumberingUpdate() === true) { 60 | if (reorderResult !== undefined) { 61 | renumberer.renumber(editor, reorderResult.start, reorderResult.limit); 62 | } 63 | } 64 | 65 | editor.setCursor({ line: lineToReturn, ch: editor.getLine(lineToReturn).length }); 66 | }, 67 | }); 68 | 69 | plugin.addCommand({ 70 | id: "5-checklist-delete-checked-items", 71 | name: "Delete all checked Items in note", 72 | editorCallback: (editor: Editor) => { 73 | const lineToReturn = editor.getCursor().line; 74 | const renumberer = plugin.getRenumberer(); 75 | 76 | const { deleteResult, deletedItemCount } = deleteChecked(editor); 77 | 78 | if (SettingsManager.getInstance().getLiveNumberingUpdate() === true) { 79 | renumberer.renumber(editor, deleteResult.start, deleteResult.limit); 80 | } 81 | 82 | const noticeString = 83 | deletedItemCount > 0 ? `Deleted ${deletedItemCount} lines` : "No checked items to delete"; 84 | 85 | new Notice(noticeString); 86 | 87 | editor.setCursor({ line: lineToReturn, ch: editor.getLine(lineToReturn).length }); 88 | }, 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /src/SettingsManager.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettings, RenumberingSettings, ChecklistSettings } from "./types"; 2 | 3 | const DEFAULT_RENUMBERING_SETTINGS: RenumberingSettings = { 4 | liveUpdate: true, 5 | smartPasting: true, 6 | startsFromOne: true, 7 | }; 8 | 9 | const DEFAULT_CHECKLIST_SETTINGS: ChecklistSettings = { 10 | liveUpdate: true, 11 | checkedItemsAtBottom: true, 12 | sortSpecialChars: true, 13 | charsToDelete: "", 14 | hierarchicalReordering: true, 15 | }; 16 | 17 | export const DEFAULT_SETTINGS: PluginSettings = { 18 | renumbering: DEFAULT_RENUMBERING_SETTINGS, 19 | checklist: DEFAULT_CHECKLIST_SETTINGS, 20 | indentSize: 4, 21 | }; 22 | 23 | // a singleton for the settings 24 | export default class SettingsManager { 25 | private static instance: SettingsManager; 26 | private settings: PluginSettings; 27 | 28 | private constructor() { 29 | this.settings = DEFAULT_SETTINGS; 30 | } 31 | 32 | public static getInstance(): SettingsManager { 33 | if (!SettingsManager.instance) { 34 | SettingsManager.instance = new SettingsManager(); 35 | } 36 | 37 | return SettingsManager.instance; 38 | } 39 | 40 | public getSettings(): PluginSettings { 41 | return this.settings; 42 | } 43 | 44 | public setSettings(settings: PluginSettings): void { 45 | this.settings = settings; 46 | } 47 | 48 | public getLiveNumberingUpdate(): boolean { 49 | return this.settings.renumbering.liveUpdate; 50 | } 51 | 52 | public setLiveNumberingUpdate(value: boolean): void { 53 | this.settings.renumbering.liveUpdate = value; 54 | } 55 | 56 | public getSmartPasting(): boolean { 57 | return this.settings.renumbering.smartPasting; 58 | } 59 | 60 | public setSmartPasting(value: boolean): void { 61 | this.settings.renumbering.smartPasting = value; 62 | } 63 | 64 | public getStartsFromOne(): boolean { 65 | return this.settings.renumbering.startsFromOne; 66 | } 67 | 68 | public setStartsFromOne(value: boolean): void { 69 | this.settings.renumbering.startsFromOne = value; 70 | } 71 | 72 | public getIndentSize(): number { 73 | return this.settings.indentSize; 74 | } 75 | 76 | public setIndentSize(value: number): void { 77 | this.settings.indentSize = value; 78 | } 79 | 80 | public getLiveCheckboxUpdate(): boolean { 81 | return this.settings.checklist.liveUpdate; 82 | } 83 | 84 | public setLiveCheckboxUpdate(value: boolean): void { 85 | this.settings.checklist.liveUpdate = value; 86 | } 87 | 88 | public isCheckedItemsAtBottom(): boolean { 89 | return this.settings.checklist.checkedItemsAtBottom; 90 | } 91 | 92 | public setCheckedItemsAtBottom(value: boolean): void { 93 | this.settings.checklist.checkedItemsAtBottom = value; 94 | } 95 | 96 | public getCharsToDelete(): string { 97 | return this.settings.checklist.charsToDelete; 98 | } 99 | 100 | public setCharsToDelete(value: string): void { 101 | this.settings.checklist.charsToDelete = value; 102 | } 103 | 104 | public getSortSpecialChars(): boolean { 105 | return this.settings.checklist.sortSpecialChars; 106 | } 107 | 108 | public setSortSpecialChars(value: boolean): void { 109 | this.settings.checklist.sortSpecialChars = value; 110 | } 111 | 112 | public isHierarchicalReordering(): boolean { 113 | return this.settings.checklist.hierarchicalReordering ?? true; 114 | } 115 | 116 | public setHierarchicalReordering(value: boolean): void { 117 | this.settings.checklist.hierarchicalReordering = value; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/paste-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { createMockEditor } from "./__mocks__/createMockEditor"; 2 | import "./__mocks__/main"; 3 | 4 | import { modifyText } from "src/pasteAndDropHandler"; 5 | 6 | describe("modifyText tests", () => { 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | const testCases = [ 12 | { 13 | name: "Modify the first numbered line", 14 | editorContent: "5. editor content\n6. more content", 15 | pastedText: "1. a\n2. b\n3. c", 16 | indexAfterPasting: 0, 17 | expectedResult: "5. a\n2. b\n3. c", 18 | }, 19 | { 20 | name: "Modify the last numbered line", 21 | editorContent: "5. editor content\n6. more content", 22 | pastedText: "2. a\n3. b\n2. c", 23 | indexAfterPasting: 1, 24 | expectedResult: "6. a\n3. b\n2. c", 25 | }, 26 | { 27 | name: "Modify the last numbered line, fronted by text", 28 | editorContent: "5. editor content\n6. more content\n7. last line", 29 | pastedText: "text\n2. a\n3. b\n2. c", 30 | indexAfterPasting: 2, 31 | expectedResult: "text\n7. a\n3. b\n2. c", 32 | }, 33 | { 34 | name: "Modify a single line", 35 | editorContent: "10. single editor line", 36 | pastedText: "1. single paste line", 37 | indexAfterPasting: 0, 38 | expectedResult: "10. single paste line", 39 | }, 40 | { 41 | name: "With different indent levels", 42 | editorContent: "5. level 1\n 6. level 2\n7. back to level 1", 43 | pastedText: "1. top level\n 2. indented\n3. top level again", 44 | indexAfterPasting: 2, 45 | expectedResult: "7. top level\n 2. indented\n3. top level again", 46 | }, 47 | { 48 | name: "With multiple indent levels", 49 | editorContent: "10. level 1\n 11. level 2\n 12. level 3\n13. level 1 again", 50 | pastedText: "1. level 1\n 2. level2\n 3. level3\n4. level 1 again", 51 | indexAfterPasting: 3, 52 | expectedResult: "13. level 1\n 2. level2\n 3. level3\n4. level 1 again", 53 | }, 54 | { 55 | name: "between different indent levels", 56 | editorContent: "4. level 2\n 6. level 2", 57 | pastedText: "1. level1\n 2. level2", 58 | indexAfterPasting: 1, 59 | expectedResult: "1. level1\n 6. level2", 60 | }, 61 | { 62 | name: "Start from higher intent", 63 | editorContent: " 10. level 2\n11. level 2", 64 | pastedText: "1. level1\n 2. level2", 65 | indexAfterPasting: 0, 66 | expectedResult: "11. level1\n 10. level2", 67 | }, 68 | { 69 | name: "Don't modify with leading spaces", 70 | editorContent: "5. first line\n6. second line", 71 | pastedText: " 1. indented line\n2. another line", 72 | indexAfterPasting: 1, 73 | expectedResult: " 1. indented line\n6. another line", 74 | }, 75 | { 76 | name: "No numbered lines in source, but target is numbered", 77 | editorContent: "5. editor content", 78 | pastedText: "no numbers here", 79 | indexAfterPasting: 0, 80 | expectedResult: "no numbers here", 81 | }, 82 | { 83 | name: "Target line is not numbered", 84 | editorContent: "5. editor content\nnot numbered", 85 | pastedText: "1. numbered line\nno numbers here", 86 | indexAfterPasting: 1, 87 | expectedResult: undefined, 88 | }, 89 | 90 | { 91 | name: "Empty content", 92 | editorContent: "5. some content", 93 | pastedText: "", 94 | indexAfterPasting: 0, 95 | expectedResult: "", 96 | }, 97 | ]; 98 | 99 | testCases.forEach(({ name, editorContent, pastedText, indexAfterPasting, expectedResult }) => { 100 | test(name, () => { 101 | const editorLines = editorContent.split("\n"); 102 | const editor = createMockEditor(editorLines); 103 | const res = modifyText(editor, pastedText, indexAfterPasting); 104 | expect(res).toBe(expectedResult); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automatic List Management Plugin for Obsidian 2 | 3 | This plugin automatically manages your lists in Obsidian, handling both numbered lists and checklists. 4 | 5 | 6 | 7 | 10 | 13 | 14 |
8 | Regular paste 9 | 11 | Smart paste 12 |
15 | 16 | ## Features 17 | 18 | ### Checklists 19 | 20 | - Automatic reordering of checked/unchecked items 21 | - Hierarchical reordering treats checkboxes and their indented content as unified blocks that move together 22 | - Configurable sorting (checked items are sorted to the bottom or to the top) 23 | - Smart handling when pasting or dragging content 24 | - Works with mouse and keyboard interactions 25 | 26 | ### Numbered Lists 27 | 28 | - Automatic renumbering as you type 29 | - Smart pasting that preserves list context 30 | - Option to maintain or reset starting numbers 31 | 32 | ### General 33 | 34 | - Handles deeply nested lists 35 | - High performance even with large documents 36 | - Manual commands for control 37 | 38 | ## Installation Steps 39 | 40 | > Step 3 is required for accurate parsing of indented lists. 41 | 42 | 1. In Obsidian, go to **Options → Community plugins → Browse** and search for **Automatic List Reordering**. 43 | 2. Click **Install** and enable the plugin. 44 | 3. In the plugin settings, adjust the **tab size** to match your editor's settings (found under **Options → Editor → Tab indent size/Indent visual width**). 45 | 46 | ## Configuration Options 47 | 48 | ### Checklists 49 | 50 | - **Auto-sort on changes**: Sorts checklist items automatically when they are checked. 51 | 52 | - **Hierarchical checkbox reordering**: When enabled, checking a checkbox moves it along with all indented content (sub-tasks, descriptions, paragraphs) as a unified block. When disabled, checkbox lines move individually without their indented content. 53 | 54 | - **Sorting position**: Choose whether checked items should be placed at the top or bottom of the list. 55 | 56 | - **Sort all special checkboxes**: When enabled, tasks with any special checkbox characters will be sorted by [ASCII](https://en.wikipedia.org/wiki/ASCII). When disabled, only tasks marked for deletion will be sorted. 57 | 58 | - **Checkbox delete characters**: Specify which checkbox characters mark tasks for deletion. Tasks with these characters are always sorted below tasks with other characters, and can be removed by using the delete command. 59 | 60 | ### Numbered lists 61 | 62 | - **Auto-renumber on changes**: Automatically update numbered lists as you edit without manual adjustments. Additional commands are available if you prefer to manually control which lists to renumber. 63 | 64 | - **Smart pasting**: Keeps the numbering intact when pasting content into an existing list, rather than adopting the numbering from the pasted text. 65 | 66 | - **Start numbering from 1**: When enabled, all numbered lists will be numbered starting from 1. 67 | 68 |
69 |
70 | Content in clipboard: 71 |
    72 |
  1. Apple
  2. 73 |
  3. Banana
  4. 74 |
75 | 76 | 77 | 81 | 85 | 86 |
78 | Regular paste 79 |

Regular pasting

80 |
82 | Smart paste 83 |

Smart pasting

84 |
87 |
88 | 89 | ## Available Commands 90 | 91 | - **Reorder checkboxes: In selection or at cursor**: Reorders checked/unchecked items within the checklist at your cursor position. If multiple checklists are selected, reorders all of them. 92 | 93 | - **Reorder checkboxes: Entire note**: Reorders all checked/unchecked items in every checklist throughout your note. 94 | 95 | - **Delete all checked Items in note**: Removes all tasks that contain the specified delete characters in their checkboxes throughout your note. 96 | 97 | - **Renumber lists: In selection or at cursor**: Renumbers the list that the cursor is within. If multiple lists are highlighted, renumbers both of them separately. 98 | 99 | - **Renumber lists: Entire note**: Renumbers every numbered list in your note. 100 | 101 | ## Performance 102 | 103 | The plugin was tested with documents containing lists with over 10,000 lines, and no performance issues were found on my machine. 104 | 105 | ## Known bugs & Limitations 106 | 107 | - **Modifier keys**: To avoid conflicts with keyboard shortcuts, the automatic update is temporarily disabled when modifier Keys (`Ctrl`, `Command` on Mac, or `Alt/Option`) held down during editing. 108 | - **Obsidian's smart lists with numbered checkboxes**: When using numbered lists with checklist items, Obsidian's default smart lists feature may cause incorrect numbering during checkbox reordering (e.g., lists starting from "2." instead of "1."). To prevent this, consider enabling the **Auto-renumber on changes** in plugin's settings - the two features work together without interfering with each other. 109 | -------------------------------------------------------------------------------- /src/pasteAndDropHandler.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorTransaction, MarkdownView } from "obsidian"; 2 | import { getLineInfo, findFirstNumbersByIndentFromEnd, findFirstNumbersAfterIndex } from "src/utils"; 3 | import SettingsManager from "src/SettingsManager"; 4 | import { checkPrimeSync } from "crypto"; 5 | import path from "path"; 6 | 7 | function handlePaste(evt: ClipboardEvent, editor: Editor): { start?: number; end?: number } { 8 | const updateNumbering = SettingsManager.getInstance().getLiveNumberingUpdate(); 9 | const updateChecklist = SettingsManager.getInstance().getLiveCheckboxUpdate(); 10 | if (!updateNumbering && !updateChecklist) { 11 | return { start: undefined, end: undefined }; 12 | } 13 | 14 | // Get the content from clipboardData 15 | const content = evt.clipboardData?.getData("text"); 16 | if (evt.defaultPrevented || !content) { 17 | return { start: undefined, end: undefined }; 18 | } 19 | 20 | // Prevent default handling 21 | evt.preventDefault(); 22 | 23 | // Get current selection 24 | const { anchor, head } = editor.listSelections()[0]; 25 | const baseIndex = Math.min(anchor.line, head.line); 26 | 27 | // Modify text if smart pasting is enabled 28 | let modifiedContent = content; 29 | const smartPasting = SettingsManager.getInstance().getSmartPasting(); 30 | if (smartPasting) { 31 | const indexAfterPasting = Math.max(anchor.line, head.line) + 1; 32 | modifiedContent = modifyText(editor, content, indexAfterPasting) ?? content; 33 | } 34 | 35 | editor.replaceSelection(modifiedContent); // Paste the content 36 | 37 | // Count lines for return value calculation 38 | const contentLines = modifiedContent.split("\n"); 39 | const numOfLines = contentLines.length - 1; 40 | 41 | // Calculate end position 42 | const start = baseIndex; 43 | const end = start + numOfLines; 44 | 45 | return { start, end }; 46 | } 47 | 48 | function handleDrop(evt: DragEvent, editor: Editor): { start?: number; end?: number } { 49 | const settingsManager = SettingsManager.getInstance(); 50 | if (!settingsManager.getLiveNumberingUpdate() && !settingsManager.getLiveCheckboxUpdate()) { 51 | return { start: undefined, end: undefined }; 52 | } 53 | 54 | // Get the cm active view 55 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 56 | if (!activeView || !activeView.editor.hasFocus()) { 57 | return { start: undefined, end: undefined }; 58 | } 59 | // @ts-expect-error, not typed 60 | const editorView = activeView.editor.cm as EditorView; 61 | 62 | const dropPosition = editorView.posAtCoords({ x: evt.clientX, y: evt.clientY }); 63 | if (dropPosition === null) { 64 | return { start: undefined, end: undefined }; 65 | } 66 | 67 | // Get the content from dataTransfer 68 | const content = evt.dataTransfer?.getData("text"); 69 | if (evt.defaultPrevented || !content) { 70 | return { start: undefined, end: undefined }; 71 | } 72 | 73 | evt.preventDefault(); // Prevent default handling 74 | 75 | const pos = editor.offsetToPos(dropPosition); // Get the drop position 76 | 77 | // Get the current selection (what's being dragged) 78 | const { anchor, head } = editor.listSelections()[0]; 79 | 80 | let modifiedContent = content; 81 | const smartPasting = SettingsManager.getInstance().getSmartPasting(); 82 | if (smartPasting) { 83 | modifiedContent = modifyText(editor, content, pos.line) ?? content; 84 | } 85 | 86 | const selectionFrom = anchor.line < head.line || (anchor.line === head.line && anchor.ch < head.ch) ? anchor : head; 87 | 88 | const selectionTo = anchor.line > head.line || (anchor.line === head.line && anchor.ch > head.ch) ? anchor : head; 89 | 90 | // Create and execute the transaction 91 | const transaction: EditorTransaction = { 92 | changes: [ 93 | { 94 | from: selectionFrom, 95 | to: selectionTo, 96 | text: "", 97 | }, 98 | { 99 | from: pos, 100 | to: pos, 101 | text: modifiedContent, 102 | }, 103 | ], 104 | }; 105 | 106 | editor.transaction(transaction); 107 | 108 | // Calculate end position of inserted text 109 | const lines = modifiedContent.split("\n"); 110 | const endPos = { 111 | line: pos.line + lines.length - 1, 112 | ch: lines.length > 1 ? lines[lines.length - 1].length : pos.ch + modifiedContent.length, 113 | }; 114 | 115 | const start = Math.min(pos.line, selectionFrom.line); 116 | const end = Math.max(endPos.line, selectionTo.line) + 1; 117 | 118 | return { start, end }; 119 | } 120 | 121 | function modifyText(editor: Editor, pastedText: string, pastePosition: number) { 122 | const currentLineInfo = getLineInfo(editor.getLine(pastePosition)); 123 | if (!currentLineInfo.number) { 124 | return; 125 | } 126 | 127 | const pastedLines = pastedText.split("\n"); 128 | const sourceListNumbers = findFirstNumbersByIndentFromEnd(pastedLines); 129 | const targetListNumbers = findFirstNumbersAfterIndex(editor, pastePosition); 130 | 131 | for (let indentLevel = 0; indentLevel < sourceListNumbers.length; indentLevel++) { 132 | const sourceLineIndex = sourceListNumbers[indentLevel]; 133 | const newNumber = targetListNumbers[indentLevel]; 134 | 135 | if (sourceLineIndex === undefined || newNumber === undefined) { 136 | continue; 137 | } 138 | 139 | const sourceLine = pastedLines[sourceLineIndex]; 140 | const sourceLineInfo = getLineInfo(sourceLine); 141 | 142 | pastedLines[sourceLineIndex] = 143 | sourceLine.slice(0, sourceLineInfo.spaceCharsNum) + 144 | newNumber + 145 | ". " + 146 | sourceLine.slice(sourceLineInfo.textOffset); 147 | } 148 | 149 | const renumberedText = pastedLines.join("\n"); 150 | 151 | return renumberedText; 152 | } 153 | 154 | export { modifyText, handlePaste, handleDrop }; 155 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "obsidian"; 2 | import SettingsManager from "./SettingsManager"; 3 | import { LineInfo } from "./types"; 4 | import { resourceLimits } from "worker_threads"; 5 | 6 | // extract information from a line of text 7 | function getLineInfo(line: string): LineInfo { 8 | const length = line.length; 9 | let offset = 0; 10 | let numOfSpaceIndents = 0; 11 | 12 | const indentSize = SettingsManager.getInstance().getIndentSize(); 13 | 14 | // num of spaces 15 | while (offset < length && (line[offset] === " " || line[offset] === "\t")) { 16 | numOfSpaceIndents += line[offset] === " " ? 1 : indentSize; 17 | offset++; 18 | } 19 | 20 | const spaceCharsNum = offset; 21 | 22 | // number indices 23 | while ( 24 | offset < length && 25 | "0".charCodeAt(0) <= line.charCodeAt(offset) && 26 | line.charCodeAt(offset) <= "9".charCodeAt(0) 27 | ) { 28 | offset++; 29 | } 30 | 31 | const isNumberDetected = spaceCharsNum !== offset && line[offset] === "." && line[offset + 1] === " "; 32 | 33 | let number = undefined; 34 | 35 | if (!isNumberDetected) { 36 | offset = spaceCharsNum; 37 | } else { 38 | const parsedNum = parseInt(line.slice(spaceCharsNum, offset)); 39 | 40 | if (isNaN(parsedNum)) { 41 | offset = spaceCharsNum; 42 | } else { 43 | number = parsedNum; 44 | offset += 2; 45 | } 46 | } 47 | 48 | const checkboxChar = getCheckboxInfo(line, offset, isNumberDetected); 49 | 50 | return { 51 | spaceCharsNum, 52 | spaceIndent: numOfSpaceIndents, 53 | number, 54 | textOffset: offset, 55 | checkboxChar, 56 | }; 57 | } 58 | 59 | function getCheckboxInfo(line: string, index: number, isNumberDetected: boolean): string | undefined { 60 | const NUMBERED_CHECKBOX = /^\s*\[(.)\] /; // checkbox inside a numbered item 61 | const UNNUMBERED_CHECKBOX = /^\s*- \[(.)\] /; // unnumbered checkbox, indented or not 62 | 63 | const pattern = isNumberDetected ? NUMBERED_CHECKBOX : UNNUMBERED_CHECKBOX; 64 | const stringToCheck = isNumberDetected ? line.slice(index) : line; 65 | 66 | const match = stringToCheck.match(pattern); 67 | if (match) { 68 | return match[1]; 69 | } 70 | 71 | return undefined; 72 | } 73 | 74 | // TODO not perfect, does not take into account indents and im not sure if its intendend (it might be) 75 | // gets the index of the first item in a numbered list 76 | function getListStart(editor: Editor, currLineIndex: number): number | undefined { 77 | if (currLineIndex < 0 || editor.lastLine() < currLineIndex) { 78 | return undefined; 79 | } 80 | 81 | const currInfo = getLineInfo(editor.getLine(currLineIndex)); 82 | if (currInfo.number === undefined) { 83 | return currLineIndex; 84 | } 85 | 86 | let prevIndex = currLineIndex - 1; 87 | while (0 <= prevIndex && getLineInfo(editor.getLine(prevIndex)).number !== undefined) { 88 | prevIndex--; 89 | } 90 | 91 | return prevIndex + 1; 92 | } 93 | 94 | function getPrevItemIndex(editor: Editor, index: number): number | undefined { 95 | if (index <= 0 || editor.lastLine() < index) { 96 | return undefined; 97 | } 98 | 99 | const currSpaceOffset = getLineInfo(editor.getLine(index)).spaceIndent; 100 | 101 | for (let prevIndex = index - 1; prevIndex >= 0; prevIndex--) { 102 | const info = getLineInfo(editor.getLine(prevIndex)); 103 | 104 | // Skip lines with deeper indentation 105 | if (info.spaceIndent > currSpaceOffset) { 106 | continue; 107 | } 108 | 109 | // If we find a line with same indentation and it has a number, we found our match 110 | if (info.spaceIndent === currSpaceOffset && info.number !== undefined) { 111 | return prevIndex; 112 | } 113 | 114 | return undefined; 115 | } 116 | 117 | return undefined; 118 | } 119 | 120 | function findFirstNumbersAfterIndex(editor: Editor, startIndex: number): number[] { 121 | // Array to store the first number found for each indent level (going forward) 122 | const result: number[] = []; 123 | 124 | // Get the indent level of the current line to set our maximum tracking threshold 125 | const currentLineInfo = getLineInfo(editor.getLine(startIndex)); 126 | 127 | if (!currentLineInfo || currentLineInfo.spaceIndent === undefined) { 128 | return []; // Invalid start line 129 | } 130 | 131 | // Initial maximum indent level we care about 132 | let maxIndentToTrack = Infinity; 133 | 134 | for (let i = startIndex; i <= editor.lastLine(); i++) { 135 | const line = editor.getLine(i); 136 | const info = getLineInfo(line); 137 | 138 | // Skip if we can't get info 139 | if (info.spaceIndent === undefined) { 140 | continue; 141 | } 142 | 143 | const currentIndent = info.spaceIndent; 144 | 145 | // Skip if this indent is higher than what we care about 146 | if (currentIndent > maxIndentToTrack) { 147 | continue; 148 | } 149 | 150 | // If the line has no number, continue to next line 151 | if (info.number === undefined) { 152 | continue; 153 | } 154 | 155 | // Store the number for this indent level if not already set 156 | if (result[currentIndent] === undefined) { 157 | result[currentIndent] = info.number; 158 | } 159 | 160 | // Only update maxIndentToTrack AFTER we've stored the number 161 | if (currentIndent < maxIndentToTrack) { 162 | maxIndentToTrack = currentIndent; 163 | } 164 | 165 | // If we've found indent 0, we're done (reached the lowest level) 166 | if (currentIndent === 0 && result[0] !== undefined) { 167 | break; 168 | } 169 | } 170 | 171 | return result; 172 | } 173 | 174 | // index of the first item in the last numbered list 175 | function findFirstNumbersByIndentFromEnd(lines: string[]): number[] { 176 | // Array to store the first number found for each indent level (from the end) 177 | const result = []; 178 | // Track the maximum indent level we still care about, which is the minimum we have seen 179 | let maxIndentToTrack = Infinity; 180 | 181 | // Process lines in reverse order 182 | for (let i = lines.length - 1; i >= 0; i--) { 183 | const line = lines[i]; 184 | const info = getLineInfo(line); 185 | 186 | // Skip if we can't get info 187 | if (!info || info.spaceIndent === undefined) { 188 | continue; 189 | } 190 | 191 | const currentIndent = info.spaceIndent; 192 | 193 | // Skip if this indent is higher than what we care about 194 | if (currentIndent > maxIndentToTrack) { 195 | continue; 196 | } 197 | 198 | // Update the minimum indent we care about 199 | maxIndentToTrack = currentIndent; 200 | 201 | // If the line has no number, we don't need to process any more lines 202 | // at this indent level or higher 203 | if (info.number === undefined) { 204 | if (currentIndent === 0) { 205 | // If we're at indent 0 with no number, we can break entirely 206 | break; 207 | } 208 | 209 | continue; 210 | } 211 | 212 | // Store the number for this indent level 213 | result[currentIndent] = i; 214 | } 215 | 216 | return result; 217 | } 218 | 219 | export { getLineInfo, getListStart, getPrevItemIndex, findFirstNumbersByIndentFromEnd, findFirstNumbersAfterIndex }; 220 | -------------------------------------------------------------------------------- /src/Renumberer.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorChange } from "obsidian"; 2 | import { getListStart, getLineInfo, getPrevItemIndex } from "./utils"; 3 | import { ChangeResult, LineInfo, PendingChanges } from "./types"; 4 | import SettingsManager from "./SettingsManager"; 5 | 6 | // responsible for all renumbering actions 7 | export default class Renumberer { 8 | renumber(editor: Editor, start: number, limit?: number) { 9 | let pendingChanges; 10 | 11 | if (limit === undefined || limit === start) { 12 | pendingChanges = this.renumberAtIndex(editor, start); 13 | } else { 14 | pendingChanges = this.renumberAllListsInRange(editor, start, limit); 15 | } 16 | 17 | this.applyChangesToEditor(editor, pendingChanges.changes); 18 | return pendingChanges.endIndex; 19 | } 20 | 21 | // renumbers all numbered lists in specified range 22 | private renumberAllListsInRange = (editor: Editor, start: number, limit: number): PendingChanges => { 23 | const isInvalidRange = start < 0 || limit < start; 24 | const editorLastLine = editor.lastLine(); 25 | const newChanges: EditorChange[] = []; 26 | 27 | if (isInvalidRange) { 28 | console.error(`Invalid renumbering range: start=${start}, limit=${limit}. Requires (0 <= start <= limit).`); 29 | return { changes: newChanges, endIndex: start }; 30 | } 31 | 32 | if (editorLastLine + 1 < limit) { 33 | console.error( 34 | `Limit exceeds document bounds: attempted limit=${limit}, actual limit=${ 35 | editorLastLine + 1 36 | }. Adjusting limit.` 37 | ); 38 | limit = editorLastLine + 1; 39 | } 40 | 41 | let i = start; 42 | for (; i < limit; i++) { 43 | const line = editor.getLine(i); 44 | 45 | if (line === undefined) { 46 | continue; 47 | } 48 | 49 | const { number } = getLineInfo(line); 50 | 51 | if (number === undefined) { 52 | continue; 53 | } 54 | 55 | const startIndex = getListStart(editor, i); 56 | 57 | if (startIndex !== undefined) { 58 | const pendingChanges = this.renumberAtIndex(editor, startIndex, false); 59 | if (pendingChanges) { 60 | newChanges.push(...pendingChanges.changes); 61 | i = pendingChanges.endIndex; 62 | } 63 | } 64 | } 65 | 66 | return { changes: newChanges, endIndex: i }; 67 | }; 68 | 69 | // bfs where indents == junctions 70 | private renumberAtIndex(editor: Editor, index: number, isLocal = true): PendingChanges { 71 | const changes: EditorChange[] = []; 72 | const queue = [index]; // contains indices to revisit 73 | let endIndex = index; 74 | 75 | if (index > 0) { 76 | queue.unshift(index - 1); 77 | } 78 | 79 | if (index < editor.lastLine()) { 80 | queue.push(index + 1); 81 | } 82 | 83 | const visited: number[] = []; // visited[spaceIndent] == lastVisitedIndex 84 | const firstSpaceIndent = getLineInfo(editor.getLine(queue[0])).spaceIndent; 85 | visited[firstSpaceIndent] = queue[0]; 86 | 87 | while (0 < queue.length) { 88 | const indexToRenumber = queue.shift()!; 89 | if (indexToRenumber > editor.lastLine()) { 90 | break; 91 | } 92 | const info = getLineInfo(editor.getLine(indexToRenumber)); 93 | if (indexToRenumber < visited[info.spaceIndent]) { 94 | // if this indentation has been visited and its this index had already been renumbered 95 | continue; 96 | } 97 | 98 | if (info.number === undefined) { 99 | continue; 100 | } 101 | 102 | const prevIndex = getPrevItemIndex(editor, indexToRenumber); 103 | const isStartFromOne = SettingsManager.getInstance().getStartsFromOne(); 104 | 105 | let num: number; 106 | if (prevIndex === undefined) { 107 | num = isStartFromOne ? 1 : info.number; // is the first item number in the list 108 | } else { 109 | num = getLineInfo(editor.getLine(prevIndex)).number! + 1; 110 | } 111 | 112 | const changeResult = this.generateChanges(editor, indexToRenumber, num, info.spaceIndent, isLocal); 113 | changes.push(...changeResult.changes); 114 | queue.push(...changeResult.revisitIndices); 115 | 116 | visited[info.spaceIndent] = changeResult.endIndex; 117 | endIndex = Math.max(endIndex, changeResult.endIndex); 118 | } 119 | 120 | return { changes, endIndex }; 121 | } 122 | 123 | // performs the calculation itself 124 | private generateChanges( 125 | editor: Editor, 126 | firstIndex: number, 127 | currentNumber: number, 128 | firstIndent: number, 129 | isLocal = true 130 | ): ChangeResult { 131 | const revisitIndices: number[] = []; 132 | const changes: EditorChange[] = []; 133 | let firstMatchInSuccession = true; 134 | 135 | if (firstIndex < 0) { 136 | return { changes, revisitIndices, endIndex: firstIndex }; 137 | } 138 | 139 | let currentIndex = firstIndex; 140 | let indexToRevisit = true; // true if the first line with higher indent needs to be revisited, false o.w. 141 | for (; currentIndex <= editor.lastLine(); currentIndex++) { 142 | const lineText = editor.getLine(currentIndex); 143 | const info = getLineInfo(lineText); 144 | 145 | // if on a higher indent, add it's first index to the the queue to revisit 146 | if (info.spaceIndent > firstIndent) { 147 | if (indexToRevisit) { 148 | revisitIndices.push(currentIndex); 149 | indexToRevisit = false; 150 | } 151 | continue; 152 | } 153 | 154 | // if on a lower indent, add it as a junction and break 155 | if (info.spaceIndent < firstIndent) { 156 | revisitIndices.push(currentIndex); 157 | break; 158 | } 159 | 160 | indexToRevisit = true; 161 | 162 | // if not a number or on a lower indent 163 | if (info.number === undefined) { 164 | break; 165 | } 166 | 167 | // if already equal to the current line, no need to update 168 | if (info.number === currentNumber) { 169 | // if isLocal and there are 2 matches in a row, break 170 | if (isLocal && firstMatchInSuccession === false) { 171 | currentIndex += 1; 172 | break; 173 | } 174 | firstMatchInSuccession = false; 175 | 176 | currentNumber++; 177 | continue; 178 | } 179 | firstMatchInSuccession = true; // if no match was found, set the flag to true, so two matches in a row could be detected 180 | 181 | const updatedLine = this.getUpdatedLine(currentIndex, currentNumber, info, lineText); 182 | changes.push(updatedLine); 183 | currentNumber++; 184 | } 185 | 186 | return { changes, revisitIndices, endIndex: currentIndex }; 187 | } 188 | 189 | private getUpdatedLine(index: number, expectedNum: number, info: LineInfo, text: string): EditorChange { 190 | const newText = `${text.slice(0, info.spaceCharsNum)}${expectedNum}. ${text.slice(info.textOffset)}`; 191 | const updatedLine = { 192 | from: { line: index, ch: 0 }, 193 | to: { line: index, ch: text.length }, 194 | text: newText, 195 | }; 196 | return updatedLine; 197 | } 198 | 199 | private applyChangesToEditor(editor: Editor, changes: EditorChange[]) { 200 | if (changes.length > 0) { 201 | editor.transaction({ changes }); 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Editor, EditorPosition, MarkdownView } from "obsidian"; 2 | import { handlePaste, handleDrop } from "src/pasteAndDropHandler"; 3 | import { registerCommands } from "src/command-registration"; 4 | import Renumberer from "src/Renumberer"; 5 | import PluginSettings from "./src/settings-tab"; 6 | import SettingsManager, { DEFAULT_SETTINGS } from "src/SettingsManager"; 7 | import { reorderChecklist } from "src/checkbox"; 8 | import { ReorderResult } from "src/types"; 9 | import { EditorView } from "@codemirror/view"; 10 | 11 | export default class AutoReordering extends Plugin { 12 | private renumberer: Renumberer; 13 | private settingsManager: SettingsManager; 14 | private blockChanges = false; 15 | private checkboxClickedAt: number | undefined = undefined; 16 | private handleKeystrokeBound: (event: KeyboardEvent) => void; 17 | private handleMouseBound: (event: MouseEvent) => void; 18 | 19 | applyReordering(editor: Editor, start?: number, end?: number) { 20 | if (this.blockChanges) { 21 | return; 22 | } 23 | this.blockChanges = true; // Prevents multiple renumbering/checkbox updates. Reset to false on mouse/keyboard input 24 | 25 | const posToReturn = editor.getCursor(); 26 | 27 | let startIndex = start; 28 | let endIndex = end; 29 | let newLine: number | undefined; 30 | 31 | if (startIndex === undefined) { 32 | const result = this.getCurrIndex(editor); 33 | startIndex = result.index; 34 | newLine = result.mouseAt; 35 | } 36 | 37 | if (newLine !== undefined) { 38 | posToReturn.line = newLine; // if the cursor is outside the screen, place it in the same line the mouse just clicked at 39 | } 40 | 41 | // Handle checkbox updates 42 | let reorderResult: ReorderResult | undefined; 43 | if (this.settingsManager.getLiveCheckboxUpdate() === true) { 44 | reorderResult = reorderChecklist(editor, startIndex, end); 45 | } 46 | 47 | // Handle numbering updates 48 | if (this.settingsManager.getLiveNumberingUpdate() === true) { 49 | // if reordered checkbox, renumber between the original location and the new one 50 | if (reorderResult !== undefined) { 51 | startIndex = reorderResult.start; 52 | endIndex = reorderResult.limit; 53 | } 54 | 55 | this.renumberer.renumber(editor, startIndex, endIndex); 56 | } 57 | 58 | this.updateCursorPosition(editor, posToReturn, reorderResult); 59 | } 60 | 61 | async onload() { 62 | await this.loadSettings(); 63 | registerCommands(this); 64 | this.addSettingTab(new PluginSettings(this.app, this)); 65 | this.settingsManager = SettingsManager.getInstance(); 66 | this.renumberer = new Renumberer(); 67 | 68 | // editor-change listener 69 | this.registerEvent( 70 | this.app.workspace.on("editor-change", (editor: Editor) => { 71 | setTimeout(() => { 72 | this.applyReordering(editor); 73 | }); 74 | }) 75 | ); 76 | 77 | // editor-paste listener 78 | this.registerEvent( 79 | this.app.workspace.on("editor-paste", (evt: ClipboardEvent, editor: Editor) => { 80 | const { start, end } = handlePaste.call(this, evt, editor); 81 | // console.log(`start: ${start}, end: ${end}`); 82 | this.blockChanges = false; 83 | this.applyReordering(editor, start, end); 84 | }) 85 | ); 86 | 87 | // editor-drop listener 88 | this.registerEvent( 89 | this.app.workspace.on("editor-drop", (evt: DragEvent, editor: Editor) => { 90 | const { start, end } = handleDrop.call(this, evt, editor); 91 | this.blockChanges = false; 92 | this.applyReordering(editor, start, end); 93 | }) 94 | ); 95 | 96 | // keyboard stroke listener 97 | this.handleKeystrokeBound = this.handleKeystroke.bind(this); 98 | window.addEventListener("keydown", this.handleKeystrokeBound); // Keystroke listener 99 | 100 | // mouse listener 101 | this.handleMouseBound = this.handleMouseClick.bind(this); 102 | window.addEventListener("click", this.handleMouseBound); // mouse listener 103 | } 104 | 105 | handleKeystroke(event: KeyboardEvent) { 106 | // if special key, dont renumber automatically 107 | this.blockChanges = event.ctrlKey || event.metaKey || event.altKey; 108 | } 109 | 110 | // mouse listener 111 | async handleMouseClick(event: MouseEvent) { 112 | try { 113 | if (!this.settingsManager.getLiveCheckboxUpdate()) { 114 | return; 115 | } 116 | this.checkboxClickedAt = undefined; 117 | const target = event.target as HTMLElement; 118 | if (target.matches('[type="checkbox"]')) { 119 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 120 | if (activeView?.editor.hasFocus()) { 121 | // @ts-expect-error, not typed 122 | const editorView = activeView.editor.cm as EditorView; 123 | const editor = activeView.editor; // obsidian's editor 124 | const pos = editorView.posAtCoords({ x: event.clientX, y: event.clientY }); 125 | 126 | if (pos) { 127 | this.checkboxClickedAt = editor.offsetToPos(pos).line; 128 | } 129 | } 130 | } 131 | } catch (error) { 132 | console.error("Error in handleMouseClick:", error); 133 | this.checkboxClickedAt = undefined; 134 | } finally { 135 | this.blockChanges = false; 136 | } 137 | } 138 | 139 | async onunload() { 140 | window.removeEventListener("keydown", this.handleKeystrokeBound); 141 | window.removeEventListener("click", this.handleMouseBound); 142 | } 143 | 144 | async loadSettings() { 145 | const settingsManager = SettingsManager.getInstance(); 146 | const settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 147 | settingsManager.setSettings(settings); 148 | } 149 | 150 | async saveSettings() { 151 | const settingsManager = SettingsManager.getInstance(); 152 | await this.saveData(settingsManager.getSettings()); 153 | } 154 | 155 | getRenumberer(): Renumberer { 156 | return this.renumberer; 157 | } 158 | 159 | updateCursorPosition(editor: Editor, originalPos: EditorPosition, reorderResult?: ReorderResult): void { 160 | if (editor.somethingSelected() || !reorderResult) { 161 | return; 162 | } 163 | 164 | // if the line where the cursor is was not reordered, leave it as it was 165 | // else, put it at the end of the same line 166 | // ideal but not implemented: follow the original line to its new location 167 | let newPosition: EditorPosition; 168 | if (originalPos.line < reorderResult.start || reorderResult.limit <= originalPos.line) { 169 | newPosition = { 170 | line: originalPos.line, 171 | ch: originalPos.ch, 172 | }; 173 | } else { 174 | const line = editor.getLine(originalPos.line); 175 | newPosition = { 176 | line: originalPos.line, 177 | ch: line.length, // not keeping the originalPos.ch bad ux on new lines after checked items 178 | }; 179 | } 180 | editor.setCursor(newPosition); 181 | } 182 | 183 | getCurrIndex(editor: Editor): { index: number; mouseAt?: number } { 184 | const isInView = this.isCursorInView(); 185 | 186 | if (this.checkboxClickedAt !== undefined) { 187 | const index = this.checkboxClickedAt; 188 | this.checkboxClickedAt = undefined; 189 | 190 | if (!isInView) { 191 | return { index, mouseAt: index }; 192 | } 193 | return { index }; 194 | } 195 | 196 | const selection = editor.listSelections()[0]; 197 | return { index: Math.min(selection.anchor.line, selection.head.line) }; 198 | } 199 | 200 | isCursorInView(): boolean { 201 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 202 | if (activeView) { 203 | // @ts-expect-error, not typed 204 | const editorView = activeView.editor.cm as EditorView; 205 | const pos = editorView.state.selection.main.head; 206 | const coords = editorView.coordsAtPos(pos); 207 | if (coords) { 208 | const editorRect = editorView.dom.getBoundingClientRect(); 209 | return coords.top >= editorRect.top && coords.bottom <= editorRect.bottom; 210 | } 211 | } 212 | 213 | return true; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/settings-tab.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from "obsidian"; 2 | import AutoReordering from "../main"; 3 | import SettingsManager from "./SettingsManager"; 4 | import "styles.css"; 5 | 6 | export default class AutoRenumberingSettings extends PluginSettingTab { 7 | plugin: AutoReordering; 8 | settingsManager: SettingsManager; 9 | 10 | constructor(app: App, plugin: AutoReordering) { 11 | super(app, plugin); 12 | this.plugin = plugin; 13 | this.settingsManager = SettingsManager.getInstance(); 14 | } 15 | 16 | display(): void { 17 | const { containerEl } = this; 18 | 19 | containerEl.empty(); 20 | 21 | const githubEl = createFragment(); 22 | githubEl.appendText("For more information, visit "); 23 | githubEl.createEl("a", { 24 | href: "https://github.com/OmriLeviGit/Auto-List-Management-Obsidian", 25 | text: "Github", 26 | }); 27 | 28 | githubEl.appendText("."); 29 | containerEl.appendChild(githubEl); 30 | 31 | new Setting(containerEl).setHeading(); 32 | 33 | new Setting(containerEl) 34 | .setName("Tab size") 35 | .setDesc( 36 | "Set the indent size to the same size as in the editor's settings. Can be found under: Options > Editor > Tab indent size/Indent visual width." 37 | ) 38 | .addSlider((slider) => { 39 | slider 40 | .setValue(this.settingsManager.getIndentSize()) 41 | .setLimits(2, 8, 1) 42 | .setDynamicTooltip() 43 | .onChange(async (value) => { 44 | this.settingsManager.setIndentSize(value); 45 | await this.plugin.saveSettings(); 46 | }); 47 | }); 48 | 49 | new Setting(containerEl).setHeading().setName("Checklists"); 50 | 51 | new Setting(containerEl) 52 | .setName("Auto-sort on changes") 53 | .setDesc("Automatically sort checklists whenever checkboxes are checked or unchecked.") 54 | .addToggle((toggle) => 55 | toggle.setValue(this.settingsManager.getLiveCheckboxUpdate()).onChange(async (value) => { 56 | this.settingsManager.setLiveCheckboxUpdate(value); 57 | await this.plugin.saveSettings(); 58 | }) 59 | ); 60 | 61 | new Setting(containerEl) 62 | .setName("Place checked items at bottom") 63 | .setDesc( 64 | "When enabled, checked tasks will be placed at the bottom. When disabled, they will be at the top." 65 | ) 66 | .addToggle((toggle) => 67 | toggle.setValue(this.settingsManager.isCheckedItemsAtBottom()).onChange(async (value) => { 68 | this.settingsManager.setCheckedItemsAtBottom(value); 69 | await this.plugin.saveSettings(); 70 | }) 71 | ); 72 | 73 | new Setting(containerEl) 74 | .setName("Hierarchical checkbox reordering") 75 | .setDesc( 76 | "When enabled, checking a checkbox moves it along with all indented content (sub-tasks, paragraphs) as a block. When disabled, checkbox lines moves individually." 77 | ) 78 | .addToggle((toggle) => 79 | toggle.setValue(this.settingsManager.isHierarchicalReordering()).onChange(async (value) => { 80 | this.settingsManager.setHierarchicalReordering(value); 81 | await this.plugin.saveSettings(); 82 | }) 83 | ); 84 | 85 | const descEl = createFragment(); 86 | descEl.appendText("When enabled, tasks with any special checkbox characters will be sorted according to "); 87 | descEl.createEl("a", { 88 | href: "https://en.wikipedia.org/wiki/ASCII", 89 | text: "ASCII", 90 | }); 91 | descEl.appendText(". When disabled, only tasks marked for deletion will be sorted."); 92 | 93 | new Setting(containerEl) 94 | .setName("Sort all special checkboxes") 95 | .setDesc(descEl) 96 | .addToggle((toggle) => 97 | toggle.setValue(this.settingsManager.getSortSpecialChars()).onChange(async (value) => { 98 | this.settingsManager.setSortSpecialChars(value); 99 | await this.plugin.saveSettings(); 100 | }) 101 | ); 102 | 103 | new Setting(containerEl) 104 | .setName("Checkbox delete-characters") 105 | .setDesc( 106 | "Specify which checkbox characters mark tasks for deletion. Tasks with these characters are always sorted below tasks with other characters, and can be removed by using the delete command." 107 | ) 108 | .addText((text) => { 109 | text.setPlaceholder("Enter characters") 110 | .setValue(this.settingsManager.getCharsToDelete()) 111 | .onChange(async (value) => { 112 | this.settingsManager.setCharsToDelete(value); 113 | await this.plugin.saveSettings(); 114 | }); 115 | }); 116 | 117 | containerEl.createEl("div", { 118 | text: "Enter single characters separated by spaces (case-insensitive). Default: 'X'.", 119 | cls: "setting-item-description", 120 | }); 121 | 122 | containerEl.createEl("div", { 123 | text: "Example: '- /' means tasks with [x], [-], or [/] will be removed, while tasks with other characters like [>] will remain.", 124 | cls: "setting-item-description", 125 | }); 126 | 127 | new Setting(containerEl).setHeading(); 128 | 129 | new Setting(containerEl).setHeading().setName("Numbered lists"); 130 | 131 | // Create the dependent settings first to get their elements 132 | const smartPastingSetting = new Setting(containerEl) 133 | .setName("Smart pasting") 134 | .setDesc("Pasting keeps the sequencing consistent with the original numbered list.") 135 | .addToggle((toggle) => 136 | toggle.setValue(this.settingsManager.getSmartPasting()).onChange(async (value) => { 137 | this.settingsManager.setSmartPasting(value); 138 | await this.plugin.saveSettings(); 139 | }) 140 | ); 141 | 142 | const smartPastingToggleEl = smartPastingSetting.settingEl; 143 | 144 | const startsFromOneSetting = new Setting(containerEl) 145 | .setName("Start numbering from 1") 146 | .setDesc("Whether lists always start from 1 or preserve their original starting numbers.") 147 | .addToggle((toggle) => 148 | toggle.setValue(this.settingsManager.getStartsFromOne()).onChange(async (value) => { 149 | this.settingsManager.setStartsFromOne(value); 150 | await this.plugin.saveSettings(); 151 | }) 152 | ); 153 | 154 | const startsFromOneToggleEl = startsFromOneSetting.settingEl; 155 | 156 | // Now create the auto-renumber toggle and insert it at the top 157 | const autoRenumberSetting = new Setting(containerEl) 158 | .setName("Auto-renumber on changes") 159 | .setDesc("Automatically sort numbered lists as changes are made.") 160 | .addToggle((toggle) => 161 | toggle.setValue(this.settingsManager.getLiveNumberingUpdate()).onChange(async (value) => { 162 | this.settingsManager.setLiveNumberingUpdate(value); 163 | await this.plugin.saveSettings(); 164 | 165 | if (value) { 166 | smartPastingToggleEl.classList.add("setting-enabled"); 167 | smartPastingToggleEl.classList.remove("setting-disabled"); 168 | startsFromOneToggleEl.classList.add("setting-enabled"); 169 | startsFromOneToggleEl.classList.remove("setting-disabled"); 170 | } else { 171 | smartPastingToggleEl.classList.remove("setting-enabled"); 172 | smartPastingToggleEl.classList.add("setting-disabled"); 173 | startsFromOneToggleEl.classList.remove("setting-enabled"); 174 | startsFromOneToggleEl.classList.add("setting-disabled"); 175 | } 176 | }) 177 | ); 178 | 179 | // Move the auto-renumber setting to the top by moving the DOM element 180 | containerEl.insertBefore(autoRenumberSetting.settingEl, smartPastingSetting.settingEl); 181 | 182 | const isLiveNumberingUpdateEnabled = this.settingsManager.getLiveNumberingUpdate(); 183 | if (isLiveNumberingUpdateEnabled) { 184 | smartPastingToggleEl.classList.add("setting-enabled"); 185 | smartPastingToggleEl.classList.remove("setting-disabled"); 186 | startsFromOneToggleEl.classList.add("setting-enabled"); 187 | startsFromOneToggleEl.classList.remove("setting-disabled"); 188 | } else { 189 | smartPastingToggleEl.classList.add("setting-disabled"); 190 | smartPastingToggleEl.classList.remove("setting-enabled"); 191 | startsFromOneToggleEl.classList.add("setting-disabled"); 192 | startsFromOneToggleEl.classList.remove("setting-enabled"); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /tests/Renumberer.test.ts: -------------------------------------------------------------------------------- 1 | import "./__mocks__/main"; 2 | import { createMockEditor } from "./__mocks__/createMockEditor"; 3 | 4 | import Renumberer from "../src/Renumberer"; 5 | import SettingsManager from "src/SettingsManager"; 6 | 7 | describe("Dynamic renumbering tests", () => { 8 | let renumberer: Renumberer; 9 | 10 | beforeEach(() => { 11 | renumberer = new Renumberer(); 12 | SettingsManager.getInstance().setStartsFromOne(false); 13 | jest.clearAllMocks(); 14 | }); 15 | 16 | const testCases = [ 17 | { 18 | name: "First index of file", 19 | content: ["2. a", "4. b"], 20 | startIndex: 0, 21 | expectedContent: ["2. a", "3. b"], 22 | expectedEndIndex: 2, 23 | }, 24 | { 25 | name: "Last index of file", 26 | content: ["2. a", "6. c"], 27 | startIndex: 1, 28 | expectedContent: ["2. a", "3. c"], 29 | expectedEndIndex: 2, 30 | }, 31 | { 32 | name: "A single item", 33 | content: ["2. a"], 34 | startIndex: 0, 35 | expectedContent: ["2. a"], 36 | expectedEndIndex: 1, 37 | }, 38 | { 39 | name: "First index of list", 40 | content: ["text", "2. a", "4. b"], 41 | startIndex: 1, 42 | expectedContent: ["text", "2. a", "3. b"], 43 | expectedEndIndex: 3, 44 | }, 45 | { 46 | name: "Last index of list", 47 | content: ["2. a", "4. b", "text"], 48 | startIndex: 1, 49 | expectedContent: ["2. a", "3. b", "text"], 50 | expectedEndIndex: 2, 51 | }, 52 | { 53 | name: "A single item in the middle", 54 | content: ["text", "2. a", "text"], 55 | startIndex: 1, 56 | expectedContent: ["text", "2. a", "text"], 57 | expectedEndIndex: 2, 58 | }, 59 | { 60 | name: "Renumber the middle", 61 | content: ["2. a", "4. b", "5. c"], 62 | startIndex: 1, 63 | expectedContent: ["2. a", "3. b", "4. c"], 64 | expectedEndIndex: 3, 65 | }, 66 | { 67 | name: "Starting the number 0", 68 | content: ["0. a", "2. b"], 69 | startIndex: 0, 70 | expectedContent: ["0. a", "1. b"], 71 | expectedEndIndex: 2, 72 | }, 73 | { 74 | name: "Renumber in sequence", 75 | content: ["2. a", "6. b", "8. c"], 76 | startIndex: 0, 77 | expectedContent: ["2. a", "3. b", "4. c"], 78 | expectedEndIndex: 3, 79 | }, 80 | { 81 | name: "Should stop at text", 82 | content: ["2. a", "4. b", "text", "7. a", "9. b"], 83 | startIndex: 0, 84 | expectedContent: ["2. a", "3. b", "text", "7. a", "9. b"], 85 | expectedEndIndex: 2, 86 | }, 87 | { 88 | name: "Should stop at empty line", 89 | content: ["2. a", "4. b", "", "7. c"], 90 | startIndex: 0, 91 | expectedContent: ["2. a", "3. b", "", "7. c"], 92 | expectedEndIndex: 2, 93 | }, 94 | { 95 | name: "Renumber across indent (two spaces)", 96 | content: ["2. a", " 4. b", "7. c"], 97 | startIndex: 0, 98 | expectedContent: ["2. a", " 4. b", "3. c"], 99 | expectedEndIndex: 3, 100 | }, 101 | 102 | { 103 | name: "Renumber across indent (tab)", 104 | content: ["2. a", "\t4. b", "7. c"], 105 | startIndex: 0, 106 | expectedContent: ["2. a", "\t4. b", "3. c"], 107 | expectedEndIndex: 3, 108 | }, 109 | { 110 | name: "Renumber across indent (text)", 111 | content: ["2. a", "\ttext", "7. c"], 112 | startIndex: 0, 113 | expectedContent: ["2. a", "\ttext", "3. c"], 114 | expectedEndIndex: 3, 115 | }, 116 | { 117 | name: "Start from base and renumber the indent", 118 | content: ["2. a", "\t7. b", "\t3. c", "1. d"], 119 | startIndex: 0, 120 | expectedContent: ["2. a", "\t7. b", "\t8. c", "3. d"], 121 | expectedEndIndex: 4, 122 | }, 123 | { 124 | name: "Start from the indent, and renumber index before", 125 | content: ["2. a", "\t7. b", "1. c"], 126 | startIndex: 1, 127 | expectedContent: ["2. a", "\t7. b", "3. c"], 128 | expectedEndIndex: 3, 129 | }, 130 | { 131 | name: "Continues on descension", 132 | content: ["\t7. a", "\t5. b", "8. c", "4. d"], 133 | startIndex: 0, 134 | expectedContent: ["\t7. a", "\t8. b", "8. c", "9. d"], 135 | expectedEndIndex: 4, 136 | }, 137 | { 138 | name: "Cascade into multiple indents", 139 | content: [ 140 | "2. a", 141 | "2. b", 142 | " 5. c", 143 | " text", 144 | " 2. d", 145 | " 9. e", 146 | " 2. f", 147 | " 10. g", 148 | " 4. h", 149 | "3. i", 150 | "5. j", 151 | "6. k", 152 | "9. l", 153 | ], 154 | startIndex: 0, 155 | expectedContent: [ 156 | "2. a", 157 | "3. b", 158 | " 5. c", 159 | " text", 160 | " 6. d", 161 | " 9. e", 162 | " 10. f", 163 | " 7. g", 164 | " 8. h", 165 | "4. i", 166 | "5. j", 167 | "6. k", 168 | "9. l", 169 | ], 170 | expectedEndIndex: 12, 171 | }, 172 | ]; 173 | 174 | testCases.forEach(({ name, content, startIndex, expectedContent, expectedEndIndex }) => { 175 | test(name, () => { 176 | const editor = createMockEditor(content); 177 | const endIndex = renumberer.renumber(editor, startIndex); 178 | 179 | expectedContent.forEach((line, i) => { 180 | expect(editor.getLine(i)).toBe(line); 181 | }); 182 | 183 | expect(endIndex).toBe(expectedEndIndex); 184 | }); 185 | }); 186 | 187 | test("Renumber entire list (dynamic renumbering)", () => { 188 | const content = [ 189 | "2. a", 190 | "4. b", 191 | "5. c", 192 | "5. d", 193 | "6. e", 194 | "7. f", 195 | "7. g", 196 | "9. h", 197 | "10. i", 198 | "", 199 | "3. dontrenumber", 200 | "5. dontrenumber", 201 | ]; 202 | const expectedContent = [ 203 | "2. a", 204 | "3. b", 205 | "4. c", 206 | "5. d", 207 | "6. e", 208 | "7. f", 209 | "8. g", 210 | "9. h", 211 | "10. i", 212 | "", 213 | "3. dontrenumber", 214 | "5. dontrenumber", 215 | ]; 216 | 217 | const editor = createMockEditor(content); 218 | const renumberer = new Renumberer(); 219 | SettingsManager.getInstance().setStartsFromOne(false); 220 | renumberer.renumber(editor, 0, content.indexOf("")); 221 | 222 | expectedContent.forEach((line, i) => { 223 | expect(editor.getLine(i)).toBe(line); 224 | }); 225 | }); 226 | }); 227 | 228 | describe("Start from one renumbering tests", () => { 229 | let renumberer: Renumberer; 230 | 231 | beforeEach(() => { 232 | renumberer = new Renumberer(); 233 | SettingsManager.getInstance().setStartsFromOne(true); 234 | jest.clearAllMocks(); 235 | }); 236 | 237 | const testCases = [ 238 | { 239 | name: "First index of file", 240 | content: ["2. a", "4. b"], 241 | startIndex: 0, 242 | expectedContent: ["1. a", "2. b"], 243 | expectedEndIndex: 2, 244 | }, 245 | { 246 | name: "Last index of file", 247 | content: ["2. a", "6. c"], 248 | startIndex: 1, 249 | expectedContent: ["1. a", "2. c"], 250 | expectedEndIndex: 2, 251 | }, 252 | { 253 | name: "A single item", 254 | content: ["2. a"], 255 | startIndex: 0, 256 | expectedContent: ["1. a"], 257 | expectedEndIndex: 1, 258 | }, 259 | { 260 | name: "First index of list", 261 | content: ["text", "2. a", "4. b"], 262 | startIndex: 1, 263 | expectedContent: ["text", "1. a", "2. b"], 264 | expectedEndIndex: 3, 265 | }, 266 | { 267 | name: "Last index of list", 268 | content: ["2. a", "4. b", "text"], 269 | startIndex: 1, 270 | expectedContent: ["1. a", "2. b", "text"], 271 | expectedEndIndex: 2, 272 | }, 273 | { 274 | name: "A single item in the middle", 275 | content: ["text", "2. a", "text"], 276 | startIndex: 1, 277 | expectedContent: ["text", "1. a", "text"], 278 | expectedEndIndex: 2, 279 | }, 280 | { 281 | name: "Renumber the middle", 282 | content: ["2. a", "4. b", "5. c"], 283 | startIndex: 1, 284 | expectedContent: ["1. a", "2. b", "3. c"], 285 | expectedEndIndex: 3, 286 | }, 287 | { 288 | name: "Starting the number 0", 289 | content: ["0. a", "2. b"], 290 | startIndex: 0, 291 | expectedContent: ["1. a", "2. b"], 292 | expectedEndIndex: 2, 293 | }, 294 | { 295 | name: "Renumber in sequence", 296 | content: ["2. a", "6. b", "8. c"], 297 | startIndex: 0, 298 | expectedContent: ["1. a", "2. b", "3. c"], 299 | expectedEndIndex: 3, 300 | }, 301 | { 302 | name: "Should stop at text", 303 | content: ["2. a", "4. b", "text", "7. a", "9. b"], 304 | startIndex: 0, 305 | expectedContent: ["1. a", "2. b", "text", "7. a", "9. b"], 306 | expectedEndIndex: 2, 307 | }, 308 | { 309 | name: "Should stop at empty line", 310 | content: ["2. a", "4. b", "", "7. c"], 311 | startIndex: 0, 312 | expectedContent: ["1. a", "2. b", "", "7. c"], 313 | expectedEndIndex: 2, 314 | }, 315 | { 316 | name: "Renumber across indent (two spaces)", 317 | content: ["2. a", " 4. b", "7. c"], 318 | startIndex: 0, 319 | expectedContent: ["1. a", " 1. b", "2. c"], 320 | expectedEndIndex: 3, 321 | }, 322 | 323 | { 324 | name: "Renumber across indent (tab)", 325 | content: ["2. a", "\t4. b", "7. c"], 326 | startIndex: 0, 327 | expectedContent: ["1. a", "\t1. b", "2. c"], 328 | expectedEndIndex: 3, 329 | }, 330 | { 331 | name: "Renumber across indent (text)", 332 | content: ["2. a", "\ttext", "7. c"], 333 | startIndex: 0, 334 | expectedContent: ["1. a", "\ttext", "2. c"], 335 | expectedEndIndex: 3, 336 | }, 337 | { 338 | name: "Start from base and renumber the indent", 339 | content: ["2. a", "\t7. b", "\t3. c", "1. d"], 340 | startIndex: 0, 341 | expectedContent: ["1. a", "\t1. b", "\t2. c", "2. d"], 342 | expectedEndIndex: 4, 343 | }, 344 | { 345 | name: "Start from the indent, and renumber index before", 346 | content: ["2. a", "\t7. b", "5. c", "8. d"], 347 | startIndex: 1, 348 | expectedContent: ["1. a", "\t1. b", "2. c", "3. d"], 349 | expectedEndIndex: 4, 350 | }, 351 | { 352 | name: "Continues on descension", 353 | content: ["\t7. a", "\t5. b", "8. c", "4. d"], 354 | startIndex: 0, 355 | expectedContent: ["\t1. a", "\t2. b", "1. c", "2. d"], 356 | expectedEndIndex: 4, 357 | }, 358 | { 359 | name: "Cascade into multiple indents", 360 | content: [ 361 | "5. a", 362 | "3. b", 363 | " 5. c", 364 | " text", 365 | " 3. d", 366 | " 9. e", 367 | " 11. f", 368 | " 10. g", 369 | " 4. h", 370 | "4. i", 371 | "4. j", 372 | "5. k", 373 | "9. l", 374 | ], 375 | startIndex: 0, 376 | expectedContent: [ 377 | "1. a", 378 | "2. b", 379 | " 1. c", 380 | " text", 381 | " 2. d", 382 | " 1. e", 383 | " 2. f", 384 | " 3. g", 385 | " 4. h", 386 | "3. i", 387 | "4. j", 388 | "5. k", 389 | "9. l", 390 | ], 391 | expectedEndIndex: 12, 392 | }, 393 | ]; 394 | 395 | testCases.forEach(({ name, content, startIndex, expectedContent, expectedEndIndex }) => { 396 | test(name, () => { 397 | const editor = createMockEditor(content); 398 | const endIndex = renumberer.renumber(editor, startIndex); 399 | 400 | expectedContent.forEach((line, i) => { 401 | expect(editor.getLine(i)).toBe(line); 402 | }); 403 | 404 | expect(endIndex).toBe(expectedEndIndex); 405 | }); 406 | }); 407 | }); 408 | 409 | describe("Renumber entire list", () => { 410 | beforeEach(() => { 411 | jest.clearAllMocks(); 412 | }); 413 | 414 | test("Numbers the entire list when using renumberallListsInRange", () => { 415 | const renumberer = new Renumberer(); 416 | const content = ["1. a", "2. b", "3. c", "4. d", "6. e"]; 417 | const expectedContent = ["1. a", "2. b", "3. c", "4. d", "5. e"]; 418 | 419 | const editor = createMockEditor(content); 420 | 421 | renumberer.renumber(editor, 0, 1); 422 | 423 | expectedContent.forEach((line, i) => { 424 | expect(editor.getLine(i)).toBe(line); 425 | }); 426 | }); 427 | 428 | test("should renumber even when the limit is greater than content.length", () => { 429 | const renumberer = new Renumberer(); 430 | const content = ["1. a", "3. b"]; 431 | const expectedContent = ["1. a", "2. b"]; 432 | const editor = createMockEditor(content); 433 | 434 | renumberer.renumber(editor, 0, content.length + 1); 435 | 436 | expectedContent.forEach((line, i) => { 437 | expect(editor.getLine(i)).toBe(line); 438 | }); 439 | }); 440 | 441 | test("Dont renumber when limit < start", () => { 442 | const renumberer = new Renumberer(); 443 | const content = ["1. a", "3. b"]; 444 | const expectedContent = ["1. a", "3. b"]; 445 | const editor = createMockEditor(content); 446 | 447 | renumberer.renumber(editor, 1, 0); 448 | 449 | expectedContent.forEach((line, i) => { 450 | expect(editor.getLine(i)).toBe(line); 451 | }); 452 | }); 453 | }); 454 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import "./__mocks__/main"; 2 | import { createMockEditor } from "./__mocks__/createMockEditor"; 3 | 4 | import { 5 | getLineInfo, 6 | getListStart, 7 | findFirstNumbersByIndentFromEnd, 8 | getPrevItemIndex, 9 | findFirstNumbersAfterIndex, 10 | } from "src/utils"; 11 | 12 | describe("getLineInfo numbering tests", () => { 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | const testCases = [ 18 | { 19 | name: "single digit line", 20 | input: "1. text", 21 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: 1, textOffset: 3, checkboxChar: undefined }, 22 | }, 23 | { 24 | name: "multiple digits line", 25 | input: "123. text", 26 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: 123, textOffset: 5, checkboxChar: undefined }, 27 | }, 28 | { 29 | name: "no digits line", 30 | input: ". text", 31 | expected: { 32 | spaceCharsNum: 0, 33 | spaceIndent: 0, 34 | number: undefined, 35 | textOffset: 0, 36 | checkboxChar: undefined, 37 | }, 38 | }, 39 | { 40 | name: "line with leading spaces", 41 | input: " 1. test", 42 | expected: { spaceCharsNum: 2, spaceIndent: 2, number: 1, textOffset: 5, checkboxChar: undefined }, 43 | }, 44 | { 45 | name: "line with leading tab", 46 | input: "\t1. test", 47 | expected: { spaceCharsNum: 1, spaceIndent: 4, number: 1, textOffset: 4, checkboxChar: undefined }, 48 | }, 49 | { 50 | name: "line with leading two spaces and a tab", 51 | input: " \t12. test", 52 | expected: { spaceCharsNum: 3, spaceIndent: 6, number: 12, textOffset: 7, checkboxChar: undefined }, 53 | }, 54 | { 55 | name: "line with leading space and two tab", 56 | input: " \t\t12. test", 57 | expected: { spaceCharsNum: 3, spaceIndent: 9, number: 12, textOffset: 7, checkboxChar: undefined }, 58 | }, 59 | { 60 | name: "line without number and with trailing spaceCharsNum", 61 | input: " . text ", 62 | expected: { 63 | spaceCharsNum: 2, 64 | spaceIndent: 2, 65 | number: undefined, 66 | textOffset: 2, 67 | checkboxChar: undefined, 68 | }, 69 | }, 70 | { 71 | name: "line with invalid format", 72 | input: "A text", 73 | expected: { 74 | spaceCharsNum: 0, 75 | spaceIndent: 0, 76 | number: undefined, 77 | textOffset: 0, 78 | checkboxChar: undefined, 79 | }, 80 | }, 81 | ]; 82 | 83 | testCases.forEach(({ name, input, expected }) => { 84 | test(name, () => { 85 | const result = getLineInfo(input); 86 | expect(result).toEqual(expected); 87 | }); 88 | }); 89 | }); 90 | 91 | describe("getLineInfo checkbox tests", () => { 92 | beforeEach(() => { 93 | jest.clearAllMocks(); 94 | }); 95 | 96 | const checkboxTestCases = [ 97 | { 98 | name: "Test without checkbox", 99 | input: "text", 100 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: undefined, textOffset: 0, checkboxChar: undefined }, 101 | }, 102 | { 103 | name: "Test with unchecked checkbox at the start of a line", 104 | input: "- [ ] text", 105 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: undefined, textOffset: 0, checkboxChar: " " }, 106 | }, 107 | { 108 | name: "Test with checked checkbox at the start of a line", 109 | input: "- [x] text", 110 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: undefined, textOffset: 0, checkboxChar: "x" }, 111 | }, 112 | { 113 | name: "Test with uppercase character", 114 | input: "- [A] text", 115 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: undefined, textOffset: 0, checkboxChar: "A" }, 116 | }, 117 | { 118 | name: "Test with unalphabet character", 119 | input: "- [>] text", 120 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: undefined, textOffset: 0, checkboxChar: ">" }, 121 | }, 122 | { 123 | name: "Test with multiple characters", 124 | input: "- [ab] text", 125 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: undefined, textOffset: 0, checkboxChar: undefined }, 126 | }, 127 | { 128 | name: "Test with unchecked checkbox with space at the start", 129 | input: " - [ ] text", 130 | expected: { spaceCharsNum: 1, spaceIndent: 1, number: undefined, textOffset: 1, checkboxChar: " " }, 131 | }, 132 | { 133 | name: "Test with checked checkbox with space at the start", 134 | input: " - [x] text", 135 | expected: { spaceCharsNum: 1, spaceIndent: 1, number: undefined, textOffset: 1, checkboxChar: "x" }, 136 | }, 137 | { 138 | name: "Test with unchecked checkbox with tab indentation", 139 | input: "\t- [ ] text", 140 | expected: { spaceCharsNum: 1, spaceIndent: 4, number: undefined, textOffset: 1, checkboxChar: " " }, 141 | }, 142 | { 143 | name: "Test with checked checkbox with tab indentation", 144 | input: "\t- [x] text", 145 | expected: { spaceCharsNum: 1, spaceIndent: 4, number: undefined, textOffset: 1, checkboxChar: "x" }, 146 | }, 147 | { 148 | name: "Test with unchecked checkbox in numbered list", 149 | input: "123. [ ] text", 150 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: 123, textOffset: 5, checkboxChar: " " }, 151 | }, 152 | { 153 | name: "Test checkbox with more spaces between the numbering and the checkbox", 154 | input: "123. [ ] text", 155 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: 123, textOffset: 5, checkboxChar: " " }, 156 | }, 157 | { 158 | name: "Test with checked checkbox in numbered list", 159 | input: "123. [x] text", 160 | expected: { spaceCharsNum: 0, spaceIndent: 0, number: 123, textOffset: 5, checkboxChar: "x" }, 161 | }, 162 | { 163 | name: "Test with unchecked checkbox and leading space in numbered list", 164 | input: " 123. [ ] text", 165 | expected: { spaceCharsNum: 1, spaceIndent: 1, number: 123, textOffset: 6, checkboxChar: " " }, 166 | }, 167 | { 168 | name: "Test with checked checkbox and leading space in numbered list", 169 | input: " 123. [x] text", 170 | expected: { spaceCharsNum: 1, spaceIndent: 1, number: 123, textOffset: 6, checkboxChar: "x" }, 171 | }, 172 | { 173 | name: "Test with unchecked checkbox and tab indentation in numbered list", 174 | input: "\t123. [ ] text", 175 | expected: { spaceCharsNum: 1, spaceIndent: 4, number: 123, textOffset: 6, checkboxChar: " " }, 176 | }, 177 | { 178 | name: "Test with checked checkbox and tab indentation in numbered list", 179 | input: "\t123. [x] text", 180 | expected: { spaceCharsNum: 1, spaceIndent: 4, number: 123, textOffset: 6, checkboxChar: "x" }, 181 | }, 182 | ]; 183 | 184 | checkboxTestCases.forEach(({ name, input, expected }) => { 185 | test(name, () => { 186 | const result = getLineInfo(input); 187 | expect(result).toEqual(expected); 188 | }); 189 | }); 190 | }); 191 | 192 | describe("getListStart tests", () => { 193 | beforeEach(() => { 194 | jest.clearAllMocks(); 195 | }); 196 | 197 | const testCases = [ 198 | { 199 | name: "start of a list", 200 | content: ["1. item 1", "2. item 2"], 201 | index: 1, 202 | expected: 0, 203 | }, 204 | { 205 | name: "middle of a list", 206 | content: ["1. item 1", "2. item 2", "3. item 3"], 207 | index: 2, 208 | expected: 0, 209 | }, 210 | { 211 | name: "no number in the line", 212 | content: ["1. item 1", "2. item 2", "not a number"], 213 | index: 2, 214 | expected: 2, 215 | }, 216 | { 217 | name: "accessing a negative line index", 218 | content: ["1. item 1", "2. item 2"], 219 | index: -1, 220 | expected: undefined, 221 | }, 222 | { 223 | name: "out of bounds line index", 224 | content: ["1. item 1", "2. item 2"], 225 | index: 3, 226 | expected: undefined, 227 | }, 228 | { 229 | name: "only empty lines above", 230 | content: ["", "", "3. item 3"], 231 | index: 2, 232 | expected: 2, 233 | }, 234 | { 235 | name: "indented", 236 | content: ["1. text", " 1. text", "2. text"], 237 | index: 2, 238 | expected: 0, 239 | }, 240 | ]; 241 | 242 | testCases.forEach(({ name, content, index, expected }) => { 243 | test(name, () => { 244 | const editor = createMockEditor(content); 245 | const result = getListStart(editor, index); 246 | expect(result).toBe(expected); 247 | }); 248 | }); 249 | }); 250 | 251 | describe("getPrevItemIndex tests", () => { 252 | beforeEach(() => { 253 | jest.clearAllMocks(); 254 | }); 255 | 256 | const testCases = [ 257 | { 258 | name: "First", 259 | content: ["1. a", "2. b"], 260 | index: 0, 261 | expectedResult: undefined, 262 | }, 263 | { 264 | name: "Not first", 265 | content: ["1. a", "2. b"], 266 | index: 1, 267 | expectedResult: 0, 268 | }, 269 | { 270 | name: "One item", 271 | content: ["1. a"], 272 | index: 0, 273 | expectedResult: undefined, 274 | }, 275 | { 276 | name: "One item indented", 277 | content: [" 1. a"], 278 | index: 0, 279 | expectedResult: undefined, 280 | }, 281 | { 282 | name: "First indented", 283 | content: ["1. a", " 2. b"], 284 | index: 1, 285 | expectedResult: undefined, 286 | }, 287 | { 288 | name: "Second indented", 289 | content: ["1. a", " 2. b", " 3. b"], 290 | index: 2, 291 | expectedResult: 1, 292 | }, 293 | { 294 | name: "Second with indent in the middle", 295 | content: ["1. a", " 2. b", "3. c"], 296 | index: 2, 297 | expectedResult: 0, 298 | }, 299 | { 300 | name: "Lower indent in the middle", 301 | content: ["1. a", " 2. b", "3. c", " 4. d"], 302 | index: 3, 303 | expectedResult: undefined, 304 | }, 305 | { 306 | name: "Text alone", 307 | content: ["text"], 308 | index: 0, 309 | expectedResult: undefined, 310 | }, 311 | { 312 | name: "Text before", 313 | content: ["text", "1. a"], 314 | index: 1, 315 | expectedResult: undefined, 316 | }, 317 | { 318 | name: "Text before indented", 319 | content: ["text", " 1. a"], 320 | index: 1, 321 | expectedResult: undefined, 322 | }, 323 | { 324 | name: "Indented text before indented", 325 | content: [" text", " 1. a"], 326 | index: 1, 327 | expectedResult: undefined, 328 | }, 329 | ]; 330 | 331 | testCases.forEach(({ name, content, index, expectedResult }) => { 332 | test(name, () => { 333 | const editor = createMockEditor(content); 334 | const res = getPrevItemIndex(editor, index); 335 | expect(res).toBe(expectedResult); 336 | }); 337 | }); 338 | }); 339 | 340 | describe("findFirstNumbersByIndentFromEnd tests", () => { 341 | beforeEach(() => { 342 | jest.clearAllMocks(); 343 | }); 344 | const testCases = [ 345 | { 346 | name: "Basic numbered list", 347 | lines: ["1. item", "2. item", "3. item"], 348 | expectedResult: [0], 349 | }, 350 | { 351 | name: "List with different indent levels", 352 | lines: ["1. item", " 2. subitem", " 3. subitem", "4. item"], 353 | expectedResult: [0], 354 | }, 355 | { 356 | name: "List with multiple indent levels", 357 | lines: ["1. item", " 2. subitem", " 3. subsubitem"], 358 | expectedResult: [0, , , , 1, , , , 2], 359 | }, 360 | { 361 | name: "Empty list", 362 | lines: [], 363 | expectedResult: [], 364 | }, 365 | { 366 | name: "List with no numbers", 367 | lines: ["item", "subitem", "another item"], 368 | expectedResult: [], 369 | }, 370 | { 371 | name: "List with mixed numbered and non-numbered lines", 372 | lines: ["1. item", "non-numbered", " 2. subitem"], 373 | expectedResult: [, , 2], 374 | }, 375 | { 376 | name: "List with decreasing indentation", 377 | lines: [" 1. deeply nested", " 2. less nested", "3. top level"], 378 | expectedResult: [2], 379 | }, 380 | { 381 | name: "List where some indents have no numbers", 382 | lines: ["1. item", " non-numbered", " 2. subsubitem"], 383 | expectedResult: [0, , , 2], 384 | }, 385 | ]; 386 | 387 | testCases.forEach(({ name, lines, expectedResult }) => { 388 | test(name, () => { 389 | // This test directly calls the function with the lines array 390 | const result = findFirstNumbersByIndentFromEnd(lines); 391 | expect(result).toEqual(expectedResult); 392 | }); 393 | }); 394 | }); 395 | 396 | describe("findFirstNumbersAfterIndex tests", () => { 397 | beforeEach(() => { 398 | jest.clearAllMocks(); 399 | }); 400 | 401 | const testCases = [ 402 | { 403 | name: "Basic numbered list", 404 | content: "1. item\n2. item\n3. item", 405 | startIndex: 0, 406 | expectedResult: [1], // Now includes the startIndex line 407 | }, 408 | { 409 | name: "List with different indent levels", 410 | content: "1. item\n 2. subitem\n 3. subitem\n4. item", 411 | startIndex: 0, 412 | expectedResult: [1], // Now includes the startIndex line 413 | }, 414 | { 415 | name: "Starting from middle of list", 416 | content: "1. item\n 2. subitem\n 3. subitem\n4. item", 417 | startIndex: 1, 418 | expectedResult: [4, , , , 2], // Starts from line 1 ("2. subitem") 419 | }, 420 | { 421 | name: "Starting from last item", 422 | content: "1. item\n 2. subitem\n 3. subitem\n4. item", 423 | startIndex: 3, 424 | expectedResult: [4], // Now includes the startIndex line itself 425 | }, 426 | { 427 | name: "Starting from indented item", 428 | content: "1. item\n 2. subitem\n 3. subsubitem\n 4. subitem\n5. item", 429 | startIndex: 1, 430 | expectedResult: [5, , , , 2], // Starts from line 1 ("2. subitem") 431 | }, 432 | { 433 | name: "Multiple indent levels", 434 | content: "1. item\n 2. subitem\n 3. subsubitem\n 4. deepitem\n5. item", 435 | startIndex: 0, 436 | expectedResult: [1], // Now includes the startIndex line 437 | }, 438 | { 439 | name: "Non-numbered lines in between", 440 | content: "1. item\n 2. subitem\nnon-numbered\n 3. subitem\n4. item", 441 | startIndex: 1, 442 | expectedResult: [4, , , , 2], // Starts from line 1 ("2. subitem") 443 | }, 444 | { 445 | name: "Starting from non-numbered line", 446 | content: "1. item\nnon-numbered\n2. item", 447 | startIndex: 1, 448 | expectedResult: [2], // Starts from a non-numbered line so only finds line 2 449 | }, 450 | ]; 451 | 452 | testCases.forEach(({ name, content, startIndex, expectedResult }) => { 453 | test(name, () => { 454 | const lines = content.split("\n"); 455 | const editor = createMockEditor(lines); 456 | 457 | const result = findFirstNumbersAfterIndex(editor, startIndex); 458 | expect(result).toEqual(expectedResult); 459 | }); 460 | }); 461 | }); 462 | -------------------------------------------------------------------------------- /src/checkbox.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorChange } from "obsidian"; 2 | import { getLineInfo } from "./utils"; 3 | import { LineInfo, ReorderResult, ChecklistBlock } from "./types"; 4 | import SettingsManager from "./SettingsManager"; 5 | 6 | function reorderChecklist(editor: Editor, start: number, limit?: number): ReorderResult | undefined { 7 | const result = limit === undefined ? reorderAtIndex(editor, start) : reorderAllListsInRange(editor, start, limit); 8 | 9 | if (!result) { 10 | return undefined; 11 | } 12 | 13 | const { changes, reorderResult } = result; 14 | applyChangesToEditor(editor, changes); 15 | 16 | return reorderResult; 17 | } 18 | 19 | // renumbers all numbered lists in specified range 20 | function reorderAllListsInRange( 21 | editor: Editor, 22 | start: number, 23 | limit: number 24 | ): { reorderResult: ReorderResult; changes: EditorChange[] } | undefined { 25 | const isInvalidRange = start < 0 || editor.lastLine() + 1 < limit || limit < start; 26 | const changes: EditorChange[] = []; 27 | 28 | let i = start; 29 | let currentStart: number | undefined = undefined; 30 | let end = i; 31 | 32 | if (isInvalidRange) { 33 | console.error( 34 | `reorderAllListsInRange is invalid with index=${start}, limit=${limit}. editor.lastLine()=${editor.lastLine()}` 35 | ); 36 | 37 | return; 38 | } 39 | 40 | for (; i < limit; i++) { 41 | const reorderData = reorderAtIndex(editor, i); 42 | 43 | if (reorderData === undefined || reorderData.changes === undefined) { 44 | continue; 45 | } 46 | 47 | changes.push(...reorderData.changes); 48 | 49 | if (currentStart === undefined) { 50 | currentStart = reorderData.reorderResult.start; 51 | } 52 | 53 | end = reorderData.reorderResult.limit; 54 | i = end; 55 | 56 | while (shouldBeSortedAsChecked(getLineInfo(editor.getLine(i)).checkboxChar) !== undefined) { 57 | i++; 58 | } 59 | } 60 | 61 | if (changes.length === 0) return undefined; 62 | 63 | return { 64 | reorderResult: { 65 | start: currentStart ?? start, 66 | limit: end, 67 | }, 68 | changes, 69 | }; 70 | } 71 | 72 | function reorderAtIndex( 73 | editor: Editor, 74 | index: number 75 | ): { reorderResult: ReorderResult; changes: EditorChange[] } | undefined { 76 | const line = editor.getLine(index); 77 | const startInfo = getLineInfo(line); 78 | const hasContent = hasCheckboxContent(line); 79 | 80 | // if not a checkbox or without any content, dont reorder 81 | if (shouldBeSortedAsChecked(startInfo.checkboxChar) === undefined || hasContent === false) { 82 | return; 83 | } 84 | 85 | const checklistStartIndex = getChecklistStart(editor, index); 86 | 87 | const { orderedItems, reorderResult } = reorder(editor, checklistStartIndex, startInfo); 88 | 89 | if (orderedItems.length === 0) { 90 | return; // no changes are needed 91 | } 92 | 93 | const { start: startIndex, limit: endIndex } = reorderResult; 94 | 95 | const newText = endIndex > editor.lastLine() ? orderedItems.join("\n") : orderedItems.join("\n") + "\n"; // adjust for the last line in note 96 | 97 | const change: EditorChange = { 98 | from: { line: startIndex, ch: 0 }, 99 | to: { line: endIndex, ch: 0 }, 100 | text: newText, 101 | }; 102 | 103 | return { 104 | changes: [change], 105 | reorderResult: { 106 | start: startIndex, 107 | limit: endIndex, 108 | }, 109 | }; 110 | } 111 | 112 | /** 113 | * NEW BLOCK-BASED IMPLEMENTATION 114 | * 115 | * Reorders checkbox items in a checklist, treating each checkbox and its indented 116 | * children as a single block that moves together. 117 | * 118 | * Key changes from the old line-based approach: 119 | * 1. Extracts checkbox blocks (parent + all indented children) 120 | * 2. Reorders blocks instead of individual lines 121 | * 3. Recursively processes nested checkboxes within each block 122 | * 123 | * This enables hierarchical checkbox reordering where checking a parent checkbox 124 | * moves the entire subtree (including indented content and sub-checkboxes). 125 | */ 126 | function reorder( 127 | editor: Editor, 128 | index: number, 129 | startInfo: LineInfo 130 | ): { orderedItems: string[]; reorderResult: ReorderResult } { 131 | const checkedItemsAtBottom = SettingsManager.getInstance().isCheckedItemsAtBottom(); 132 | const charsToDelete = getCharsToDelete(); 133 | 134 | const startIndex = findReorderStartPosition(editor, index, startInfo, checkedItemsAtBottom); 135 | 136 | // Phase 1: Extract all checkbox blocks at the current indentation level 137 | const { blocks, finishedAt } = extractBlocksAtLevel(editor, startIndex, startInfo); 138 | 139 | // If no blocks found, return empty (no changes needed) 140 | if (blocks.length === 0) { 141 | return { 142 | orderedItems: [], 143 | reorderResult: { 144 | start: startIndex, 145 | limit: startIndex, 146 | }, 147 | }; 148 | } 149 | 150 | // Phase 2: Reorder blocks based on checkbox state 151 | const reorderedBlocks = reorderBlocks(blocks, checkedItemsAtBottom, charsToDelete); 152 | 153 | // Phase 3: Recursively process each block and flatten to lines 154 | // This handles nested checkboxes within each block 155 | const allLines: string[] = []; 156 | for (const block of reorderedBlocks) { 157 | const blockLines = recursivelyProcessBlock(block, checkedItemsAtBottom, charsToDelete); 158 | allLines.push(...blockLines); 159 | } 160 | 161 | // If automatic renumbering is enabled, preserve the original first line's number 162 | // This prevents glitches before the renumbering pass runs 163 | if (SettingsManager.getInstance().getLiveNumberingUpdate() && allLines.length > 0) { 164 | const originalLine = editor.getLine(startIndex); 165 | const originalInfo = getLineInfo(originalLine); 166 | const newFirstInfo = getLineInfo(allLines[0]); 167 | 168 | if (originalInfo.number !== undefined && newFirstInfo.number !== undefined) { 169 | // Reconstruct the line with original number but new content 170 | // This handles different digit lengths correctly (1. vs 12. vs 123.) 171 | const prefix = originalLine.substring(0, originalInfo.spaceCharsNum); 172 | const suffix = allLines[0].substring(newFirstInfo.textOffset); 173 | allLines[0] = prefix + originalInfo.number + ". " + suffix; 174 | } 175 | } 176 | 177 | // Phase 4: Optimization - Remove unchanged lines from the beginning 178 | let count = 0; 179 | for (; count < allLines.length; count++) { 180 | if (allLines[count] !== editor.getLine(startIndex + count)) { 181 | break; 182 | } 183 | } 184 | 185 | const orderedItems = allLines.slice(count); 186 | const newStart = startIndex + count; 187 | 188 | // Phase 5: Optimization - Remove unchanged lines from the end 189 | // After removing lines from the beginning (count items), we need to compare 190 | // the end of orderedItems with the corresponding lines in the editor. 191 | // orderedItems[i] should match editor.getLine(newStart + i) 192 | for (let i = orderedItems.length - 1; i >= 0; i--) { 193 | if (orderedItems[i] !== editor.getLine(newStart + i)) { 194 | orderedItems.splice(i + 1); 195 | break; 196 | } 197 | } 198 | 199 | // When no changes needed and hierarchical mode is OFF, return appropriate boundary indices 200 | if (orderedItems.length === 0 && !SettingsManager.getInstance().isHierarchicalReordering()) { 201 | if (checkedItemsAtBottom) { 202 | return { 203 | orderedItems, 204 | reorderResult: { start: finishedAt, limit: finishedAt }, 205 | }; 206 | } 207 | 208 | const firstIsChecked = 209 | blocks[0]?.parentInfo.checkboxChar !== undefined && blocks[0].parentInfo.checkboxChar !== " "; 210 | if (!firstIsChecked) { 211 | return { 212 | orderedItems, 213 | reorderResult: { start: startIndex, limit: startIndex }, 214 | }; 215 | } 216 | 217 | let boundaryIndex = startIndex; 218 | for (const block of blocks) { 219 | const isChecked = block.parentInfo.checkboxChar !== undefined && block.parentInfo.checkboxChar !== " "; 220 | if (!isChecked) break; 221 | boundaryIndex += 1 + block.childLines.length; 222 | } 223 | 224 | return { 225 | orderedItems, 226 | reorderResult: { start: boundaryIndex, limit: boundaryIndex }, 227 | }; 228 | } 229 | 230 | return { 231 | orderedItems, 232 | reorderResult: { 233 | start: newStart, 234 | limit: newStart + orderedItems.length, 235 | }, 236 | }; 237 | } 238 | 239 | function getChecklistStart(editor: Editor, index: number): number { 240 | if (index === 0) { 241 | return index; 242 | } 243 | 244 | const startInfo = getLineInfo(editor.getLine(index)); 245 | let i = index - 1; 246 | 247 | while (0 <= i) { 248 | const currInfo = getLineInfo(editor.getLine(i)); 249 | if (!isSameStatus(startInfo, currInfo)) { 250 | break; 251 | } 252 | i--; 253 | } 254 | 255 | return i + 1; 256 | } 257 | 258 | /** 259 | * Finds the starting position for reordering when checked items go at the bottom. 260 | * 261 | * In the old line-based approach, this would skip unchecked lines. 262 | * In the new block-based approach, we skip entire unchecked blocks. 263 | * 264 | * HOWEVER, for the block-based reordering to work correctly, we should always 265 | * start from the original startIndex because we extract and reorder ALL blocks 266 | * in the range, not just the checked ones. 267 | * 268 | * The old optimization of skipping unchecked items doesn't apply anymore because: 269 | * 1. We extract all blocks (both checked and unchecked) at once 270 | * 2. We reorder them based on their checked state 271 | * 3. We only apply changes where actual reordering occurred 272 | */ 273 | function findReorderStartPosition( 274 | editor: Editor, 275 | startIndex: number, 276 | startInfo: LineInfo, 277 | checkedItemsAtBottom: boolean 278 | ): number { 279 | // Always start from the beginning for block-based reordering 280 | return startIndex; 281 | } 282 | 283 | // Status = Both lines are numbered \ unnumbered 284 | function isSameStatus(info1: LineInfo, info2: LineInfo): boolean { 285 | const hasSameNumberStatus = (info1.number !== undefined) === (info2.number !== undefined); 286 | const hasSameIndentation = info1.spaceIndent === info2.spaceIndent; 287 | const hasSameCheckboxStatus = 288 | (shouldBeSortedAsChecked(info1.checkboxChar) !== undefined) === 289 | (shouldBeSortedAsChecked(info2.checkboxChar) !== undefined); 290 | 291 | if (hasSameNumberStatus && hasSameIndentation && hasSameCheckboxStatus) { 292 | return true; 293 | } 294 | 295 | return false; 296 | } 297 | 298 | /** 299 | * Extracts a checkbox block consisting of the parent checkbox line 300 | * and all lines indented more than the parent. 301 | * 302 | * A block includes: 303 | * - The checkbox line itself (parent) 304 | * - ALL subsequent lines with greater indentation (children) 305 | * - This includes text, code, nested checkboxes, etc. 306 | * 307 | * The block ends when we encounter: 308 | * - A line with equal or less indentation than the parent 309 | * - The end of the editor 310 | * 311 | * @param editor - The Obsidian editor instance 312 | * @param startLineIndex - Index of the checkbox line (parent) 313 | * @returns Object containing the extracted block and the index of the next line after the block 314 | */ 315 | function extractBlock(editor: Editor, startLineIndex: number): { block: ChecklistBlock; nextIndex: number } { 316 | const parentLine = editor.getLine(startLineIndex); 317 | const parentInfo = getLineInfo(parentLine); 318 | const parentIndent = parentInfo.spaceIndent; 319 | const childLines: string[] = []; 320 | 321 | let i = startLineIndex + 1; 322 | 323 | // Only collect children if hierarchical reordering is enabled 324 | const useHierarchical = SettingsManager.getInstance().isHierarchicalReordering(); 325 | 326 | if (useHierarchical) { 327 | while (i <= editor.lastLine()) { 328 | const line = editor.getLine(i); 329 | const lineInfo = getLineInfo(line); 330 | 331 | if (lineInfo.spaceIndent <= parentIndent) { 332 | break; 333 | } 334 | 335 | childLines.push(line); 336 | i++; 337 | } 338 | } 339 | 340 | return { 341 | block: { 342 | parentLine, 343 | parentInfo, 344 | childLines, 345 | }, 346 | nextIndex: i, 347 | }; 348 | } 349 | 350 | /** 351 | * Extracts all checkbox blocks at the same indentation level within a checklist group. 352 | * 353 | * This function: 354 | * 1. Starts at startIndex and processes lines forward 355 | * 2. Only extracts blocks (checkboxes) at the targetIndent level 356 | * 3. Stops when it hits a line with different status (using isSameStatus) 357 | * 4. Returns all extracted blocks and the index where processing stopped 358 | * 359 | * @param editor - The Obsidian editor instance 360 | * @param startIndex - Index to start searching from 361 | * @param startInfo - LineInfo of the line that triggered reordering (for status comparison) 362 | * @returns Object containing array of blocks and the index where we finished 363 | */ 364 | function extractBlocksAtLevel( 365 | editor: Editor, 366 | startIndex: number, 367 | startInfo: LineInfo 368 | ): { blocks: ChecklistBlock[]; finishedAt: number } { 369 | const blocks: ChecklistBlock[] = []; 370 | const targetIndent = startInfo.spaceIndent; 371 | let i = startIndex; 372 | 373 | // Process lines while they're part of the same checklist group 374 | while (i <= editor.lastLine()) { 375 | const line = editor.getLine(i); 376 | const currInfo = getLineInfo(line); 377 | 378 | // Stop if the line status differs from the starting group 379 | if (!isSameStatus(startInfo, currInfo)) { 380 | break; 381 | } 382 | 383 | // If this line is at our target indentation and has a checkbox, extract it as a block 384 | if (currInfo.spaceIndent === targetIndent && currInfo.checkboxChar !== undefined) { 385 | const { block, nextIndex } = extractBlock(editor, i); 386 | blocks.push(block); 387 | i = nextIndex; // Skip past the entire block (parent + all children) 388 | } else { 389 | // This line is not a checkbox at our level, skip it 390 | i++; 391 | } 392 | } 393 | 394 | return { 395 | blocks, 396 | finishedAt: i, 397 | }; 398 | } 399 | 400 | /** 401 | * Reorders checkbox blocks based on their checkbox state. 402 | * 403 | * Categorizes blocks into: 404 | * - Unchecked blocks (checkbox char = ' ') 405 | * - Checked blocks (grouped by checkbox character, then sorted) 406 | * - Delete blocks (checkbox chars in charsToDelete set) 407 | * 408 | * @param blocks - Array of checkbox blocks to reorder 409 | * @param checkedItemsAtBottom - If true, checked items go to bottom; if false, to top 410 | * @param charsToDelete - Set of checkbox characters that should be treated as "to delete" 411 | * @returns Reordered array of blocks 412 | */ 413 | function reorderBlocks( 414 | blocks: ChecklistBlock[], 415 | checkedItemsAtBottom: boolean, 416 | charsToDelete: Set 417 | ): ChecklistBlock[] { 418 | const uncheckedBlocks: ChecklistBlock[] = []; 419 | const checkedMap: Map = new Map(); 420 | const deleteBlocks: ChecklistBlock[] = []; 421 | 422 | // Phase 1: Categorize blocks 423 | for (const block of blocks) { 424 | const char = block.parentInfo.checkboxChar!; // We know it exists because we extracted checkbox blocks 425 | 426 | if (charsToDelete.has(char.toLowerCase())) { 427 | deleteBlocks.push(block); 428 | } else if (shouldBeSortedAsChecked(char)) { 429 | if (!checkedMap.has(char)) { 430 | checkedMap.set(char, []); 431 | } 432 | checkedMap.get(char)!.push(block); 433 | } else { 434 | uncheckedBlocks.push(block); 435 | } 436 | } 437 | 438 | // Phase 2: Sort checked blocks by checkbox character 439 | const checkedBlocks: ChecklistBlock[] = []; 440 | const keys = Array.from(checkedMap.keys()).sort(); 441 | for (const key of keys) { 442 | checkedBlocks.push(...checkedMap.get(key)!); 443 | } 444 | 445 | // Add delete blocks to the end of checked blocks 446 | checkedBlocks.push(...deleteBlocks); 447 | 448 | // Phase 3: Combine based on settings 449 | const reordered = checkedItemsAtBottom 450 | ? [...uncheckedBlocks, ...checkedBlocks] 451 | : [...checkedBlocks, ...uncheckedBlocks]; 452 | 453 | return reordered; 454 | } 455 | 456 | /** 457 | * Extracts checkbox blocks from an array of lines (instead of from Editor). 458 | * Used for processing nested checkboxes within a block's children. 459 | * 460 | * @param lines - Array of lines to extract blocks from 461 | * @param targetIndent - Indentation level to look for checkboxes at 462 | * @returns Object containing extracted blocks and the index where extraction ended 463 | */ 464 | function extractBlocksFromLines( 465 | lines: string[], 466 | targetIndent: number 467 | ): { blocks: ChecklistBlock[]; lastProcessedIndex: number } { 468 | const blocks: ChecklistBlock[] = []; 469 | let i = 0; 470 | 471 | while (i < lines.length) { 472 | const line = lines[i]; 473 | const lineInfo = getLineInfo(line); 474 | 475 | // If this is a checkbox at the target indentation level, extract it as a block 476 | if (lineInfo.spaceIndent === targetIndent && lineInfo.checkboxChar !== undefined) { 477 | const parentLine = line; 478 | const parentInfo = lineInfo; 479 | const childLines: string[] = []; 480 | 481 | const useHierarchical = SettingsManager.getInstance().isHierarchicalReordering(); 482 | 483 | let j = i + 1; 484 | if (useHierarchical) { 485 | while (j < lines.length) { 486 | const childLine = lines[j]; 487 | const childLineInfo = getLineInfo(childLine); 488 | 489 | if (childLineInfo.spaceIndent <= targetIndent) { 490 | break; 491 | } 492 | 493 | childLines.push(childLine); 494 | j++; 495 | } 496 | } 497 | 498 | blocks.push({ 499 | parentLine, 500 | parentInfo, 501 | childLines, 502 | }); 503 | 504 | i = j; 505 | } else { 506 | i++; 507 | } 508 | } 509 | 510 | return { 511 | blocks, 512 | lastProcessedIndex: i, 513 | }; 514 | } 515 | 516 | /** 517 | * Recursively processes a checkbox block, reordering any nested checkboxes within its children. 518 | * 519 | * This function: 520 | * 1. Takes a block (parent checkbox + all indented children) 521 | * 2. Looks for nested checkboxes at the immediate child indentation level 522 | * 3. Reorders those nested checkboxes based on their checkbox state 523 | * 4. Recursively processes each nested block 524 | * 5. Preserves non-checkbox lines in their appropriate positions 525 | * 6. Returns all lines flattened (parent + processed children) 526 | * 527 | * Example: 528 | * Input block: 529 | * - [ ] Parent 530 | * Description text 531 | * - [x] Child 1 532 | * - [ ] Child 2 533 | * 534 | * Output (with checked at bottom): 535 | * - [ ] Parent 536 | * Description text 537 | * - [ ] Child 2 538 | * - [x] Child 1 539 | * 540 | * @param block - The checkbox block to process 541 | * @param checkedItemsAtBottom - Settings flag for checked item position 542 | * @param charsToDelete - Set of checkbox characters to treat as "to delete" 543 | * @returns Array of lines (parent + all processed children) 544 | */ 545 | function recursivelyProcessBlock( 546 | block: ChecklistBlock, 547 | checkedItemsAtBottom: boolean, 548 | charsToDelete: Set 549 | ): string[] { 550 | const result: string[] = [block.parentLine]; 551 | 552 | // Base case: no children, just return the parent line 553 | if (block.childLines.length === 0) { 554 | return result; 555 | } 556 | 557 | // Calculate the indentation level for immediate children 558 | const childIndent = block.parentInfo.spaceIndent + SettingsManager.getInstance().getIndentSize(); 559 | 560 | // Extract checkbox blocks at the child level 561 | const { blocks: childBlocks } = extractBlocksFromLines(block.childLines, childIndent); 562 | 563 | // If no nested checkboxes found, return parent + all children as-is 564 | if (childBlocks.length === 0) { 565 | return [...result, ...block.childLines]; 566 | } 567 | 568 | // Strategy: We need to reconstruct childLines by: 569 | // 1. Identifying which line indices belong to checkbox blocks 570 | // 2. Preserving non-checkbox lines in their original order 571 | // 3. Replacing checkbox blocks with their reordered & recursively processed versions 572 | 573 | // Map each line index to the block it belongs to (if any) 574 | const lineToBlockMap = new Map(); 575 | const blockStartIndices = new Map(); 576 | 577 | for (const childBlock of childBlocks) { 578 | // Find where this block starts in childLines 579 | for (let i = 0; i < block.childLines.length; i++) { 580 | if (block.childLines[i] === childBlock.parentLine) { 581 | blockStartIndices.set(childBlock, i); 582 | // Mark the parent line 583 | lineToBlockMap.set(i, childBlock); 584 | // Mark all child lines of this block 585 | for (let j = 0; j < childBlock.childLines.length; j++) { 586 | lineToBlockMap.set(i + 1 + j, childBlock); 587 | } 588 | break; 589 | } 590 | } 591 | } 592 | 593 | // Collect lines that are NOT part of any checkbox block (prefix and suffix lines) 594 | const prefixLines: string[] = []; 595 | const suffixLines: string[] = []; 596 | 597 | // Find the first checkbox index 598 | const firstCheckboxIndex = Math.min(...Array.from(blockStartIndices.values())); 599 | // Find the last line of the last checkbox block 600 | let lastCheckboxEndIndex = -1; 601 | for (const [childBlock, startIndex] of blockStartIndices.entries()) { 602 | // Block occupies: startIndex (parent) + childLines.length (children) 603 | // So the next line after the block is at: startIndex + 1 + childLines.length 604 | const endIndex = startIndex + 1 + childBlock.childLines.length; 605 | if (endIndex > lastCheckboxEndIndex) { 606 | lastCheckboxEndIndex = endIndex; 607 | } 608 | } 609 | 610 | // Collect prefix lines (before first checkbox) 611 | for (let i = 0; i < firstCheckboxIndex; i++) { 612 | prefixLines.push(block.childLines[i]); 613 | } 614 | 615 | // Collect suffix lines (after last checkbox block) 616 | for (let i = lastCheckboxEndIndex; i < block.childLines.length; i++) { 617 | suffixLines.push(block.childLines[i]); 618 | } 619 | 620 | // Reorder child blocks 621 | const reorderedChildren = reorderBlocks(childBlocks, checkedItemsAtBottom, charsToDelete); 622 | 623 | // Add prefix lines 624 | result.push(...prefixLines); 625 | 626 | // Recursively process and add reordered child blocks 627 | for (const childBlock of reorderedChildren) { 628 | const processedLines = recursivelyProcessBlock(childBlock, checkedItemsAtBottom, charsToDelete); 629 | result.push(...processedLines); 630 | } 631 | 632 | // Add suffix lines 633 | result.push(...suffixLines); 634 | 635 | return result; 636 | } 637 | 638 | function deleteChecked(editor: Editor): { deleteResult: ReorderResult; deletedItemCount: number } { 639 | const lastLine = editor.lastLine(); 640 | const changes: EditorChange[] = []; 641 | const charsToDelete = getCharsToDelete(); 642 | 643 | let deletedItemCount = 0; 644 | let start = 0; 645 | let end = 0; 646 | 647 | for (let i = 0; i <= lastLine; i++) { 648 | const currLine = getLineInfo(editor.getLine(i)); 649 | 650 | if (currLine.checkboxChar !== undefined && charsToDelete.has(currLine.checkboxChar.toLowerCase())) { 651 | if (start === 0) { 652 | start = i; 653 | } 654 | 655 | changes.push({ 656 | from: { line: i, ch: 0 }, 657 | to: { line: i + 1, ch: 0 }, 658 | text: "", 659 | }); 660 | 661 | end = i; 662 | deletedItemCount++; 663 | } 664 | } 665 | 666 | applyChangesToEditor(editor, changes); 667 | 668 | // last line is done separately becasue it has no new line after it 669 | if (end === lastLine && end !== 0) { 670 | const lastIndex = editor.lastLine(); 671 | if (lastIndex > 0) { 672 | editor.replaceRange( 673 | "", 674 | { line: lastIndex - 1, ch: editor.getLine(lastIndex - 1).length }, 675 | { line: lastIndex, ch: 0 } 676 | ); 677 | } 678 | } 679 | 680 | const limit = end + 1 - deletedItemCount; // index after the last deleted line 681 | 682 | return { deleteResult: { start, limit }, deletedItemCount }; 683 | } 684 | 685 | // char should be treated as checked 686 | function shouldBeSortedAsChecked(char: string | undefined): boolean | undefined { 687 | if (char === undefined) { 688 | return undefined; 689 | } 690 | 691 | const sortSpecialChars = SettingsManager.getInstance().getSortSpecialChars(); 692 | const checkedItems = getCharsToDelete(); 693 | const isSpecialChar = char !== " "; 694 | 695 | if ((isSpecialChar && sortSpecialChars) || checkedItems.has(char)) { 696 | return true; 697 | } 698 | 699 | return false; 700 | } 701 | 702 | function getCharsToDelete(): Set { 703 | const value = SettingsManager.getInstance().getCharsToDelete(); 704 | const defaultDelete = ["x"]; 705 | const filterChars = value 706 | .trim() 707 | .toLowerCase() 708 | .split(" ") 709 | .filter((char) => char.length === 1); 710 | 711 | const charsToDelete = new Set([...defaultDelete, ...filterChars]); 712 | 713 | return charsToDelete; 714 | } 715 | 716 | // is a part of a checklist, and not an empty item 717 | function hasCheckboxContent(line: string): boolean { 718 | const CHECKBOX_WITH_CONTENT = /^(?:\s*\d+\.\s*\[.\]|\s*-\s*\[.\])\s+\S+/; 719 | return CHECKBOX_WITH_CONTENT.test(line); 720 | } 721 | 722 | function applyChangesToEditor(editor: Editor, changes: EditorChange[]) { 723 | if (changes.length > 0) { 724 | editor.transaction({ changes }); 725 | } 726 | } 727 | export { reorderChecklist, reorder, getChecklistStart, deleteChecked }; 728 | -------------------------------------------------------------------------------- /tests/checkbox.test.ts: -------------------------------------------------------------------------------- 1 | import { getLineInfo } from "src/utils"; 2 | import { createMockEditor } from "./__mocks__/createMockEditor"; 3 | import "./__mocks__/main"; 4 | 5 | import { reorder, getChecklistStart, deleteChecked, reorderChecklist } from "src/checkbox"; 6 | import SettingsManager from "src/SettingsManager"; 7 | 8 | describe("getChecklistStart", () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 12 | }); 13 | 14 | const testCases = [ 15 | { 16 | name: "One item unchecked", 17 | content: ["- [ ] a"], 18 | index: 0, 19 | expected: 0, 20 | }, 21 | { 22 | name: "One item checked", 23 | content: ["- [x] a"], 24 | index: 0, 25 | expected: 0, 26 | }, 27 | { 28 | name: "Several items", 29 | content: ["- [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 30 | index: 3, 31 | expected: 0, 32 | }, 33 | { 34 | name: "Several tabbed items", 35 | content: ["\t- [ ] a", "\t- [x] b", "\t- [ ] c", "\t- [x] d"], 36 | index: 3, 37 | expected: 0, 38 | }, 39 | { 40 | name: "Stop at text", 41 | content: ["- [ ] a", "text", "- [x] b", "- [ ] c", "- [x] d"], 42 | index: 4, 43 | expected: 2, 44 | }, 45 | { 46 | name: "Stop at indented box", 47 | content: ["\t [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 48 | index: 3, 49 | expected: 1, 50 | }, 51 | { 52 | name: "Stop at numbered checkbox", 53 | content: ["1. [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 54 | index: 3, 55 | expected: 1, 56 | }, 57 | { 58 | name: "Several numbered items", 59 | content: ["12. [ ] a", "12. [x] b", "12. [ ] c", "12. [x] d"], 60 | index: 3, 61 | expected: 0, 62 | }, 63 | { 64 | name: "Stop at indented box", 65 | content: ["\t [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 66 | index: 3, 67 | expected: 1, 68 | }, 69 | { 70 | name: "Stop at numbered text", 71 | content: ["12. [ ] a", "12. text", "12. [x] b", "12. [ ] c", "12. [x] d"], 72 | index: 4, 73 | expected: 2, 74 | }, 75 | ]; 76 | 77 | testCases.forEach(({ name, content, index, expected }) => { 78 | test(name, () => { 79 | const editor = createMockEditor(content); 80 | const res = getChecklistStart(editor, index); 81 | 82 | expect(res).toBe(expected); 83 | }); 84 | }); 85 | }); 86 | 87 | describe("deleteChecked", () => { 88 | beforeEach(() => { 89 | jest.clearAllMocks(); 90 | SettingsManager.getInstance().setCharsToDelete("A $"); 91 | }); 92 | 93 | const testCases = [ 94 | { 95 | name: "One item unchecked", 96 | content: ["- [ ] a"], 97 | expected: ["- [ ] a"], 98 | }, 99 | { 100 | name: "One item checked", 101 | content: ["- [x] a"], 102 | expected: [""], 103 | }, 104 | { 105 | name: "Several items with different characters", 106 | content: ["- [x] x checked", "- [ ] unchecked", "- [A] A checked", "- [$] $ checked"], 107 | expected: ["- [ ] unchecked"], 108 | }, 109 | { 110 | name: "Remove every checked character", 111 | content: [ 112 | "text", 113 | "- [ ] unchecked", 114 | "- [A] A checked", 115 | "1. numbered", 116 | "- [$] $ checked", 117 | "1. [a] numbered checked", 118 | "2. [] numbered unchecked", 119 | "\t- [$] indented checked", 120 | "\t- [ ] indented unchecked", 121 | "\t1. [$] indented numbered checked", 122 | "\t2. [ ] indented numbered unchecked", 123 | "\tindented text", 124 | ], 125 | expected: [ 126 | "text", 127 | "- [ ] unchecked", 128 | "1. numbered", 129 | "2. [] numbered unchecked", 130 | "\t- [ ] indented unchecked", 131 | "\t2. [ ] indented numbered unchecked", 132 | "\tindented text", 133 | ], 134 | }, 135 | ]; 136 | 137 | testCases.forEach(({ name, content, expected }) => { 138 | test(name, () => { 139 | const editor = createMockEditor(content); 140 | 141 | deleteChecked(editor); 142 | 143 | expected.forEach((line, i) => { 144 | expect(editor.getLine(i)).toBe(line); 145 | }); 146 | }); 147 | }); 148 | }); 149 | 150 | describe("reorder", () => { 151 | describe("with checked items at the top", () => { 152 | beforeEach(() => { 153 | jest.clearAllMocks(); 154 | SettingsManager.getInstance().setCheckedItemsAtBottom(false); 155 | }); 156 | 157 | const testCases = [ 158 | { 159 | name: "One item unchecked", 160 | content: ["- [ ] a"], 161 | index: 0, 162 | expected: { 163 | unchecked: [], 164 | checked: [], 165 | startIndex: 1, 166 | endIndex: 1, 167 | }, 168 | }, 169 | { 170 | name: "One item checked", 171 | content: ["- [x] a"], 172 | index: 0, 173 | expected: { 174 | unchecked: [], 175 | checked: [], 176 | startIndex: 1, 177 | endIndex: 1, 178 | }, 179 | }, 180 | { 181 | name: "Return the correct ending index of the first change", 182 | content: ["- [x] a", "- [ ] b", "- [x] c"], 183 | index: 0, 184 | expected: { 185 | unchecked: ["- [ ] b"], 186 | checked: ["- [x] c"], 187 | startIndex: 1, 188 | endIndex: 3, 189 | }, 190 | }, 191 | { 192 | name: "Not the same indentation", 193 | content: ["- [ ] a", "- [ ] b", "- [x] c", "\t- [x] d", "- [ ] e", "- [x] f"], 194 | index: 0, 195 | expected: { 196 | unchecked: ["- [ ] a", "- [ ] b", "- [ ] e"], 197 | checked: ["- [x] c", "\t- [x] d", "- [x] f"], 198 | startIndex: 0, 199 | endIndex: 6, 200 | }, 201 | }, 202 | { 203 | name: "Alternating starting unchecked", 204 | content: ["- [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 205 | index: 0, 206 | expected: { 207 | unchecked: ["- [ ] a", "- [ ] c"], 208 | checked: ["- [x] b", "- [x] d"], 209 | startIndex: 0, 210 | endIndex: 4, 211 | }, 212 | }, 213 | { 214 | name: "Alternating starting checked", 215 | content: ["- [x] a", "- [ ] b", "- [x] c", "- [ ] d"], 216 | index: 0, 217 | expected: { 218 | unchecked: ["- [ ] b"], 219 | checked: ["- [x] c"], 220 | startIndex: 1, 221 | endIndex: 3, 222 | }, 223 | }, 224 | { 225 | name: "Two checked then two unchecked", 226 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d"], 227 | index: 0, 228 | expected: { 229 | unchecked: [], 230 | checked: [], 231 | startIndex: 4, 232 | endIndex: 4, 233 | }, 234 | }, 235 | { 236 | name: "Two unchecked then two checked", 237 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d"], 238 | index: 0, 239 | expected: { 240 | unchecked: ["- [ ] a", "- [ ] b"], 241 | checked: ["- [x] c", "- [x] d"], 242 | startIndex: 0, 243 | endIndex: 4, 244 | }, 245 | }, 246 | { 247 | name: "Multiple checked at end", 248 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d", "- [x] e"], 249 | index: 0, 250 | expected: { 251 | unchecked: ["- [ ] a", "- [ ] b"], 252 | checked: ["- [x] c", "- [x] d", "- [x] e"], 253 | startIndex: 0, 254 | endIndex: 5, 255 | }, 256 | }, 257 | { 258 | name: "Multiple unchecked at end", 259 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d", "- [ ] e"], 260 | index: 0, 261 | expected: { 262 | unchecked: [], 263 | checked: [], 264 | startIndex: 5, 265 | endIndex: 5, 266 | }, 267 | }, 268 | { 269 | name: "Empty list", 270 | content: [""], 271 | index: 0, 272 | expected: { 273 | unchecked: [], 274 | checked: [], 275 | startIndex: 0, 276 | endIndex: 0, 277 | }, 278 | }, 279 | ]; 280 | 281 | testCases.forEach(({ name, content, index, expected }) => { 282 | test(name, () => { 283 | const editor = createMockEditor(content); 284 | const info = getLineInfo(editor.getLine(index)); 285 | const result = reorder(editor, index, info); 286 | 287 | const expectedItems = [...expected.checked, ...expected.unchecked]; 288 | 289 | expect(result).toEqual({ 290 | orderedItems: expectedItems, 291 | reorderResult: { 292 | start: expected.startIndex, 293 | limit: expected.endIndex, 294 | }, 295 | }); 296 | }); 297 | }); 298 | }); 299 | 300 | describe("with checked items at the bottom", () => { 301 | beforeEach(() => { 302 | jest.clearAllMocks(); 303 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 304 | }); 305 | 306 | const testCases = [ 307 | { 308 | name: "One item unchecked", 309 | content: ["- [ ] a"], 310 | index: 0, 311 | expected: { 312 | unchecked: [], 313 | checked: [], 314 | startIndex: 1, 315 | endIndex: 1, 316 | }, 317 | }, 318 | { 319 | name: "One item checked", 320 | content: ["- [x] a"], 321 | index: 0, 322 | expected: { 323 | unchecked: [], 324 | checked: [], 325 | startIndex: 1, 326 | endIndex: 1, 327 | }, 328 | }, 329 | { 330 | name: "Return the correct starting index of the first change", 331 | content: ["- [ ] a", "- [x] b", "- [ ] c"], 332 | index: 0, 333 | expected: { 334 | unchecked: ["- [ ] c"], 335 | checked: ["- [x] b"], 336 | startIndex: 1, 337 | endIndex: 3, 338 | }, 339 | }, 340 | { 341 | name: "Not the same indentation", 342 | content: ["- [x] a", "- [ ] b", "- [ ] c", "\t- [x] d", "- [ ] e", "- [x] f"], 343 | index: 0, 344 | expected: { 345 | unchecked: ["- [ ] b", "- [ ] c", "\t- [x] d", "- [ ] e"], 346 | checked: ["- [x] a"], 347 | startIndex: 0, 348 | endIndex: 5, 349 | }, 350 | }, 351 | { 352 | name: "Alternating starting unchecked", 353 | content: ["- [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 354 | index: 0, 355 | expected: { 356 | unchecked: ["- [ ] c"], 357 | checked: ["- [x] b"], 358 | startIndex: 1, 359 | endIndex: 3, 360 | }, 361 | }, 362 | { 363 | name: "Alternating starting checked", 364 | content: ["- [x] a", "- [ ] b", "- [x] c", "- [ ] d"], 365 | index: 0, 366 | expected: { 367 | unchecked: ["- [ ] b", "- [ ] d"], 368 | checked: ["- [x] a", "- [x] c"], 369 | startIndex: 0, 370 | endIndex: 4, 371 | }, 372 | }, 373 | { 374 | name: "Two checked then two unchecked", 375 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d"], 376 | index: 0, 377 | expected: { 378 | unchecked: ["- [ ] c", "- [ ] d"], 379 | checked: ["- [x] a", "- [x] b"], 380 | startIndex: 0, 381 | endIndex: 4, 382 | }, 383 | }, 384 | { 385 | name: "Two unchecked then two checked", 386 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d"], 387 | index: 0, 388 | expected: { 389 | unchecked: [], 390 | checked: [], 391 | startIndex: 4, 392 | endIndex: 4, 393 | }, 394 | }, 395 | { 396 | name: "Multiple checked at end", 397 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d", "- [x] e"], 398 | index: 0, 399 | expected: { 400 | unchecked: [], 401 | checked: [], 402 | startIndex: 5, 403 | endIndex: 5, 404 | }, 405 | }, 406 | { 407 | name: "Multiple unchecked at end", 408 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d", "- [ ] e"], 409 | index: 0, 410 | expected: { 411 | unchecked: ["- [ ] c", "- [ ] d", "- [ ] e"], 412 | checked: ["- [x] a", "- [x] b"], 413 | startIndex: 0, 414 | endIndex: 5, 415 | }, 416 | }, 417 | { 418 | name: "Empty list", 419 | content: [""], 420 | index: 0, 421 | expected: { 422 | unchecked: [], 423 | checked: [], 424 | startIndex: 0, 425 | endIndex: 0, 426 | }, 427 | }, 428 | ]; 429 | 430 | testCases.forEach(({ name, content, index, expected }) => { 431 | test(name, () => { 432 | const editor = createMockEditor(content); 433 | const info = getLineInfo(editor.getLine(index)); 434 | const result = reorder(editor, index, info); 435 | 436 | const expectedItems = [...expected.unchecked, ...expected.checked]; 437 | 438 | expect(result).toEqual({ 439 | orderedItems: expectedItems, 440 | reorderResult: { 441 | start: expected.startIndex, 442 | limit: expected.endIndex, 443 | }, 444 | }); 445 | }); 446 | }); 447 | }); 448 | }); 449 | 450 | describe("reorderChecklist", () => { 451 | describe("Checked items at the top", () => { 452 | beforeEach(() => { 453 | jest.clearAllMocks(); 454 | SettingsManager.getInstance().setCheckedItemsAtBottom(false); 455 | }); 456 | 457 | const testCases = [ 458 | { 459 | name: "Empty list", 460 | content: [""], 461 | index: 0, 462 | expected: { 463 | content: [""], 464 | result: undefined, 465 | }, 466 | }, 467 | { 468 | name: "One item unchecked", 469 | content: ["- [ ] a"], 470 | index: 0, 471 | expected: { 472 | content: ["- [ ] a"], 473 | result: undefined, 474 | }, 475 | }, 476 | { 477 | name: "One item checked", 478 | content: ["- [x] a"], 479 | index: 0, 480 | expected: { 481 | content: ["- [x] a"], 482 | result: undefined, 483 | }, 484 | }, 485 | { 486 | name: "Return the correct ending index of the first change", 487 | content: ["- [x] a", "- [ ] b", "- [x] c"], 488 | index: 0, 489 | expected: { 490 | content: ["- [x] a", "- [x] c", "- [ ] b"], 491 | result: { 492 | start: 1, 493 | limit: 3, 494 | }, 495 | }, 496 | }, 497 | { 498 | name: "Not the same indentation", 499 | content: ["- [ ] a", "- [ ] b", "- [x] c", "\t- [x] d", "- [ ] e", "- [x] f"], 500 | index: 0, 501 | expected: { 502 | content: ["- [x] c", "\t- [x] d", "- [x] f", "- [ ] a", "- [ ] b", "- [ ] e"], 503 | result: { 504 | start: 0, 505 | limit: 6, 506 | }, 507 | }, 508 | }, 509 | { 510 | name: "Alternating starting unchecked", 511 | content: ["- [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 512 | index: 0, 513 | expected: { 514 | content: ["- [x] b", "- [x] d", "- [ ] a", "- [ ] c"], 515 | result: { 516 | start: 0, 517 | limit: 4, 518 | }, 519 | }, 520 | }, 521 | { 522 | name: "Alternating starting checked", 523 | content: ["- [x] a", "- [ ] b", "- [x] c", "- [ ] d"], 524 | index: 0, 525 | expected: { 526 | content: ["- [x] a", "- [x] c", "- [ ] b", "- [ ] d"], 527 | result: { 528 | start: 1, 529 | limit: 3, 530 | }, 531 | }, 532 | }, 533 | { 534 | name: "Two checked then two unchecked", 535 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d"], 536 | index: 0, 537 | expected: { 538 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d"], 539 | result: undefined, 540 | }, 541 | }, 542 | { 543 | name: "Two unchecked then two checked", 544 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d"], 545 | index: 0, 546 | expected: { 547 | content: ["- [x] c", "- [x] d", "- [ ] a", "- [ ] b"], 548 | result: { 549 | start: 0, 550 | limit: 4, 551 | }, 552 | }, 553 | }, 554 | { 555 | name: "Multiple checked at end", 556 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d", "- [x] e"], 557 | index: 0, 558 | expected: { 559 | content: ["- [x] c", "- [x] d", "- [x] e", "- [ ] a", "- [ ] b"], 560 | result: { 561 | start: 0, 562 | limit: 5, 563 | }, 564 | }, 565 | }, 566 | { 567 | name: "Multiple unchecked at end", 568 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d", "- [ ] e"], 569 | index: 0, 570 | expected: { 571 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d", "- [ ] e"], 572 | result: undefined, 573 | }, 574 | }, 575 | ]; 576 | 577 | testCases.forEach(({ name, content, index, expected }) => { 578 | test(name, () => { 579 | const editor = createMockEditor(content); 580 | const result = reorderChecklist(editor, index); 581 | 582 | for (let i = 0; i < content.length; i++) { 583 | expect(editor.getLine(i)).toBe(expected.content[i]); 584 | } 585 | 586 | expect(result).toStrictEqual(expected.result); 587 | }); 588 | }); 589 | }); 590 | 591 | describe("Checked items at the bottom", () => { 592 | beforeEach(() => { 593 | jest.clearAllMocks(); 594 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 595 | }); 596 | 597 | const testCases = [ 598 | { 599 | name: "Empty list", 600 | content: [""], 601 | index: 0, 602 | expected: { 603 | content: [""], 604 | result: undefined, 605 | }, 606 | }, 607 | { 608 | name: "One item unchecked", 609 | content: ["- [ ] a"], 610 | index: 0, 611 | expected: { 612 | content: ["- [ ] a"], 613 | result: undefined, 614 | }, 615 | }, 616 | { 617 | name: "One item checked", 618 | content: ["- [x] a"], 619 | index: 0, 620 | expected: { 621 | content: ["- [x] a"], 622 | result: undefined, 623 | }, 624 | }, 625 | { 626 | name: "Return the correct starting index of the first change", 627 | content: ["- [ ] a", "- [x] b", "- [ ] c"], 628 | index: 0, 629 | expected: { 630 | content: ["- [ ] a", "- [ ] c", "- [x] b"], 631 | result: { 632 | start: 1, 633 | limit: 3, 634 | }, 635 | }, 636 | }, 637 | { 638 | name: "Not the same indentation", 639 | content: ["- [x] a", "- [ ] b", "- [ ] c", "\t- [x] d", "- [ ] e", "- [x] f"], 640 | index: 0, 641 | expected: { 642 | content: ["- [ ] b", "- [ ] c", "\t- [x] d", "- [ ] e", "- [x] a", "- [x] f"], 643 | result: { 644 | start: 0, 645 | limit: 5, 646 | }, 647 | }, 648 | }, 649 | { 650 | name: "Alternating starting unchecked", 651 | content: ["- [ ] a", "- [x] b", "- [ ] c", "- [x] d"], 652 | index: 0, 653 | expected: { 654 | content: ["- [ ] a", "- [ ] c", "- [x] b", "- [x] d"], 655 | result: { 656 | start: 1, 657 | limit: 3, 658 | }, 659 | }, 660 | }, 661 | { 662 | name: "Alternating starting checked", 663 | content: ["- [x] a", "- [ ] b", "- [x] c", "- [ ] d"], 664 | index: 0, 665 | expected: { 666 | content: ["- [ ] b", "- [ ] d", "- [x] a", "- [x] c"], 667 | result: { 668 | start: 0, 669 | limit: 4, 670 | }, 671 | }, 672 | }, 673 | { 674 | name: "Two checked then two unchecked", 675 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d"], 676 | index: 0, 677 | expected: { 678 | content: ["- [ ] c", "- [ ] d", "- [x] a", "- [x] b"], 679 | result: { 680 | start: 0, 681 | limit: 4, 682 | }, 683 | }, 684 | }, 685 | { 686 | name: "Two unchecked then two checked", 687 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d"], 688 | index: 0, 689 | expected: { 690 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d"], 691 | result: undefined, 692 | }, 693 | }, 694 | { 695 | name: "Multiple checked at end", 696 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d", "- [x] e"], 697 | index: 0, 698 | expected: { 699 | content: ["- [ ] a", "- [ ] b", "- [x] c", "- [x] d", "- [x] e"], 700 | result: undefined, 701 | }, 702 | }, 703 | { 704 | name: "Multiple unchecked at end", 705 | content: ["- [x] a", "- [x] b", "- [ ] c", "- [ ] d", "- [ ] e"], 706 | index: 0, 707 | expected: { 708 | content: ["- [ ] c", "- [ ] d", "- [ ] e", "- [x] a", "- [x] b"], 709 | result: { 710 | start: 0, 711 | limit: 5, 712 | }, 713 | }, 714 | }, 715 | ]; 716 | 717 | testCases.forEach(({ name, content, index, expected }) => { 718 | test(name, () => { 719 | const editor = createMockEditor(content); 720 | const result = reorderChecklist(editor, index); 721 | 722 | // for (let i = 0; i < content.length; i++) { 723 | // expect(editor.getLine(i)).toBe(expected.content[i]); 724 | // } 725 | 726 | for (let i = 0; i < content.length; i++) { 727 | expect(editor.getLine(i)).toBe(expected.content[i]); 728 | } 729 | 730 | expect(result).toStrictEqual(expected.result); 731 | }); 732 | }); 733 | }); 734 | 735 | test("Multiple lists", () => { 736 | jest.clearAllMocks(); 737 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 738 | 739 | const content = [ 740 | "- [ ] a", 741 | "- [x] b", 742 | "- [ ] c", 743 | "text", 744 | "- [ ] a", 745 | "- [x] b", 746 | "- [ ] c", 747 | "text", 748 | "- [ ] a", 749 | "- [x] b", 750 | "- [ ] c", 751 | ]; 752 | 753 | const expected = [ 754 | "- [ ] a", 755 | "- [ ] c", 756 | "- [x] b", 757 | "text", 758 | "- [ ] a", 759 | "- [ ] c", 760 | "- [x] b", 761 | "text", 762 | "- [ ] a", 763 | "- [x] b", 764 | "- [ ] c", 765 | ]; 766 | 767 | const editor = createMockEditor(content); 768 | const result = reorderChecklist(editor, 0, 6); 769 | 770 | for (let i = 0; i < content.length; i++) { 771 | expect(editor.getLine(i)).toBe(expected[i]); 772 | } 773 | 774 | expect(result).toStrictEqual({ 775 | start: 1, 776 | limit: 7, 777 | }); 778 | }); 779 | }); 780 | -------------------------------------------------------------------------------- /tests/checkbox-blocks.test.ts: -------------------------------------------------------------------------------- 1 | import { getLineInfo } from "src/utils"; 2 | import { createMockEditor } from "./__mocks__/createMockEditor"; 3 | import "./__mocks__/main"; 4 | 5 | import { reorder, reorderChecklist } from "src/checkbox"; 6 | import SettingsManager from "src/SettingsManager"; 7 | 8 | describe("Block-based checkbox reordering", () => { 9 | describe("Single level with indented content", () => { 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 13 | }); 14 | 15 | const testCases = [ 16 | { 17 | name: "Unchecked checkbox with indented text moves as a block", 18 | content: [ 19 | "- [ ] Task A", 20 | " Some description", 21 | "- [x] Task B", 22 | ], 23 | index: 0, 24 | expected: [ 25 | "- [ ] Task A", 26 | " Some description", 27 | "- [x] Task B", 28 | ], 29 | }, 30 | { 31 | name: "Checked checkbox with indented text moves to bottom as a block", 32 | content: [ 33 | "- [x] Task A", 34 | " Some description", 35 | "- [ ] Task B", 36 | ], 37 | index: 0, 38 | expected: [ 39 | "- [ ] Task B", 40 | "- [x] Task A", 41 | " Some description", 42 | ], 43 | }, 44 | { 45 | name: "Multiple checkboxes with their indented content move as blocks", 46 | content: [ 47 | "- [ ] Task A", 48 | " Description A", 49 | "- [x] Task B", 50 | " Description B", 51 | "- [ ] Task C", 52 | " Description C", 53 | ], 54 | index: 0, 55 | expected: [ 56 | "- [ ] Task A", 57 | " Description A", 58 | "- [ ] Task C", 59 | " Description C", 60 | "- [x] Task B", 61 | " Description B", 62 | ], 63 | }, 64 | { 65 | name: "Multiple lines of indented content stay with parent", 66 | content: [ 67 | "- [x] Task A", 68 | " Line 1", 69 | " Line 2", 70 | " Line 3", 71 | "- [ ] Task B", 72 | ], 73 | index: 0, 74 | expected: [ 75 | "- [ ] Task B", 76 | "- [x] Task A", 77 | " Line 1", 78 | " Line 2", 79 | " Line 3", 80 | ], 81 | }, 82 | ]; 83 | 84 | testCases.forEach(({ name, content, index, expected }) => { 85 | test(name, () => { 86 | const editor = createMockEditor(content); 87 | reorderChecklist(editor, index); 88 | 89 | for (let i = 0; i < expected.length; i++) { 90 | expect(editor.getLine(i)).toBe(expected[i]); 91 | } 92 | }); 93 | }); 94 | }); 95 | 96 | describe("Nested checkboxes", () => { 97 | beforeEach(() => { 98 | jest.clearAllMocks(); 99 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 100 | }); 101 | 102 | const testCases = [ 103 | { 104 | name: "Parent checkbox with nested unchecked children", 105 | content: [ 106 | "- [ ] Parent A", 107 | " - [ ] Child A1", 108 | " - [ ] Child A2", 109 | "- [ ] Parent B", 110 | ], 111 | index: 0, 112 | expected: [ 113 | "- [ ] Parent A", 114 | " - [ ] Child A1", 115 | " - [ ] Child A2", 116 | "- [ ] Parent B", 117 | ], 118 | }, 119 | { 120 | name: "Parent checkbox with nested checked children - children reorder within parent", 121 | content: [ 122 | "- [ ] Parent A", 123 | " - [x] Child A1", 124 | " - [ ] Child A2", 125 | "- [ ] Parent B", 126 | ], 127 | index: 0, 128 | expected: [ 129 | "- [ ] Parent A", 130 | " - [ ] Child A2", 131 | " - [x] Child A1", 132 | "- [ ] Parent B", 133 | ], 134 | }, 135 | { 136 | name: "Checked parent with children moves entire block to bottom", 137 | content: [ 138 | "- [x] Parent A", 139 | " - [ ] Child A1", 140 | " - [ ] Child A2", 141 | "- [ ] Parent B", 142 | ], 143 | index: 0, 144 | expected: [ 145 | "- [ ] Parent B", 146 | "- [x] Parent A", 147 | " - [ ] Child A1", 148 | " - [ ] Child A2", 149 | ], 150 | }, 151 | { 152 | name: "Checked parent with mixed children - parent moves, children reorder within", 153 | content: [ 154 | "- [x] Parent A", 155 | " - [x] Child A1", 156 | " - [ ] Child A2", 157 | "- [ ] Parent B", 158 | ], 159 | index: 0, 160 | expected: [ 161 | "- [ ] Parent B", 162 | "- [x] Parent A", 163 | " - [ ] Child A2", 164 | " - [x] Child A1", 165 | ], 166 | }, 167 | { 168 | name: "Multiple parents with nested checkboxes", 169 | content: [ 170 | "- [ ] Parent A", 171 | " - [x] Child A1", 172 | " - [ ] Child A2", 173 | "- [x] Parent B", 174 | " - [ ] Child B1", 175 | " - [x] Child B2", 176 | ], 177 | index: 0, 178 | expected: [ 179 | "- [ ] Parent A", 180 | " - [ ] Child A2", 181 | " - [x] Child A1", 182 | "- [x] Parent B", 183 | " - [ ] Child B1", 184 | " - [x] Child B2", 185 | ], 186 | }, 187 | ]; 188 | 189 | testCases.forEach(({ name, content, index, expected }) => { 190 | test(name, () => { 191 | const editor = createMockEditor(content); 192 | reorderChecklist(editor, index); 193 | 194 | for (let i = 0; i < expected.length; i++) { 195 | expect(editor.getLine(i)).toBe(expected[i]); 196 | } 197 | }); 198 | }); 199 | }); 200 | 201 | describe("Deeply nested checkboxes", () => { 202 | beforeEach(() => { 203 | jest.clearAllMocks(); 204 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 205 | }); 206 | 207 | const testCases = [ 208 | { 209 | name: "Three levels of nesting - grandchild checked", 210 | content: [ 211 | "- [ ] Parent", 212 | " - [ ] Child", 213 | " - [x] Grandchild A", 214 | " - [ ] Grandchild B", 215 | ], 216 | index: 0, 217 | expected: [ 218 | "- [ ] Parent", 219 | " - [ ] Child", 220 | " - [ ] Grandchild B", 221 | " - [x] Grandchild A", 222 | ], 223 | }, 224 | { 225 | name: "Three levels - checking child moves child block within parent", 226 | content: [ 227 | "- [ ] Parent", 228 | " - [x] Child A", 229 | " - [ ] Grandchild A1", 230 | " - [ ] Grandchild A2", 231 | " - [ ] Child B", 232 | ], 233 | index: 0, 234 | expected: [ 235 | "- [ ] Parent", 236 | " - [ ] Child B", 237 | " - [x] Child A", 238 | " - [ ] Grandchild A1", 239 | " - [ ] Grandchild A2", 240 | ], 241 | }, 242 | { 243 | name: "Three levels - checking parent moves entire tree", 244 | content: [ 245 | "- [x] Parent A", 246 | " - [ ] Child A1", 247 | " - [ ] Grandchild A1a", 248 | " - [ ] Child A2", 249 | "- [ ] Parent B", 250 | ], 251 | index: 0, 252 | expected: [ 253 | "- [ ] Parent B", 254 | "- [x] Parent A", 255 | " - [ ] Child A1", 256 | " - [ ] Grandchild A1a", 257 | " - [ ] Child A2", 258 | ], 259 | }, 260 | { 261 | name: "Complex nested scenario with mixed states", 262 | content: [ 263 | "- [ ] Parent A", 264 | " - [x] Child A1", 265 | " - [x] Grandchild A1a", 266 | " - [ ] Grandchild A1b", 267 | " - [ ] Child A2", 268 | " - [ ] Grandchild A2a", 269 | "- [x] Parent B", 270 | " - [ ] Child B1", 271 | ], 272 | index: 0, 273 | expected: [ 274 | "- [ ] Parent A", 275 | " - [ ] Child A2", 276 | " - [ ] Grandchild A2a", 277 | " - [x] Child A1", 278 | " - [ ] Grandchild A1b", 279 | " - [x] Grandchild A1a", 280 | "- [x] Parent B", 281 | " - [ ] Child B1", 282 | ], 283 | }, 284 | ]; 285 | 286 | testCases.forEach(({ name, content, index, expected }) => { 287 | test(name, () => { 288 | const editor = createMockEditor(content); 289 | reorderChecklist(editor, index); 290 | 291 | for (let i = 0; i < expected.length; i++) { 292 | expect(editor.getLine(i)).toBe(expected[i]); 293 | } 294 | }); 295 | }); 296 | }); 297 | 298 | describe("Mixed content (checkboxes + text + nested items)", () => { 299 | beforeEach(() => { 300 | jest.clearAllMocks(); 301 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 302 | }); 303 | 304 | const testCases = [ 305 | { 306 | name: "Checkbox with description before nested checkbox", 307 | content: [ 308 | "- [x] Parent", 309 | " This is a description", 310 | " - [ ] Child", 311 | "- [ ] Other", 312 | ], 313 | index: 0, 314 | expected: [ 315 | "- [ ] Other", 316 | "- [x] Parent", 317 | " This is a description", 318 | " - [ ] Child", 319 | ], 320 | }, 321 | { 322 | name: "Checkbox with description before and after nested checkbox", 323 | content: [ 324 | "- [ ] Parent", 325 | " Before description", 326 | " - [x] Child A", 327 | " - [ ] Child B", 328 | " After description", 329 | ], 330 | index: 0, 331 | expected: [ 332 | "- [ ] Parent", 333 | " Before description", 334 | " - [ ] Child B", 335 | " - [x] Child A", 336 | " After description", 337 | ], 338 | }, 339 | { 340 | name: "Multiple indentation levels with mixed content", 341 | content: [ 342 | "- [x] Parent", 343 | " Parent description", 344 | " - [ ] Child", 345 | " Child description", 346 | " More parent text", 347 | "- [ ] Other", 348 | ], 349 | index: 0, 350 | expected: [ 351 | "- [ ] Other", 352 | "- [x] Parent", 353 | " Parent description", 354 | " - [ ] Child", 355 | " Child description", 356 | " More parent text", 357 | ], 358 | }, 359 | ]; 360 | 361 | testCases.forEach(({ name, content, index, expected }) => { 362 | test(name, () => { 363 | const editor = createMockEditor(content); 364 | reorderChecklist(editor, index); 365 | 366 | for (let i = 0; i < expected.length; i++) { 367 | expect(editor.getLine(i)).toBe(expected[i]); 368 | } 369 | }); 370 | }); 371 | }); 372 | 373 | describe("Checked items at top setting", () => { 374 | beforeEach(() => { 375 | jest.clearAllMocks(); 376 | SettingsManager.getInstance().setCheckedItemsAtBottom(false); 377 | }); 378 | 379 | const testCases = [ 380 | { 381 | name: "Checked parent moves to top with all children", 382 | content: [ 383 | "- [ ] Parent A", 384 | " - [ ] Child A1", 385 | "- [x] Parent B", 386 | " - [ ] Child B1", 387 | ], 388 | index: 0, 389 | expected: [ 390 | "- [x] Parent B", 391 | " - [ ] Child B1", 392 | "- [ ] Parent A", 393 | " - [ ] Child A1", 394 | ], 395 | }, 396 | { 397 | name: "Nested checked items move to top within their parent", 398 | content: [ 399 | "- [ ] Parent", 400 | " - [ ] Child A", 401 | " - [x] Child B", 402 | " - [ ] Grandchild", 403 | ], 404 | index: 0, 405 | expected: [ 406 | "- [ ] Parent", 407 | " - [x] Child B", 408 | " - [ ] Grandchild", 409 | " - [ ] Child A", 410 | ], 411 | }, 412 | ]; 413 | 414 | testCases.forEach(({ name, content, index, expected }) => { 415 | test(name, () => { 416 | const editor = createMockEditor(content); 417 | reorderChecklist(editor, index); 418 | 419 | for (let i = 0; i < expected.length; i++) { 420 | expect(editor.getLine(i)).toBe(expected[i]); 421 | } 422 | }); 423 | }); 424 | }); 425 | 426 | describe("Tab indentation", () => { 427 | beforeEach(() => { 428 | jest.clearAllMocks(); 429 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 430 | }); 431 | 432 | const testCases = [ 433 | { 434 | name: "Tab-indented children move with parent", 435 | content: [ 436 | "- [x] Parent", 437 | "\t- [ ] Child A", 438 | "\t- [ ] Child B", 439 | "- [ ] Other", 440 | ], 441 | index: 0, 442 | expected: [ 443 | "- [ ] Other", 444 | "- [x] Parent", 445 | "\t- [ ] Child A", 446 | "\t- [ ] Child B", 447 | ], 448 | }, 449 | { 450 | name: "Tab-indented nested checkboxes reorder within parent", 451 | content: [ 452 | "- [ ] Parent", 453 | "\t- [x] Child A", 454 | "\t- [ ] Child B", 455 | ], 456 | index: 0, 457 | expected: [ 458 | "- [ ] Parent", 459 | "\t- [ ] Child B", 460 | "\t- [x] Child A", 461 | ], 462 | }, 463 | ]; 464 | 465 | testCases.forEach(({ name, content, index, expected }) => { 466 | test(name, () => { 467 | const editor = createMockEditor(content); 468 | reorderChecklist(editor, index); 469 | 470 | for (let i = 0; i < expected.length; i++) { 471 | expect(editor.getLine(i)).toBe(expected[i]); 472 | } 473 | }); 474 | }); 475 | }); 476 | 477 | describe("Edge cases", () => { 478 | beforeEach(() => { 479 | jest.clearAllMocks(); 480 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 481 | }); 482 | 483 | const testCases = [ 484 | { 485 | name: "Empty indented lines are preserved", 486 | content: [ 487 | "- [x] Parent", 488 | " ", 489 | " - [ ] Child", 490 | "- [ ] Other", 491 | ], 492 | index: 0, 493 | expected: [ 494 | "- [ ] Other", 495 | "- [x] Parent", 496 | " ", 497 | " - [ ] Child", 498 | ], 499 | }, 500 | { 501 | name: "Only parent checkbox, no children", 502 | content: [ 503 | "- [x] Task A", 504 | "- [ ] Task B", 505 | ], 506 | index: 0, 507 | expected: [ 508 | "- [ ] Task B", 509 | "- [x] Task A", 510 | ], 511 | }, 512 | { 513 | name: "All checkboxes checked - no reordering needed", 514 | content: [ 515 | "- [x] Parent A", 516 | " - [x] Child A1", 517 | "- [x] Parent B", 518 | ], 519 | index: 0, 520 | expected: [ 521 | "- [x] Parent A", 522 | " - [x] Child A1", 523 | "- [x] Parent B", 524 | ], 525 | }, 526 | { 527 | name: "All checkboxes unchecked - no reordering needed", 528 | content: [ 529 | "- [ ] Parent A", 530 | " - [ ] Child A1", 531 | "- [ ] Parent B", 532 | ], 533 | index: 0, 534 | expected: [ 535 | "- [ ] Parent A", 536 | " - [ ] Child A1", 537 | "- [ ] Parent B", 538 | ], 539 | }, 540 | { 541 | name: "No extra lines added - line count preserved after reordering", 542 | content: [ 543 | "- [ ] Task A", 544 | " Description A", 545 | "- [x] Task B", 546 | " Description B", 547 | "- [ ] Task C", 548 | ], 549 | index: 0, 550 | expected: [ 551 | "- [ ] Task A", 552 | " Description A", 553 | "- [ ] Task C", 554 | "- [x] Task B", 555 | " Description B", 556 | ], 557 | }, 558 | { 559 | name: "No extra lines with unchanged prefix", 560 | content: [ 561 | "- [ ] Unchanged", 562 | "- [ ] Task A", 563 | "- [x] Task B", 564 | ], 565 | index: 0, 566 | expected: [ 567 | "- [ ] Unchanged", 568 | "- [ ] Task A", 569 | "- [x] Task B", 570 | ], 571 | }, 572 | ]; 573 | 574 | testCases.forEach(({ name, content, index, expected }) => { 575 | test(name, () => { 576 | const editor = createMockEditor(content); 577 | const initialLineCount = content.length; 578 | 579 | reorderChecklist(editor, index); 580 | 581 | // Check line count is preserved (no extra lines added) 582 | expect(editor.lastLine() + 1).toBe(initialLineCount); 583 | 584 | // Check content matches expected 585 | for (let i = 0; i < expected.length; i++) { 586 | expect(editor.getLine(i)).toBe(expected[i]); 587 | } 588 | }); 589 | }); 590 | }); 591 | 592 | describe("Numbered lists with checkboxes", () => { 593 | beforeEach(() => { 594 | jest.clearAllMocks(); 595 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 596 | SettingsManager.getInstance().setLiveNumberingUpdate(false); 597 | }); 598 | 599 | const testCases = [ 600 | { 601 | name: "Numbered checkbox with indented children", 602 | content: [ 603 | "1. [x] Parent", 604 | " - [ ] Child A", 605 | " - [ ] Child B", 606 | "2. [ ] Other", 607 | ], 608 | index: 0, 609 | expected: [ 610 | "2. [ ] Other", 611 | "1. [x] Parent", 612 | " - [ ] Child A", 613 | " - [ ] Child B", 614 | ], 615 | }, 616 | { 617 | name: "Numbered parent with numbered checked children", 618 | content: [ 619 | "1. [ ] Parent", 620 | " 1. [x] Child A", 621 | " 2. [ ] Child B", 622 | ], 623 | index: 0, 624 | expected: [ 625 | "1. [ ] Parent", 626 | " 2. [ ] Child B", 627 | " 1. [x] Child A", 628 | ], 629 | }, 630 | ]; 631 | 632 | testCases.forEach(({ name, content, index, expected }) => { 633 | test(name, () => { 634 | const editor = createMockEditor(content); 635 | reorderChecklist(editor, index); 636 | 637 | for (let i = 0; i < expected.length; i++) { 638 | expect(editor.getLine(i)).toBe(expected[i]); 639 | } 640 | }); 641 | }); 642 | }); 643 | 644 | describe("Numbered checkboxes - reorder and maintain proper numbering", () => { 645 | beforeEach(() => { 646 | jest.clearAllMocks(); 647 | SettingsManager.getInstance().setCheckedItemsAtBottom(true); 648 | SettingsManager.getInstance().setLiveNumberingUpdate(false); 649 | }); 650 | 651 | const testCases = [ 652 | { 653 | name: "Simple numbered list - checkboxes reorder, numbers stay sequential", 654 | content: [ 655 | "1. [ ] Task A", 656 | "2. [x] Task B", 657 | "3. [ ] Task C", 658 | ], 659 | index: 0, 660 | expected: [ 661 | "1. [ ] Task A", 662 | "3. [ ] Task C", 663 | "2. [x] Task B", 664 | ], 665 | }, 666 | { 667 | name: "Numbered list with blocks - entire blocks move together", 668 | content: [ 669 | "1. [x] Parent A", 670 | " Description A", 671 | "2. [ ] Parent B", 672 | " Description B", 673 | "3. [x] Parent C", 674 | ], 675 | index: 0, 676 | expected: [ 677 | "2. [ ] Parent B", 678 | " Description B", 679 | "1. [x] Parent A", 680 | " Description A", 681 | "3. [x] Parent C", 682 | ], 683 | }, 684 | { 685 | name: "Numbered list with nested numbered checkboxes", 686 | content: [ 687 | "1. [ ] Parent A", 688 | " 1. [x] Child A1", 689 | " 2. [ ] Child A2", 690 | "2. [x] Parent B", 691 | " 1. [ ] Child B1", 692 | ], 693 | index: 0, 694 | expected: [ 695 | "1. [ ] Parent A", 696 | " 2. [ ] Child A2", 697 | " 1. [x] Child A1", 698 | "2. [x] Parent B", 699 | " 1. [ ] Child B1", 700 | ], 701 | }, 702 | { 703 | name: "Complex numbered hierarchy with mixed checkbox states", 704 | content: [ 705 | "1. [x] Parent A", 706 | " 1. [ ] Child A1", 707 | " 2. [x] Child A2", 708 | "2. [ ] Parent B", 709 | "3. [x] Parent C", 710 | " 1. [ ] Child C1", 711 | ], 712 | index: 0, 713 | expected: [ 714 | "2. [ ] Parent B", 715 | "1. [x] Parent A", 716 | " 1. [ ] Child A1", 717 | " 2. [x] Child A2", 718 | "3. [x] Parent C", 719 | " 1. [ ] Child C1", 720 | ], 721 | }, 722 | { 723 | name: "Multiple levels of numbered nesting with checkboxes", 724 | content: [ 725 | "1. [ ] Level 1 A", 726 | " 1. [x] Level 2 A1", 727 | " 1. [ ] Level 3 A1a", 728 | " 2. [ ] Level 2 A2", 729 | "2. [x] Level 1 B", 730 | ], 731 | index: 0, 732 | expected: [ 733 | "1. [ ] Level 1 A", 734 | " 2. [ ] Level 2 A2", 735 | " 1. [x] Level 2 A1", 736 | " 1. [ ] Level 3 A1a", 737 | "2. [x] Level 1 B", 738 | ], 739 | }, 740 | { 741 | name: "Numbered list starting from non-1 value", 742 | content: [ 743 | "5. [x] Task A", 744 | "6. [ ] Task B", 745 | "7. [x] Task C", 746 | ], 747 | index: 0, 748 | expected: [ 749 | "6. [ ] Task B", 750 | "5. [x] Task A", 751 | "7. [x] Task C", 752 | ], 753 | }, 754 | { 755 | name: "Numbered blocks with mixed content and nested lists", 756 | content: [ 757 | "1. [x] Parent A", 758 | " Description text", 759 | " 1. [ ] Child A1", 760 | " Some more text", 761 | "2. [ ] Parent B", 762 | " 1. [x] Child B1", 763 | " 2. [ ] Child B2", 764 | ], 765 | index: 0, 766 | expected: [ 767 | "2. [ ] Parent B", 768 | " 2. [ ] Child B2", 769 | " 1. [x] Child B1", 770 | "1. [x] Parent A", 771 | " Description text", 772 | " 1. [ ] Child A1", 773 | " Some more text", 774 | ], 775 | }, 776 | ]; 777 | 778 | testCases.forEach(({ name, content, index, expected }) => { 779 | test(name, () => { 780 | const editor = createMockEditor(content); 781 | reorderChecklist(editor, index); 782 | 783 | for (let i = 0; i < expected.length; i++) { 784 | expect(editor.getLine(i)).toBe(expected[i]); 785 | } 786 | }); 787 | }); 788 | }); 789 | 790 | describe("Numbered checkboxes - checked items at top", () => { 791 | beforeEach(() => { 792 | jest.clearAllMocks(); 793 | SettingsManager.getInstance().setCheckedItemsAtBottom(false); 794 | SettingsManager.getInstance().setLiveNumberingUpdate(false); 795 | }); 796 | 797 | const testCases = [ 798 | { 799 | name: "Checked numbered items move to top", 800 | content: [ 801 | "1. [ ] Task A", 802 | "2. [x] Task B", 803 | "3. [ ] Task C", 804 | ], 805 | index: 0, 806 | expected: [ 807 | "2. [x] Task B", 808 | "1. [ ] Task A", 809 | "3. [ ] Task C", 810 | ], 811 | }, 812 | { 813 | name: "Numbered blocks with checked parent moves to top", 814 | content: [ 815 | "1. [ ] Parent A", 816 | " 1. [ ] Child A1", 817 | "2. [x] Parent B", 818 | " 1. [ ] Child B1", 819 | ], 820 | index: 0, 821 | expected: [ 822 | "2. [x] Parent B", 823 | " 1. [ ] Child B1", 824 | "1. [ ] Parent A", 825 | " 1. [ ] Child A1", 826 | ], 827 | }, 828 | ]; 829 | 830 | testCases.forEach(({ name, content, index, expected }) => { 831 | test(name, () => { 832 | const editor = createMockEditor(content); 833 | reorderChecklist(editor, index); 834 | 835 | for (let i = 0; i < expected.length; i++) { 836 | expect(editor.getLine(i)).toBe(expected[i]); 837 | } 838 | }); 839 | }); 840 | }); 841 | }); 842 | --------------------------------------------------------------------------------