From a5ef5548f81d98d8e63a7d229b000bc4ccbad65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=9A=D1=83=D0=B4?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9?= Date: Thu, 30 Apr 2026 21:45:25 +0300 Subject: [PATCH] Add files via upload --- README.md | 189 +++- main.js | 2946 +++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 9 + styles.css | 394 +++++++ 4 files changed, 3536 insertions(+), 2 deletions(-) create mode 100644 main.js create mode 100644 manifest.json create mode 100644 styles.css diff --git a/README.md b/README.md index 9a85086..8ef15ca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,187 @@ -# structure-commander -Obsidian plugin for managing Markdown structure as a tree: move branches, change heading levels, fold sections, renumber outlines, export branches, and navigate long notes. +# Structure Commander v2.2.1 + +Плагин Obsidian для управления многоуровневой Markdown-структурой: перемещение веток, повышение и понижение уровня (включая преобразования между текстом, заголовками и списками), сворачивание/разворачивание, перенумерация, экспорт ветки, боковая панель и узкая панель инструментов над редактором. + +## Назначение + +Плагин превращает заголовки документа в управляемое дерево. Удобно работать с длинными заметками, ТЗ, конспектами, документацией: переставлять разделы, менять глубину иерархии, быстро сворачивать/разворачивать ветки, экспортировать отдельные подразделы в самостоятельные заметки. + +## Команды + +Все команды доступны через палитру `Ctrl+P`. Названия команд начинаются с `Structure Commander`. + +| Команда | Действие | +|---|---| +| Ветка выше | переместить ветку выше (или, если есть выделение — переместить выделенные строки выше) | +| Ветка ниже | переместить ветку ниже (или, если есть выделение — переместить выделенные строки ниже) | +| Повысить (заголовок/текст/список) | универсальное «повышение» по цепочке: `а)` → `1)` → `1.` → `- ` → текст → H6 → H5 → … → H1 | +| Понизить (заголовок/текст/список) | универсальное «понижение» по цепочке: H1 → … → H6 → текст → `- ` → `1.` → `1)` → `а)` | +| Свернуть ветку | свернуть только текущую ветку | +| Развернуть ветку | развернуть только текущую ветку | +| Показать документ до Hx | свернуть всё, что глубже Hx | +| Показать всё | полностью развернуть документ | +| Скрыть/показать боковую панель | переключатель видимости панели структуры | +| Скрыть/показать панель инструментов | переключатель видимости тулбара над редактором | +| Поставить заголовку ветки H1…H6 | назначить уровень верхнему заголовку, всю ветку сдвинуть с сохранением иерархии | +| Перенумеровать структуру | пронумеровать заголовки и/или списки | +| Удалить нумерацию | убрать существующие префиксы нумерации | +| Emoji-цифры | заменить нумерацию на 1️⃣, 2️⃣, … | +| Исправить пропуски уровней | подтянуть уровни так, чтобы не было прыжков H1 → H3 | +| Экспорт ветки | сохранить текущую ветку в отдельный `.md` | + +## Хоткеи по умолчанию + +| Действие | Хоткей | +|---|---| +| Ветка выше | `Alt+Shift+↑` | +| Ветка ниже | `Alt+Shift+↓` | +| Повысить ветку | `Alt+Shift+←` | +| Понизить ветку | `Alt+Shift+→` | +| Свернуть ветку | `Alt+←` | +| Развернуть ветку | `Alt+→` | + +Хоткеи `Alt+←/→` выбраны намеренно вместо `Ctrl+Shift+←/→`: последние конфликтуют со стандартным выделением текста по словам в любом текстовом поле. + +Изменить хоткеи: `Настройки → Hotkeys`, ищите `Structure`. В настройках плагина есть кнопка `Открыть Hotkeys`. + +## Панель инструментов над редактором + +Над Markdown-редактором есть узкая горизонтальная панель: + +``` +[↑] [↓] [←] [→] [−] [+] [Показать: H1 ▾] [H? ▾] [№] +``` + +| Кнопка | Действие | +|---|---| +| ↑ ↓ | переместить ветку выше/ниже | +| ← → | повысить/понизить (заголовок, текст или список) | +| − + | свернуть / развернуть ветку | +| Показать: Hn ▾ | дропдаун с уровнями документа + «Показать всё». В тексте кнопки показан последний выбранный уровень | +| H? ▾ | уровень текущего заголовка; меню позволяет сменить уровень или назначить уровень верхнему заголовку всей ветки | +| № | перенумерация (ПКМ — подменю с другими режимами) | + +В tooltip на кнопках показано действие и текущий хоткей. ПКМ по кнопке открывает меню с пунктом **«Назначить хоткей»** — это всплывающее окно, в котором достаточно нажать желаемую комбинацию клавиш (включая `Numpad1..9`, `F1..F12`, любые модификаторы), и плагин сразу её сохранит. Можно также очистить хоткей или открыть штатный редактор Obsidian. + +Все выпадающие меню тулбара (`Показать ▾`, `Hn ▾`, ПКМ по `№`) показывают рядом с пунктами их текущие хоткеи, если хоткей назначен. + +Кнопка `×` справа на тулбаре скрывает его. Вернуть тулбар: команда `Скрыть/показать панель инструментов над редактором` (или галка в настройках плагина). + +Когда курсор стоит вне ветки заголовков, кнопки операций над веткой подсвечены приглушённо. + +Если не нужна — отключается в настройках плагина. + +## Контекстное меню редактора (ПКМ) + +ПКМ в Markdown-редакторе добавляет в контекстное меню несколько верхнеуровневых пунктов с подменю: + +``` +Развернуть/свернуть ▸ + ├─ Развернуть Alt+→ + ├─ Свернуть Alt+← + └─ Показать/скрыть панель структуры + +Показать до ▸ H1, H2, …, Показать всё + (только реально существующие в документе уровни) + +Уровень | Перенос ▸ + ├─ Повысить Alt+Shift+← + ├─ Понизить Alt+Shift+→ + ├─ Вверх Alt+Shift+↑ + ├─ Вниз Alt+Shift+↓ + ├─ Понизить до ▸ H2, H3, … (вся ветка с сохранением иерархии) + └─ Повысить до ▸ H1, H2, … (вся ветка с сохранением иерархии) + +Нумерация ▸ Перенумеровать, Удалить, Emoji, Исправить пропуски + +Экспорт ветки… +``` + +В подменю «Понизить до» / «Повысить до» — только те уровни, при сдвиге к которым ни один заголовок ветки не выйдет за пределы H1…H6. + +`Повысить` / `Понизить` универсальны: работают и на заголовке (вся ветка), и на обычном тексте (текст → H6 / текст → список), и на маркированном списке (список → текст). + +## Боковая панель + +Команды: + +- `Открыть боковую панель структуры справа` +- `Открыть боковую панель структуры слева` +- `Открыть боковую панель структуры снизу` +- `Скрыть/показать боковую панель структуры` (можно повесить хоткей) + +В панели: + +- поиск по заголовкам; +- фильтр глубины (`До H1` … `До H6`, `Все`); +- счётчик `видно / всего`; +- подсветка текущего заголовка (по позиции курсора в активном редакторе); +- кнопка `×` в шапке скрывает панель; +- ПКМ по пункту: `Перейти`, свернуть/развернуть, переместить выше/ниже, повысить/понизить, экспорт. + +Уровень заголовка в панели передаётся **визуально** — отступом, жирностью, наклоном и цветом текста. Подписи `H1`, `H2` намеренно не дублируются в каждой строке. + +Панель обновляется при смене активного файла, при изменении файла, при смене активного редактора. Чтение файла защищено от устаревших async-результатов и работает с debounce (≈220 мс). + +## Экспорт ветки + +Окно экспорта: + +- куда сохранить: рядом с текущей заметкой / в указанную папку / спрашивать каждый раз; +- папка по умолчанию: `_Exports`; +- чекбокс «Открыть файл после экспорта» (по умолчанию выключен); +- чекбокс «Привести корневой заголовок к H1» — при экспорте сдвигает уровни так, чтобы корень стал H1; +- имя файла берётся из заголовка корня ветки, запрещённые символы вычищаются; +- при коллизии добавляется суффикс ` 2`, ` 3`, ` 4` …; +- последний выбор запоминается, `Enter` повторяет. + +## Перенумерация + +Окно перенумерации: + +- область: весь документ / текущая ветка / выделенный фрагмент; +- что менять: только заголовки / только списки / заголовки и списки; +- стиль: `1. / 1.1.` или `1️⃣ / 1️⃣.1️⃣`; +- предпросмотр: первые 8 изменений в виде `было → станет`; +- сводка `Будет изменено строк: N`. + +Применение — одной транзакцией. Курсор и позиция скролла сохраняются. `Ctrl+Z` откатывает всю операцию одним действием. + +## Транзакционность + +Все массовые изменения (перемещение ветки, повышение/понижение, нормализация, перенумерация, удаление нумерации, emoji-цифры) применяются одной транзакцией редактора — `Ctrl+Z` откатывает каждую операцию одним действием. + +`moveBlock` переписан так, чтобы: + +- корректно работать на первой и последней ветке; +- корректно работать на файле без финального перевода строки; +- не создавать лишних пустых строк; +- не склеивать заголовки. + +## Ограничения + +- Markdown поддерживает только заголовки **H1 … H6**. +- Плагин работает с **ATX-заголовками**: `#`, `##`, `###`, … +- **Setext-заголовки** (с подчёркиванием `===` / `---`) не поддерживаются. +- Заголовки внутри **fenced code blocks** (` ``` ` или `~~~`) игнорируются. +- Глобальные хоткеи Windows / macOS / других приложений изнутри Obsidian проверить нельзя — коллизии с системными или сторонними хоткеями плагин не покажет, только коллизии внутри Obsidian. +- При использовании `Alt+←/→` для свёртывания: на macOS `Alt` это `Option` — учитывайте раскладки клавиатуры. + +## Установка + +1. Закрыть Obsidian. +2. Скопировать папку `structure-commander` в `.obsidian/plugins/` хранилища. +3. Проверить структуру файлов: + +``` +.obsidian/plugins/structure-commander/manifest.json +.obsidian/plugins/structure-commander/main.js +.obsidian/plugins/structure-commander/styles.css +``` + +4. Открыть Obsidian. +5. `Настройки → Community plugins` → включить `Structure Commander`. + +## Совместимость + +`minAppVersion`: 1.0.0. Тестировалось с актуальными десктопными сборками Obsidian. На мобильных Obsidian панель инструментов над редактором тоже работает, но физических хоткеев на телефоне нет — пользуйтесь верхней панелью и подменю ПКМ. diff --git a/main.js b/main.js new file mode 100644 index 0000000..99bd044 --- /dev/null +++ b/main.js @@ -0,0 +1,2946 @@ +"use strict"; + +const { + Plugin, + Notice, + MarkdownView, + Modal, + Menu, + ItemView, + PluginSettingTab, + Setting, + TFile, + normalizePath, + debounce +} = require("obsidian"); + +const STRUCTURE_PANEL_VIEW = "structure-commander-panel"; + +// Русский алфавит без 'ё' и 'й' (визуально похожих на и). 30 букв. +const ALPHA_LIST = ["а","б","в","г","д","е","ж","з","и","к","л","м","н","о","п","р","с","т","у","ф","х","ц","ч","ш","щ","ъ","ы","ь","э","ю","я"]; + +const DEFAULT_SETTINGS = { + panelMaxLevel: 6, + exportMode: "same-folder", + exportFolder: "_Exports", + exportAskEachTime: false, + exportOpenAfter: false, + exportPromoteToH1: false, + renumberScope: "document", + renumberTargets: "headings", + renumberStyle: "numeric", + lastPanelSide: "right", + showEditorToolbar: true, + toolbarSize: "compact", + lastShowToLevel: 1 +}; + +// Хоткеи по умолчанию. +// Alt+←/→ для свернуть/развернуть выбраны намеренно: Ctrl+Shift+←/→ +// конфликтуют со штатным выделением слова в любом текстовом поле. +const HOTKEYS = { + "move-current-branch-up": [{ modifiers: ["Alt", "Shift"], key: "ArrowUp" }], + "move-current-branch-down": [{ modifiers: ["Alt", "Shift"], key: "ArrowDown" }], + "promote-current-branch": [{ modifiers: ["Alt", "Shift"], key: "ArrowLeft" }], + "demote-current-branch": [{ modifiers: ["Alt", "Shift"], key: "ArrowRight" }], + "collapse-current-branch": [{ modifiers: ["Alt"], key: "ArrowLeft" }], + "expand-current-branch": [{ modifiers: ["Alt"], key: "ArrowRight" }] +}; + +const HOTKEY_LABELS_RU = { + "move-current-branch-up": "Ветка выше", + "move-current-branch-down": "Ветка ниже", + "promote-current-branch": "Повысить ветку", + "demote-current-branch": "Понизить ветку", + "collapse-current-branch": "Свернуть ветку", + "expand-current-branch": "Развернуть ветку" +}; + +module.exports = class StructureCommanderPlugin extends Plugin { + + async onload() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.cmLanguage = this.loadCodeMirrorLanguage(); + this.lastMarkdownFilePath = ""; + this.toolbars = new WeakMap(); // MarkdownView -> EditorToolbar + this._panelReadToken = 0; // защита от устаревших async-read + + this.addSettingTab(new StructureCommanderSettingTab(this.app, this)); + + this.registerView( + STRUCTURE_PANEL_VIEW, + (leaf) => new StructurePanelView(leaf, this) + ); + + this.addRibbonIcon("list-tree", "Structure Commander", () => { + new StructureCommanderModal(this.app, this).open(); + }); + + // — события — + this.registerEvent(this.app.workspace.on("file-open", (file) => { + if (file instanceof TFile && file.extension && file.extension.toLowerCase() === "md") { + this.lastMarkdownFilePath = file.path; + } + this.refreshStructurePanels(); + this.syncAllToolbars(); + })); + + this.registerEvent(this.app.workspace.on("active-leaf-change", () => { + this.syncAllToolbars(); + this.refreshStructurePanels(); + })); + + this.registerEvent(this.app.workspace.on("layout-change", () => { + this.syncAllToolbars(); + })); + + this.registerEvent(this.app.vault.on("modify", (file) => { + if (file instanceof TFile && file.extension && file.extension.toLowerCase() === "md") { + const cur = this.getCurrentMarkdownFile(); + if (cur && cur.path === file.path) { + this.refreshStructurePanels(); + this.refreshActiveToolbar(); + } + } + })); + + this.registerEvent(this.app.workspace.on("editor-change", (editor, info) => { + this.refreshActiveToolbar(); + })); + + this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor, view) => { + this.buildEditorContextMenu(menu, editor, view); + })); + + // — команды — + this.registerCommands(); + + // — toolbar над редактором — + this.app.workspace.onLayoutReady(() => this.syncAllToolbars()); + } + + onunload() { + // снять все toolbar-инъекции + const leaves = this.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + const v = leaf.view; + if (v instanceof MarkdownView) { + const tb = this.toolbars.get(v); + if (tb) { tb.destroy(); this.toolbars.delete(v); } + } + }); + } + + registerCommands() { + this.addCommand({ + id: "open-structure-commander", + name: "Открыть Structure Commander", + callback: () => new StructureCommanderModal(this.app, this).open() + }); + + this.addCommand({ + id: "open-hotkeys-settings", + name: "Открыть настройки горячих клавиш", + callback: () => this.openHotkeysSettings() + }); + + this.addCommand({ + id: "open-structure-panel-right", + name: "Открыть боковую панель структуры справа", + callback: () => this.openStructurePanel("right") + }); + this.addCommand({ + id: "open-structure-panel-left", + name: "Открыть боковую панель структуры слева", + callback: () => this.openStructurePanel("left") + }); + this.addCommand({ + id: "open-structure-panel-bottom", + name: "Открыть боковую панель структуры снизу", + callback: () => this.openStructurePanel("bottom") + }); + this.addCommand({ + id: "toggle-structure-panel", + name: "Скрыть/показать боковую панель структуры", + callback: () => this.toggleStructurePanel() + }); + this.addCommand({ + id: "toggle-editor-toolbar", + name: "Скрыть/показать панель инструментов над редактором", + callback: () => this.toggleEditorToolbar() + }); + + this.addCommand({ + id: "fold-all-headings", + name: "Свернуть все заголовки", + callback: () => this.foldAll() + }); + this.addCommand({ + id: "unfold-all-headings", + name: "Развернуть все заголовки (Показать всё)", + callback: () => this.unfoldAll() + }); + + for (let level = 1; level <= 6; level++) { + this.addCommand({ + id: "show-document-to-h" + level, + name: "Показать документ до H" + level, + callback: () => this.showDocumentToLevel(level) + }); + this.addCommand({ + id: "set-current-heading-h" + level, + name: "Поставить текущему заголовку уровень H" + level, + callback: () => this.setCurrentHeadingLevel(level) + }); + this.addCommand({ + id: "set-current-branch-root-h" + level, + name: "Поставить заголовку ветки H" + level + " (с сохранением иерархии)", + callback: () => this.setCurrentBranchRootLevel(level) + }); + } + + this.addCommand({ + id: "collapse-current-branch", + name: "Свернуть текущую ветку", + hotkeys: HOTKEYS["collapse-current-branch"], + callback: () => this.collapseCurrentBranch() + }); + this.addCommand({ + id: "expand-current-branch", + name: "Развернуть текущую ветку", + hotkeys: HOTKEYS["expand-current-branch"], + callback: () => this.expandCurrentBranch() + }); + this.addCommand({ + id: "toggle-current-branch-fold", + name: "Переключить сворачивание текущей ветки", + callback: () => this.toggleCurrentBranchFold() + }); + + for (let depth = 1; depth <= 6; depth++) { + this.addCommand({ + id: "show-current-branch-depth-" + depth, + name: "Показать текущую ветку до глубины " + depth, + callback: () => this.showCurrentBranchDepth(depth) + }); + } + + this.addCommand({ + id: "promote-current-heading", + name: "Повысить только текущий заголовок", + callback: () => this.shiftCurrentHeading(-1) + }); + this.addCommand({ + id: "demote-current-heading", + name: "Понизить только текущий заголовок", + callback: () => this.shiftCurrentHeading(1) + }); + + this.addCommand({ + id: "promote-current-branch", + name: "Повысить (заголовок/текст/список)", + hotkeys: HOTKEYS["promote-current-branch"], + callback: () => this.shiftLineAtCursor(-1) + }); + this.addCommand({ + id: "demote-current-branch", + name: "Понизить (заголовок/текст/список)", + hotkeys: HOTKEYS["demote-current-branch"], + callback: () => this.shiftLineAtCursor(1) + }); + + this.addCommand({ + id: "copy-current-branch", + name: "Скопировать текущую ветку", + callback: () => this.copyCurrentBranch() + }); + this.addCommand({ + id: "export-current-branch", + name: "Экспортировать ветку в .md", + callback: () => this.openExportBranchModal() + }); + + this.addCommand({ + id: "move-current-branch-up", + name: "Ветка выше", + hotkeys: HOTKEYS["move-current-branch-up"], + callback: () => this.moveCurrentBranch(-1) + }); + this.addCommand({ + id: "move-current-branch-down", + name: "Ветка ниже", + hotkeys: HOTKEYS["move-current-branch-down"], + callback: () => this.moveCurrentBranch(1) + }); + + this.addCommand({ + id: "normalize-heading-levels", + name: "Исправить пропуски уровней", + callback: () => this.normalizeHeadingLevels() + }); + this.addCommand({ + id: "renumber-structure", + name: "Перенумеровать структуру…", + callback: () => this.openRenumberModal("renumber") + }); + this.addCommand({ + id: "remove-structure-numbering", + name: "Удалить нумерацию…", + callback: () => this.openRenumberModal("remove") + }); + this.addCommand({ + id: "emoji-number-structure", + name: "Заменить нумерацию на emoji-цифры…", + callback: () => this.openRenumberModal("emoji") + }); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + loadCodeMirrorLanguage() { + try { + if (typeof require !== "undefined") { + return require("@codemirror/language"); + } + } catch (e) { + return null; + } + return null; + } + + // ─────────────────────── контекст: вью / редактор / файл ─────────────────────── + + getMarkdownView(silent) { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view || !view.editor) { + if (!silent) new Notice("Откройте Markdown-заметку"); + return null; + } + if (view.file instanceof TFile) this.lastMarkdownFilePath = view.file.path; + return view; + } + + getEditor(silent) { + const view = this.getMarkdownView(silent); + return view ? view.editor : null; + } + + getCurrentMarkdownFile() { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (view && view.file instanceof TFile) { + this.lastMarkdownFilePath = view.file.path; + return view.file; + } + if (this.lastMarkdownFilePath) { + const file = this.app.vault.getAbstractFileByPath(this.lastMarkdownFilePath); + if (file instanceof TFile) return file; + } + return null; + } + + // ─────────────────────── хоткеи: чтение и подписи ─────────────────────── + + getHotkeyLabel(commandId) { + const fullId = this.manifest.id + ":" + commandId; + try { + if (this.app.hotkeyManager && typeof this.app.hotkeyManager.getHotkeys === "function") { + const hk = this.app.hotkeyManager.getHotkeys(fullId); + if (hk && hk.length) return formatHotkey(hk[0]); + } + } catch (e) { /* fallthrough */ } + + try { + if (this.app.hotkeyManager && typeof this.app.hotkeyManager.getDefaultHotkeys === "function") { + const hk = this.app.hotkeyManager.getDefaultHotkeys(fullId); + if (hk && hk.length) return formatHotkey(hk[0]); + } + } catch (e) { /* fallthrough */ } + + const fb = HOTKEYS[commandId]; + if (fb && fb.length) return formatHotkey(fb[0]); + return ""; + } + + labelWithHotkey(label, commandId) { + const hk = this.getHotkeyLabel(commandId); + return hk ? label + "\u00A0\u00A0\u00A0" + hk : label; + } + + // ─────────────────────── контекстное меню редактора ─────────────────────── + + buildEditorContextMenu(menu, editor, view) { + if (!editor || !(view instanceof MarkdownView)) return; + + const headings = this.getHeadings(editor); + if (headings.length === 0) return; + + const currentHeading = this.getCurrentHeadingSilently(editor); + const docMaxLevel = headings.reduce((m, h) => Math.max(m, h.level), 1); + + menu.addSeparator(); + + // ── 1. «Развернуть/свернуть» — подменю ── + menu.addItem((item) => { + item.setTitle("Развернуть/свернуть").setIcon("chevrons-up-down"); + const sub = (typeof item.setSubmenu === "function") ? item.setSubmenu() : null; + if (!sub) { + item.onClick(() => this.toggleCurrentBranchFold()); + return; + } + sub.addItem((it) => it + .setTitle(this.labelWithHotkey("Развернуть", "expand-current-branch")) + .setIcon("chevrons-down") + .onClick(() => this.expandCurrentBranch())); + sub.addItem((it) => it + .setTitle(this.labelWithHotkey("Свернуть", "collapse-current-branch")) + .setIcon("chevrons-up") + .onClick(() => this.collapseCurrentBranch())); + sub.addSeparator(); + sub.addItem((it) => it + .setTitle("Показать/скрыть панель структуры") + .setIcon("panel-right") + .onClick(() => this.toggleStructurePanel())); + sub.addItem((it) => it + .setTitle("Показать/скрыть панель инструментов") + .setIcon("layout-panel-top") + .onClick(() => this.toggleEditorToolbar())); + }); + + // ── 2. «Показать до» — подменю с реально существующими уровнями ── + menu.addItem((item) => { + item.setTitle("Показать до").setIcon("eye"); + const sub = (typeof item.setSubmenu === "function") ? item.setSubmenu() : null; + if (!sub) { + item.onClick(() => this.showDocumentToLevel(docMaxLevel)); + return; + } + const presentLevels = uniqueSortedLevels(headings); + presentLevels.forEach((lv) => { + const cmdId = "show-document-to-h" + lv; + const hk = this.getHotkeyLabel(cmdId); + const title = "H" + lv + (hk ? " " + hk : ""); + sub.addItem((x) => x.setTitle(title).onClick(() => this.showDocumentToLevel(lv))); + }); + sub.addSeparator(); + sub.addItem((x) => x + .setTitle("Показать всё") + .setIcon("expand") + .onClick(() => this.unfoldAll())); + }); + + // ── 3. «Уровень | Перенос» — все стрелки + повысить-до/понизить-до ── + menu.addItem((item) => { + item.setTitle("Уровень | Перенос").setIcon("move-vertical"); + const sub = (typeof item.setSubmenu === "function") ? item.setSubmenu() : null; + if (!sub) { + item.onClick(() => this.shiftCurrentBranch(1)); + return; + } + + sub.addItem((it) => it + .setTitle(this.labelWithHotkey("Повысить", "promote-current-branch")) + .setIcon("arrow-left") + .onClick(() => this.shiftLineAtCursor(-1))); + + sub.addItem((it) => it + .setTitle(this.labelWithHotkey("Понизить", "demote-current-branch")) + .setIcon("arrow-right") + .onClick(() => this.shiftLineAtCursor(1))); + + sub.addItem((it) => it + .setTitle(this.labelWithHotkey("Вверх", "move-current-branch-up")) + .setIcon("arrow-up") + .onClick(() => this.moveCurrentBranch(-1))); + + sub.addItem((it) => it + .setTitle(this.labelWithHotkey("Вниз", "move-current-branch-down")) + .setIcon("arrow-down") + .onClick(() => this.moveCurrentBranch(1))); + + // Точечные «Повысить до Hx» / «Понизить до Hx» + if (currentHeading) { + const branchHeadings = headings.filter((h) => + h.line >= currentHeading.line && + h.line <= this.getBranchEndLine(editor, currentHeading)); + const branchMaxLevel = branchHeadings.reduce((m, h) => Math.max(m, h.level), currentHeading.level); + + sub.addSeparator(); + + sub.addItem((it) => { + it.setTitle("Понизить до").setIcon("arrow-right"); + const ds = (typeof it.setSubmenu === "function") ? it.setSubmenu() : null; + if (!ds) { it.onClick(() => this.shiftCurrentBranch(1)); return; } + let any = false; + for (let target = currentHeading.level + 1; target <= 6; target++) { + const delta = target - currentHeading.level; + if (branchMaxLevel + delta > 6) break; + any = true; + const t = target; + const cmdId = "set-current-branch-root-h" + t; + const hk = this.getHotkeyLabel(cmdId); + const title = "H" + t + (hk ? " " + hk : ""); + ds.addItem((x) => x.setTitle(title).onClick(() => this.setCurrentBranchRootLevel(t))); + } + if (!any) { + ds.addItem((x) => x.setTitle("Невозможно: глубина уже H6").setDisabled(true)); + } + }); + + sub.addItem((it) => { + it.setTitle("Повысить до").setIcon("arrow-left"); + const ps = (typeof it.setSubmenu === "function") ? it.setSubmenu() : null; + if (!ps) { it.onClick(() => this.shiftCurrentBranch(-1)); return; } + let any = false; + for (let target = currentHeading.level - 1; target >= 1; target--) { + any = true; + const t = target; + const cmdId = "set-current-branch-root-h" + t; + const hk = this.getHotkeyLabel(cmdId); + const title = "H" + t + (hk ? " " + hk : ""); + ps.addItem((x) => x.setTitle(title).onClick(() => this.setCurrentBranchRootLevel(t))); + } + if (!any) { + ps.addItem((x) => x.setTitle("Невозможно: уже H1").setDisabled(true)); + } + }); + } + }); + + // ── 4. Нумерация ── + menu.addItem((item) => { + item.setTitle("Нумерация").setIcon("list-ordered"); + const sub = (typeof item.setSubmenu === "function") ? item.setSubmenu() : null; + if (!sub) { + item.onClick(() => this.openRenumberModal("renumber")); + return; + } + sub.addItem((x) => x.setTitle("Перенумеровать…").onClick(() => this.openRenumberModal("renumber"))); + sub.addItem((x) => x.setTitle("Удалить нумерацию…").onClick(() => this.openRenumberModal("remove"))); + sub.addItem((x) => x.setTitle("Emoji-цифры…").onClick(() => this.openRenumberModal("emoji"))); + sub.addSeparator(); + sub.addItem((x) => x.setTitle("Исправить пропуски уровней").onClick(() => this.normalizeHeadingLevels())); + }); + + // ── 5. Экспорт ветки ── + menu.addItem((item) => item + .setTitle("Экспорт ветки…") + .setIcon("download") + .onClick(() => this.openExportBranchModal())); + } + + // ─────────────────────── парсинг заголовков ─────────────────────── + + /** + * Заголовки текущего редактора. ATX (# ... ######), игнорирует код-блоки. + * Возвращает [{line, level, text, title}, ...]. + */ + getHeadings(editor) { + const lines = []; + const total = editor.lineCount(); + for (let i = 0; i < total; i++) lines.push(editor.getLine(i)); + return parseHeadingsFromLines(lines); + } + + getCurrentHeadingSilently(editor) { + const cursor = editor.getCursor(); + const headings = this.getHeadings(editor); + let cur = null; + for (let i = 0; i < headings.length; i++) { + if (headings[i].line <= cursor.line) cur = headings[i]; + else break; + } + return cur; + } + + getCurrentHeading(editor) { + const c = this.getCurrentHeadingSilently(editor); + if (!c) { + new Notice("Курсор не находится внутри ветки заголовков"); + return null; + } + return c; + } + + getBranchEndLine(editor, heading) { + const headings = this.getHeadings(editor); + let endLine = editor.lineCount() - 1; + for (let i = 0; i < headings.length; i++) { + const h = headings[i]; + if (h.line <= heading.line) continue; + if (h.level <= heading.level) { endLine = h.line - 1; break; } + } + return endLine; + } + + getBranch(editor, heading) { + return { + startLine: heading.line, + endLine: this.getBranchEndLine(editor, heading), + level: heading.level, + heading + }; + } + + // ─────────────────────── свернуть / развернуть ─────────────────────── + + async foldAll() { + const editor = this.getEditor(); if (!editor) return; + if (!this.foldAllDirect(editor)) this.executeCommand("editor:fold-all"); + } + + async unfoldAll() { + const editor = this.getEditor(); if (!editor) return; + const ok = this.unfoldRangeDirect(editor, 0, editor.lineCount() - 1); + if (!ok) this.executeCommand("editor:unfold-all"); + } + + async showDocumentToLevel(level) { + const editor = this.getEditor(); if (!editor) return; + const headings = this.getHeadings(editor); + if (!headings.length) { new Notice("В документе нет заголовков"); return; } + const toFold = headings.filter((h) => h.level >= level).reverse(); + + this.settings.lastShowToLevel = level; + this.saveSettings(); + this.refreshActiveToolbar(); + + if (this.cmLanguage && editor.cm) { + this.unfoldRangeDirect(editor, 0, editor.lineCount() - 1); + this.foldHeadingsDirect(editor, toFold); + new Notice("Показано до H" + level); + return; + } + await this.fallbackFoldHeadings(editor, toFold, true); + new Notice("Показано до H" + level); + } + + async collapseCurrentBranch() { + const editor = this.getEditor(); if (!editor) return; + const heading = this.getCurrentHeading(editor); if (!heading) return; + + if (this.cmLanguage && editor.cm) { + this.foldHeadingsDirect(editor, [heading]); + } else { + await this.toggleHeadingFoldFallback(editor, heading.line); + } + } + + async expandCurrentBranch() { + const editor = this.getEditor(); if (!editor) return; + const heading = this.getCurrentHeading(editor); if (!heading) return; + + const branch = this.getBranch(editor, heading); + const ok = this.unfoldRangeDirect(editor, branch.startLine, branch.endLine); + if (ok) return; + + // Точечный fallback: тогглим конкретный заголовок. Никаких unfold-all. + await this.toggleHeadingFoldFallback(editor, heading.line); + } + + async toggleCurrentBranchFold() { + const editor = this.getEditor(); if (!editor) return; + const heading = this.getCurrentHeading(editor); if (!heading) return; + await this.toggleHeadingFoldFallback(editor, heading.line); + } + + async showCurrentBranchDepth(depth) { + const editor = this.getEditor(); if (!editor) return; + const root = this.getCurrentHeading(editor); if (!root) return; + + const branch = this.getBranch(editor, root); + const targetLevel = Math.min(6, root.level + depth - 1); + const toFold = this.getHeadings(editor) + .filter((h) => h.line >= branch.startLine && h.line <= branch.endLine && h.level > targetLevel) + .reverse(); + + if (this.cmLanguage && editor.cm) { + this.unfoldRangeDirect(editor, branch.startLine, branch.endLine); + this.foldHeadingsDirect(editor, toFold); + new Notice("Ветка показана до глубины " + depth); + return; + } + await this.fallbackFoldHeadings(editor, toFold, true); + new Notice("Ветка показана до глубины " + depth); + } + + foldAllDirect(editor) { + const headings = this.getHeadings(editor).slice().reverse(); + return this.foldHeadingsDirect(editor, headings); + } + + foldHeadingsDirect(editor, headings) { + if (!this.cmLanguage || !editor.cm || !this.cmLanguage.foldEffect) return false; + const view = editor.cm; + const effects = []; + const seen = Object.create(null); + + for (let i = 0; i < headings.length; i++) { + const h = headings[i]; + if (seen[h.line]) continue; + seen[h.line] = true; + + const endLine = this.getBranchEndLine(editor, h); + if (endLine <= h.line) continue; + + const from = this.safePosToOffset(editor, { line: h.line, ch: editor.getLine(h.line).length }); + const to = this.safePosToOffset(editor, { line: endLine, ch: editor.getLine(endLine).length }); + if (from !== null && to !== null && to > from) { + effects.push(this.cmLanguage.foldEffect.of({ from, to })); + } + } + if (!effects.length) return true; + try { view.dispatch({ effects }); return true; } + catch (e) { console.error(e); return false; } + } + + unfoldRangeDirect(editor, startLine, endLine) { + if (!this.cmLanguage || !editor.cm || !this.cmLanguage.unfoldEffect || !this.cmLanguage.foldedRanges) return false; + const view = editor.cm; + const from = this.safePosToOffset(editor, { line: Math.max(0, startLine), ch: 0 }); + const safeEnd = Math.min(editor.lineCount() - 1, endLine); + const to = this.safePosToOffset(editor, { line: safeEnd, ch: editor.getLine(safeEnd).length }); + if (from === null || to === null) return false; + + const effects = []; + try { + this.cmLanguage.foldedRanges(view.state).between(from, to, (a, b) => { + effects.push(this.cmLanguage.unfoldEffect.of({ from: a, to: b })); + }); + if (effects.length) view.dispatch({ effects }); + return true; + } catch (e) { console.error(e); return false; } + } + + safePosToOffset(editor, pos) { + try { if (typeof editor.posToOffset === "function") return editor.posToOffset(pos); } + catch (e) { return null; } + return null; + } + + async fallbackFoldHeadings(editor, headings, unfoldFirst) { + const cursor = editor.getCursor(); + if (unfoldFirst) { + this.executeCommand("editor:unfold-all"); + await sleep(30); + } + for (let i = 0; i < headings.length; i++) { + await this.toggleHeadingFoldFallback(editor, headings[i].line); + await sleep(10); + } + editor.setCursor(cursor); + } + + async toggleHeadingFoldFallback(editor, line) { + const cursor = editor.getCursor(); + editor.setCursor({ line, ch: 0 }); + this.executeCommand("editor:toggle-fold"); + await sleep(10); + editor.setCursor(cursor); + } + + executeCommand(id) { + try { this.app.commands.executeCommandById(id); return true; } + catch (e) { console.error(e); new Notice("Не удалось выполнить команду: " + id); return false; } + } + + // ─────────────────────── уровни заголовков ─────────────────────── + + setCurrentHeadingLevel(level) { + const editor = this.getEditor(); if (!editor) return; + const h = this.getCurrentHeading(editor); if (!h) return; + if (level === h.level) return; + this.replaceHeadingLevel(editor, h.line, level); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Заголовок: H" + level); + } + + setCurrentBranchRootLevel(level) { + const editor = this.getEditor(); if (!editor) return; + const root = this.getCurrentHeading(editor); if (!root) return; + const delta = level - root.level; + if (delta === 0) { new Notice("Уровень не изменился"); return; } + + // Проверка границ всей ветки + const branch = this.getBranch(editor, root); + let minL = root.level, maxL = root.level; + for (let l = branch.startLine; l <= branch.endLine; l++) { + const t = editor.getLine(l); + const m = t.match(/^(#{1,6})(\s+.+)$/); + if (!m) continue; + const lv = m[1].length; + if (lv < minL) minL = lv; + if (lv > maxL) maxL = lv; + } + if (minL + delta < 1) { new Notice("Операция невозможна: ветка содержит H" + minL); return; } + if (maxL + delta > 6) { new Notice("Операция невозможна: ветка ушла бы за H6"); return; } + + this.shiftBranch(editor, root, delta); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Заголовок ветки: H" + level); + } + + shiftCurrentHeading(delta) { + const editor = this.getEditor(); if (!editor) return; + const h = this.getCurrentHeading(editor); if (!h) return; + const next = clamp(h.level + delta, 1, 6); + if (next === h.level) { new Notice("Дальше менять уровень нельзя"); return; } + this.replaceHeadingLevel(editor, h.line, next); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Заголовок: H" + next); + } + + shiftCurrentBranch(delta) { + const editor = this.getEditor(); if (!editor) return; + const root = this.getCurrentHeading(editor); if (!root) return; + + // защита границ + const branch = this.getBranch(editor, root); + let minL = root.level, maxL = root.level; + for (let l = branch.startLine; l <= branch.endLine; l++) { + const t = editor.getLine(l); + const m = t.match(/^(#{1,6})(\s+.+)$/); + if (!m) continue; + const lv = m[1].length; + if (lv < minL) minL = lv; + if (lv > maxL) maxL = lv; + } + if (minL + delta < 1) { new Notice("Операция невозможна: ветка содержит H" + minL); return; } + if (maxL + delta > 6) { new Notice("Операция невозможна: ветка ушла бы за H6"); return; } + + this.shiftBranch(editor, root, delta); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice(delta < 0 ? "Ветка повышена" : "Ветка понижена"); + } + + shiftBranch(editor, root, delta) { + const branch = this.getBranch(editor, root); + const replacements = []; + for (let line = branch.startLine; line <= branch.endLine; line++) { + const text = editor.getLine(line); + const m = text.match(/^(#{1,6})(\s+.+)$/); + if (!m) continue; + const cur = m[1].length; + const nxt = clamp(cur + delta, 1, 6); + if (nxt === cur) continue; + replacements.push({ line, text: "#".repeat(nxt) + m[2] }); + } + this.applyLineReplacements(editor, replacements); + } + + replaceHeadingLevel(editor, line, level) { + const text = editor.getLine(line); + const m = text.match(/^(#{1,6})(\s+.+)$/); + if (!m) { new Notice("Текущая строка — не заголовок"); return; } + this.applyLineReplacements(editor, [{ line, text: "#".repeat(level) + m[2] }]); + } + + /** + * Универсальный shift по Alt+Shift+←/→. + * + * Цепочка типов «снизу вверх по абстракции»: + * stage 0 → обычный текст + * stage 1 → "- " маркированный + * stage 2 → "1. " нумерованный точкой + * stage 3 → "1) " нумерованный скобкой + * stage 4 → "а) " буквенный со скобкой (а, б, в, …) + * + * delta = +1 (понизить, →): двигаемся 0→1→2→3→4. На 4 — некуда дальше. + * delta = -1 (повысить, ←): 4→3→2→1→0→H6. Из H6 поднимаем как заголовок. + * + * Заголовок Hn — отдельная ветка: повышение/понижение через shiftCurrentBranch, + * H6 в одиночку при понижении → текст (stage 0). + * + * Поддерживается выделение нескольких строк: операция применяется ко всем строкам + * выделения как одной транзакцией; нумерация при этом делается сквозной для + * соседствующих строк-нумерованных-списков. + */ + shiftLineAtCursor(delta) { + const editor = this.getEditor(); if (!editor) return; + + // Определяем диапазон строк + let startLine, endLine; + const hasSel = editor.somethingSelected && editor.somethingSelected(); + if (hasSel) { + const selFrom = editor.getCursor("from"); + const selTo = editor.getCursor("to"); + startLine = selFrom.line; + endLine = selTo.line; + if (selTo.ch === 0 && endLine > startLine) endLine -= 1; + } else { + const c = editor.getCursor(); + startLine = endLine = c.line; + } + + // Если выделена ровно одна строка — старая ветка-логика для заголовков. + if (startLine === endLine) { + this._shiftSingleLine(editor, startLine, delta); + return; + } + + // Многострочное выделение: применяем построчно. Заголовки в выделении трактуем + // как «строка-заголовок», то есть при понижении — Hn → Hn+1 (без всей ветки), + // при повышении — Hn → Hn-1; H6→текст и текст→H6 — как обычно. + const replacements = []; + let prevWasNumberedDot = false; // 1. + let prevWasNumberedParen = false; // 1) + let prevWasAlpha = false; // а) + let dotCounter = 0, parenCounter = 0, alphaCounter = 0; + + // Начальные значения счётчиков — продолжение от предыдущей строки выше выделения, + // если она того же типа и при операции мы переводим в этот тип. + // Чтобы не усложнять — при groupe-операции просто считаем заново с 1 + // в пределах выделения для каждого нового стейка. + + for (let line = startLine; line <= endLine; line++) { + const raw = editor.getLine(line); + const transformed = this._transformLineForGroup(raw, delta); + if (transformed.text === null) continue; // пропуск (пустая строка и т.п.) + + // Постпроцесс: пересчёт нумерации для нумерованных стейков + if (transformed.stage === 2) { + // 1. + if (!prevWasNumberedDot) dotCounter = 1; else dotCounter++; + prevWasNumberedDot = true; prevWasNumberedParen = false; prevWasAlpha = false; + const t = transformed.text.replace(/^(\s*)1\./, "$1" + dotCounter + "."); + replacements.push({ line, text: t }); + } else if (transformed.stage === 3) { + if (!prevWasNumberedParen) parenCounter = 1; else parenCounter++; + prevWasNumberedDot = false; prevWasNumberedParen = true; prevWasAlpha = false; + const t = transformed.text.replace(/^(\s*)1\)/, "$1" + parenCounter + ")"); + replacements.push({ line, text: t }); + } else if (transformed.stage === 4) { + if (!prevWasAlpha) alphaCounter = 1; else alphaCounter++; + prevWasNumberedDot = false; prevWasNumberedParen = false; prevWasAlpha = true; + const letter = ALPHA_LIST[(alphaCounter - 1) % ALPHA_LIST.length]; + const t = transformed.text.replace(/^(\s*)а\)/, "$1" + letter + ")"); + replacements.push({ line, text: t }); + } else { + prevWasNumberedDot = false; prevWasNumberedParen = false; prevWasAlpha = false; + replacements.push({ line, text: transformed.text }); + } + } + + if (!replacements.length) { + new Notice("Нечего преобразовывать"); + return; + } + this.applyLineReplacements(editor, replacements); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Преобразовано строк: " + replacements.length); + } + + /** + * Возвращает stage 0..4 + tail (текст без префикса) + indent. + * stage: 0=text, 1="- ", 2="1.", 3="1)", 4="а)", -1=heading, -2=empty. + */ + _detectLineStage(raw) { + if (raw == null) return { stage: -2, indent: "", tail: "", level: 0 }; + if (!raw.trim()) return { stage: -2, indent: "", tail: "", level: 0 }; + + const headingMatch = raw.match(/^(#{1,6})(\s+)(.+)$/); + if (headingMatch) return { stage: -1, indent: "", tail: headingMatch[3], level: headingMatch[1].length }; + + // - foo / * foo / + foo + const m1 = raw.match(/^(\s*)([-*+])\s+(.*)$/); + if (m1) return { stage: 1, indent: m1[1], tail: m1[3], level: 0 }; + + // 1. foo + const m2 = raw.match(/^(\s*)\d+\.\s+(.*)$/); + if (m2) return { stage: 2, indent: m2[1], tail: m2[2], level: 0 }; + + // 1) foo + const m3 = raw.match(/^(\s*)\d+\)\s+(.*)$/); + if (m3) return { stage: 3, indent: m3[1], tail: m3[2], level: 0 }; + + // а) foo / а. foo (поддерживаем и латиницу как fallback) + const m4 = raw.match(/^(\s*)([а-яa-z])[\.\)]\s+(.*)$/); + if (m4) return { stage: 4, indent: m4[1], tail: m4[3], level: 0 }; + + // обычный текст + return { stage: 0, indent: raw.match(/^\s*/)[0], tail: raw.replace(/^\s+/, ""), level: 0 }; + } + + /** + * Преобразование одной строки в новую стадию (без пересчёта нумерации). + * Возвращает { text, stage }. text=null — пропускаем. + */ + _transformLineForGroup(raw, delta) { + const info = this._detectLineStage(raw); + + // Пустая + if (info.stage === -2) return { text: null, stage: -2 }; + + // Заголовок + if (info.stage === -1) { + const lv = info.level; + if (delta > 0) { + if (lv < 6) return { text: "#".repeat(lv + 1) + " " + info.tail, stage: -1 }; + // H6 → текст + return { text: info.tail, stage: 0 }; + } else { + if (lv > 1) return { text: "#".repeat(lv - 1) + " " + info.tail, stage: -1 }; + return { text: raw, stage: -1 }; // некуда + } + } + + // Не-заголовок: stage 0..4 + let next = info.stage + (delta > 0 ? 1 : -1); + // Границы + if (delta > 0 && info.stage === 4) return { text: raw, stage: 4 }; // некуда + if (delta < 0 && info.stage === 0) { + // 0 → H6 + return { text: "###### " + info.tail, stage: -1 }; + } + if (next < 0) next = 0; + if (next > 4) next = 4; + + return { text: this._stageToPrefix(next, info.indent) + info.tail, stage: next }; + } + + _stageToPrefix(stage, indent) { + switch (stage) { + case 0: return indent; + case 1: return indent + "- "; + case 2: return indent + "1. "; // счётчик пересчитается в shiftLineAtCursor + case 3: return indent + "1) "; + case 4: return indent + "а) "; + default: return indent; + } + } + + /** Однострочный shift — оригинальная логика (заголовки = вся ветка). */ + _shiftSingleLine(editor, line, delta) { + const raw = editor.getLine(line); + if (raw == null) return; + + const info = this._detectLineStage(raw); + + if (info.stage === -1) { + // Заголовок — повышаем/понижаем всю ветку + const lv = info.level; + if (delta > 0 && lv === 6) { + const heading = this.getCurrentHeadingSilently(editor); + if (heading) { + this.applyLineReplacements(editor, [{ line, text: info.tail }]); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("H6 → обычный текст"); + return; + } + } + this.shiftCurrentBranch(delta); + return; + } + + if (info.stage === -2) { + new Notice("Пустая строка — нечего преобразовывать"); + return; + } + + // Списки/текст: 0..4 цепочка + if (delta > 0 && info.stage === 4) { + new Notice("Дальше понижать некуда (а)"); + return; + } + if (delta < 0 && info.stage === 0) { + // Текст → H6 + this.applyLineReplacements(editor, [{ line, text: "###### " + info.tail }]); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Текст → H6"); + return; + } + + const nextStage = info.stage + (delta > 0 ? 1 : -1); + let prefix; + if (nextStage === 0) prefix = info.indent; + else if (nextStage === 1) prefix = info.indent + "- "; + else if (nextStage === 2) { + const n = this._continueCounterAbove(editor, line, /^(\s*)(\d+)\.\s/, info.indent); + prefix = info.indent + n + ". "; + } else if (nextStage === 3) { + const n = this._continueCounterAbove(editor, line, /^(\s*)(\d+)\)\s/, info.indent); + prefix = info.indent + n + ") "; + } else if (nextStage === 4) { + const n = this._continueCounterAboveAlpha(editor, line, info.indent); + const letter = ALPHA_LIST[(n - 1) % ALPHA_LIST.length]; + prefix = info.indent + letter + ") "; + } + + this.applyLineReplacements(editor, [{ line, text: prefix + info.tail }]); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice(["Текст", "- список", "1. список", "1) список", "а) список"][nextStage]); + } + + /** Найти выше последовательную нумерацию (того же отступа) и вернуть следующее число. */ + _continueCounterAbove(editor, line, regex, indent) { + let n = 0; + for (let i = line - 1; i >= 0; i--) { + const t = editor.getLine(i); + const m = t.match(regex); + if (!m) break; + if (m[1] !== indent) break; + n = parseInt(m[2], 10); + break; // достаточно соседней верхней + } + return n + 1; + } + + _continueCounterAboveAlpha(editor, line, indent) { + for (let i = line - 1; i >= 0; i--) { + const t = editor.getLine(i); + const m = t.match(/^(\s*)([а-я])\)\s/); + if (!m) break; + if (m[1] !== indent) break; + const idx = ALPHA_LIST.indexOf(m[2]); + if (idx < 0) break; + return (idx + 2); + } + return 1; + } + + /** + * Все строковые замены — одной транзакцией → один Ctrl+Z. + */ + applyLineReplacements(editor, replacements) { + if (!replacements || !replacements.length) return; + replacements = replacements.slice().sort((a, b) => a.line - b.line); + + const changes = replacements.map((r) => { + const old = editor.getLine(r.line); + return { + from: { line: r.line, ch: 0 }, + to: { line: r.line, ch: old.length }, + text: r.text + }; + }); + + if (typeof editor.transaction === "function") { + editor.transaction({ changes }); + } else { + // Старые сборки Obsidian — по очереди (несколько undo, но иначе никак). + for (let i = changes.length - 1; i >= 0; i--) { + editor.replaceRange(changes[i].text, changes[i].from, changes[i].to); + } + } + } + + // ─────────────────────── копирование / экспорт ─────────────────────── + + async copyCurrentBranch() { + const editor = this.getEditor(); if (!editor) return; + const root = this.getCurrentHeading(editor); if (!root) return; + const branch = this.getBranch(editor, root); + + const lines = []; + for (let l = branch.startLine; l <= branch.endLine; l++) lines.push(editor.getLine(l)); + const text = lines.join("\n"); + try { await navigator.clipboard.writeText(text); new Notice("Ветка скопирована"); } + catch (e) { console.error(e); new Notice("Не удалось скопировать ветку"); } + } + + openExportBranchModal() { + const editor = this.getEditor(); if (!editor) return; + const file = this.getCurrentMarkdownFile(); + if (!file) { new Notice("Нет активной Markdown-заметки"); return; } + const root = this.getCurrentHeading(editor); if (!root) return; + new ExportBranchModal(this.app, this, editor, file, root).open(); + } + + async exportBranch(editor, file, root, options) { + const branch = this.getBranch(editor, root); + const rawLines = []; + for (let l = branch.startLine; l <= branch.endLine; l++) rawLines.push(editor.getLine(l)); + + let lines = rawLines.slice(); + + if (options.promoteToH1) { + // Сдвигаем все заголовки так, чтобы верхний заголовок стал H1 + const delta = 1 - root.level; + lines = lines.map((t) => { + const m = t.match(/^(#{1,6})(\s+.+)$/); + if (!m) return t; + const newLv = clamp(m[1].length + delta, 1, 6); + return "#".repeat(newLv) + m[2]; + }); + } + + const branchText = lines.join("\n").replace(/\s+$/g, "") + "\n"; + const baseName = sanitizeFileName(stripHeadingMarks(root.text)) || "Экспорт ветки"; + let targetFolder = ""; + + if (options.mode === "same-folder") { + targetFolder = file.parent ? file.parent.path : ""; + } else if (options.mode === "folder") { + targetFolder = normalizePath(options.folder || this.settings.exportFolder || "_Exports"); + await this.ensureFolder(targetFolder); + } else { + targetFolder = file.parent ? file.parent.path : ""; + } + + const targetPath = await this.getUniqueMarkdownPath(targetFolder, baseName); + await this.app.vault.create(targetPath, branchText); + + this.settings.exportMode = options.mode; + this.settings.exportFolder = options.folder || this.settings.exportFolder; + this.settings.exportOpenAfter = !!options.openAfter; + this.settings.exportPromoteToH1 = !!options.promoteToH1; + this.settings.exportAskEachTime = !!options.askEachTime; + await this.saveSettings(); + + if (options.openAfter) { + const created = this.app.vault.getAbstractFileByPath(targetPath); + if (created instanceof TFile) { + await this.app.workspace.getLeaf(true).openFile(created); + } + } + + new Notice("Ветка экспортирована: " + targetPath); + } + + async ensureFolder(folderPath) { + const clean = normalizePath(folderPath || ""); + if (!clean) return; + const parts = clean.split("/").filter(Boolean); + let cur = ""; + for (const p of parts) { + cur = cur ? cur + "/" + p : p; + const ex = this.app.vault.getAbstractFileByPath(cur); + if (!ex) await this.app.vault.createFolder(cur); + } + } + + async getUniqueMarkdownPath(folder, baseName) { + const cf = normalizePath(folder || ""); + const cb = sanitizeFileName(baseName || "Экспорт ветки") || "Экспорт ветки"; + let path = cf ? cf + "/" + cb + ".md" : cb + ".md"; + let i = 2; + while (this.app.vault.getAbstractFileByPath(path)) { + path = cf ? cf + "/" + cb + " " + i + ".md" : cb + " " + i + ".md"; + i++; + } + return path; + } + + // ─────────────────────── перемещение ветки ─────────────────────── + + moveCurrentBranch(direction) { + const editor = this.getEditor(); if (!editor) return; + + // 1) Есть выделение → двигаем диапазон выделенных строк целиком (как блок). + // Цель — найти соседнюю «ветку того же уровня» относительно граничного заголовка + // выделения, иначе сдвиг на одну строку выше/ниже соседа. + if (editor.somethingSelected && editor.somethingSelected()) { + const selFrom = editor.getCursor("from"); + const selTo = editor.getCursor("to"); + let startLine = selFrom.line; + let endLine = selTo.line; + // Если выделение заканчивается в начале строки (ch === 0) и захватывает >0 строк — + // "хвостовая" строка фактически не выделена; стандарт текстовых редакторов. + if (selTo.ch === 0 && endLine > startLine) endLine -= 1; + + this.moveLineRange(editor, startLine, endLine, direction); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + return; + } + + // 2) Нет выделения → старая логика: перенос всей ветки относительно соседа того же уровня. + const root = this.getCurrentHeading(editor); if (!root) return; + + const headings = this.getHeadings(editor); + const cur = this.getBranch(editor, root); + + let sibling = null; + if (direction < 0) { + for (let i = headings.length - 1; i >= 0; i--) { + const h = headings[i]; + if (h.line >= root.line) continue; + if (h.level < root.level) break; + if (h.level === root.level) { sibling = h; break; } + } + if (!sibling) { new Notice("Нет предыдущей ветки того же уровня"); return; } + const sb = this.getBranch(editor, sibling); + this.moveBlock(editor, cur.startLine, cur.endLine, sb.startLine); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Ветка перемещена выше"); + return; + } + + for (let j = 0; j < headings.length; j++) { + const h = headings[j]; + if (h.line <= root.line) continue; + if (h.line <= cur.endLine) continue; + if (h.level < root.level) break; + if (h.level === root.level) { sibling = h; break; } + } + if (!sibling) { new Notice("Нет следующей ветки того же уровня"); return; } + const nb = this.getBranch(editor, sibling); + this.moveBlock(editor, cur.startLine, cur.endLine, nb.endLine + 1); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Ветка перемещена ниже"); + } + + /** + * Перемещает диапазон строк [startLine..endLine] на одну строку + * выше/ниже (direction = -1 / +1). Если в диапазоне есть заголовок и + * следующая строка тоже заголовок того же уровня — диапазон перепрыгивает + * целую соседнюю ветку (соседи переставляются как блоки). + */ + moveLineRange(editor, startLine, endLine, direction) { + const total = editor.lineCount(); + if (startLine < 0 || endLine >= total || startLine > endLine) return; + + if (direction < 0) { + if (startLine === 0) { new Notice("Уже наверху"); return; } + // Если строка startLine — заголовок и строка startLine-1 (или сосед) тоже заголовок — + // перепрыгиваем соседнюю ветку. Иначе двигаем на одну строку. + const targetLine = this.findMoveTargetUp(editor, startLine); + this.moveBlock(editor, startLine, endLine, targetLine); + new Notice("Перемещено выше"); + } else { + if (endLine >= total - 1) { new Notice("Уже внизу"); return; } + const targetLine = this.findMoveTargetDown(editor, endLine); + this.moveBlock(editor, startLine, endLine, targetLine); + new Notice("Перемещено ниже"); + } + } + + findMoveTargetUp(editor, startLine) { + const text = editor.getLine(startLine); + const m = text.match(/^(#{1,6})(\s+.+)$/); + if (m) { + // Это заголовок — ищем заголовок того же уровня выше и его branch.startLine. + const headings = this.getHeadings(editor); + const myLv = m[1].length; + let prev = null; + for (let i = headings.length - 1; i >= 0; i--) { + const h = headings[i]; + if (h.line >= startLine) continue; + if (h.level < myLv) break; + if (h.level === myLv) { prev = h; break; } + } + if (prev) { + const pb = this.getBranch(editor, prev); + return pb.startLine; + } + } + // обычный текст — на одну строку выше + return startLine - 1; + } + + findMoveTargetDown(editor, endLine) { + // Если последняя строка диапазона — заголовок, то целевая = конец следующей ветки + 1. + // Иначе — endLine + 2 (сдвиг на одну строку вниз). + const text = editor.getLine(endLine); + const m = text.match(/^(#{1,6})(\s+.+)$/); + if (m) { + const headings = this.getHeadings(editor); + const myLv = m[1].length; + let next = null; + for (let j = 0; j < headings.length; j++) { + const h = headings[j]; + if (h.line <= endLine) continue; + if (h.level < myLv) break; + if (h.level === myLv) { next = h; break; } + } + if (next) { + const nb = this.getBranch(editor, next); + return nb.endLine + 1; + } + } + return endLine + 2; + } + + /** + * Атомарное перемещение [startLine..endLine] так, чтобы НОВАЯ позиция + * первой строки блока оказалась на месте targetLine исходного документа. + * + * Реализовано через полную замену объединённого диапазона + * [min(srcStart,dstStart)..max(srcEnd,dstEnd)] одной транзакцией. + * Это гарантирует: + * - один Ctrl+Z откатывает всё; + * - не появляются лишние пустые строки; + * - корректна работа на границах файла (без финального \n тоже). + */ + moveBlock(editor, startLine, endLine, targetLine) { + const total = editor.lineCount(); + if (startLine < 0 || endLine >= total || startLine > endLine) return; + if (targetLine < 0) targetLine = 0; + if (targetLine > total) targetLine = total; + if (targetLine >= startLine && targetLine <= endLine + 1) return; // нет смещения + + // Соберём «строки» как массив, без склейки концов файла + const allLines = []; + for (let i = 0; i < total; i++) allLines.push(editor.getLine(i)); + + const block = allLines.slice(startLine, endLine + 1); + const before = allLines.slice(0, startLine); + const after = allLines.slice(endLine + 1); + + // Склеим до удалённого блока + const removed = before.concat(after); + // targetLine задавался относительно исходного документа. + // Если target идёт ПОСЛЕ удалённого блока, его индекс в removed уменьшится + // на размер блока. + let insertAt = targetLine; + if (targetLine > endLine) insertAt = targetLine - (endLine - startLine + 1); + insertAt = clamp(insertAt, 0, removed.length); + + const finalLines = removed.slice(0, insertAt).concat(block).concat(removed.slice(insertAt)); + + // Заменим всё содержимое одним диапазоном — одна транзакция, один Ctrl+Z. + const lastLine = total - 1; + const lastCh = editor.getLine(lastLine).length; + + const newText = finalLines.join("\n"); + editor.replaceRange( + newText, + { line: 0, ch: 0 }, + { line: lastLine, ch: lastCh } + ); + + // Поставим курсор на начало перемещённого блока + const cursorLine = clamp(insertAt, 0, finalLines.length - 1); + editor.setCursor({ line: cursorLine, ch: 0 }); + try { + editor.scrollIntoView({ from: { line: cursorLine, ch: 0 }, to: { line: cursorLine, ch: 0 } }, true); + } catch (e) { /* noop */ } + } + + // ─────────────────────── нормализация уровней ─────────────────────── + + normalizeHeadingLevels() { + const editor = this.getEditor(); if (!editor) return; + const headings = this.getHeadings(editor); + if (!headings.length) { new Notice("В документе нет заголовков"); return; } + + let prev = 0; + const replacements = []; + for (let i = 0; i < headings.length; i++) { + const h = headings[i]; + let nx = h.level; + if (i === 0 && nx > 1) nx = 1; + else if (prev > 0 && nx > prev + 1) nx = prev + 1; + prev = nx; + if (nx !== h.level) { + const t = editor.getLine(h.line); + const m = t.match(/^(#{1,6})(\s+.+)$/); + if (m) replacements.push({ line: h.line, text: "#".repeat(nx) + m[2] }); + } + } + + if (!replacements.length) { new Notice("Уровни уже нормальные"); return; } + this.applyLineReplacements(editor, replacements); + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Исправлено заголовков: " + replacements.length); + } + + // ─────────────────────── перенумерация ─────────────────────── + + openRenumberModal(mode) { + const editor = this.getEditor(); if (!editor) return; + new RenumberModal(this.app, this, editor, mode).open(); + } + + /** + * Возвращает список замен (без применения). Используется и для применения, + * и для предпросмотра. + */ + collectRenumberReplacements(editor, options) { + const scope = options.scope || this.settings.renumberScope || "document"; + const targets = options.targets || this.settings.renumberTargets || "headings"; + const style = options.style || this.settings.renumberStyle || "numeric"; + const action = options.action || "renumber"; + + let startLine = 0, endLine = editor.lineCount() - 1; + + if (scope === "branch") { + const root = this.getCurrentHeading(editor); + if (!root) return null; + const b = this.getBranch(editor, root); + startLine = b.startLine; endLine = b.endLine; + } else if (scope === "selection") { + const sel = editor.listSelections && editor.listSelections()[0]; + if (sel) { + const a = sel.anchor.line, h = sel.head.line; + startLine = Math.min(a, h); endLine = Math.max(a, h); + } + } + + const replacements = []; + if (targets === "headings" || targets === "both") { + if (action === "remove") this.collectRemoveHeadingNumbering(editor, startLine, endLine, replacements); + else this.collectRenumberHeadings(editor, startLine, endLine, style, replacements); + } + if (targets === "bullets" || targets === "both") { + if (action === "remove") this.collectRemoveListNumbering(editor, startLine, endLine, replacements); + else this.collectRenumberLists(editor, startLine, endLine, style, replacements); + } + return replacements; + } + + async applyRenumber(editor, options) { + // Сохраняем позицию курсора и скролла + const cursor = editor.getCursor(); + let scroll = null; + try { scroll = editor.getScrollInfo && editor.getScrollInfo(); } catch (e) {} + + const replacements = this.collectRenumberReplacements(editor, options); + if (!replacements) return; + if (!replacements.length) { new Notice("Нечего менять"); return; } + + this.applyLineReplacements(editor, replacements); + + this.settings.renumberScope = options.scope || this.settings.renumberScope; + this.settings.renumberTargets = options.targets || this.settings.renumberTargets; + this.settings.renumberStyle = options.style || this.settings.renumberStyle; + await this.saveSettings(); + + try { editor.setCursor(cursor); } catch (e) {} + if (scroll && typeof editor.scrollTo === "function") { + try { editor.scrollTo(scroll.left, scroll.top); } catch (e) {} + } + + this.refreshStructurePanels(); this.refreshActiveToolbar(); + new Notice("Изменено строк: " + replacements.length); + } + + collectRenumberHeadings(editor, startLine, endLine, style, replacements) { + const counters = [0, 0, 0, 0, 0, 0, 0]; + let inFence = false; let fenceMarker = ""; + + for (let line = startLine; line <= endLine; line++) { + const text = editor.getLine(line); + const fence = text.match(/^\s*(```+|~~~+)/); + if (fence) { + const mk = fence[1].charAt(0); + if (!inFence) { inFence = true; fenceMarker = mk; } + else if (mk === fenceMarker) { inFence = false; fenceMarker = ""; } + continue; + } + if (inFence) continue; + + const m = text.match(/^(#{1,6})(\s+)(.+)$/); + if (!m) continue; + const level = m[1].length; + counters[level] += 1; + for (let i = level + 1; i <= 6; i++) counters[i] = 0; + + const path = []; + for (let j = 1; j <= level; j++) if (counters[j] > 0) path.push(counters[j]); + + const cleanTitle = stripNumberPrefix(m[3]); + const prefix = (style === "emoji") ? emojiPath(path) : path.join(".") + "."; + replacements.push({ + line, + text: m[1] + m[2] + prefix + " " + cleanTitle + }); + } + } + + collectRemoveHeadingNumbering(editor, startLine, endLine, replacements) { + let inFence = false, fenceMarker = ""; + for (let line = startLine; line <= endLine; line++) { + const text = editor.getLine(line); + const fence = text.match(/^\s*(```+|~~~+)/); + if (fence) { + const mk = fence[1].charAt(0); + if (!inFence) { inFence = true; fenceMarker = mk; } + else if (mk === fenceMarker) { inFence = false; fenceMarker = ""; } + continue; + } + if (inFence) continue; + + const m = text.match(/^(#{1,6})(\s+)(.+)$/); + if (!m) continue; + const cleanTitle = stripNumberPrefix(m[3]); + if (cleanTitle !== m[3]) replacements.push({ line, text: m[1] + m[2] + cleanTitle }); + } + } + + collectRenumberLists(editor, startLine, endLine, style, replacements) { + const counters = {}; + for (let line = startLine; line <= endLine; line++) { + const text = editor.getLine(line); + if (!text.trim() || /^#{1,6}\s+/.test(text)) { + for (const k in counters) delete counters[k]; + continue; + } + const m = text.match(/^(\s*)(?:[-*+]|\d+[.)]|(?:\d️⃣)+|[①②③④⑤⑥⑦⑧⑨⑩])\s+(.+)$/); + if (!m) continue; + const indent = m[1].replace(/\t/g, " ").length; + counters[indent] = (counters[indent] || 0) + 1; + Object.keys(counters).forEach((k) => { if (Number(k) > indent) delete counters[k]; }); + + const number = counters[indent]; + const prefix = (style === "emoji") ? emojiNumber(number) + "." : number + "."; + replacements.push({ line, text: m[1] + prefix + " " + stripNumberPrefix(m[2]) }); + } + } + + collectRemoveListNumbering(editor, startLine, endLine, replacements) { + for (let line = startLine; line <= endLine; line++) { + const text = editor.getLine(line); + const m = text.match(/^(\s*)(?:\d+[.)]|(?:\d️⃣)+\.?|[①②③④⑤⑥⑦⑧⑨⑩])\s+(.+)$/); + if (!m) continue; + replacements.push({ line, text: m[1] + "- " + stripNumberPrefix(m[2]) }); + } + } + + // ─────────────────────── боковая панель ─────────────────────── + + async openStructurePanel(side) { + let leaf = null; + const selected = side || this.settings.lastPanelSide || "right"; + try { + if (selected === "left" && typeof this.app.workspace.getLeftLeaf === "function") { + leaf = this.app.workspace.getLeftLeaf(false); + } else if (selected === "right" && typeof this.app.workspace.getRightLeaf === "function") { + leaf = this.app.workspace.getRightLeaf(false); + } else if (selected === "bottom") { + leaf = this.app.workspace.getLeaf("split", "horizontal"); + } + } catch (e) { leaf = null; } + + if (!leaf) leaf = this.app.workspace.getLeaf(true); + + await leaf.setViewState({ type: STRUCTURE_PANEL_VIEW, active: true }); + this.settings.lastPanelSide = selected; + await this.saveSettings(); + this.app.workspace.revealLeaf(leaf); + this.refreshStructurePanels(); + } + + closeStructurePanel() { + const leaves = this.app.workspace.getLeavesOfType(STRUCTURE_PANEL_VIEW); + if (!leaves.length) { new Notice("Панель структуры не открыта"); return; } + leaves.forEach((leaf) => leaf.detach()); + new Notice("Панель структуры скрыта"); + } + + async toggleStructurePanel() { + const leaves = this.app.workspace.getLeavesOfType(STRUCTURE_PANEL_VIEW); + if (leaves.length) { + this.closeStructurePanel(); + } else { + await this.openStructurePanel(this.settings.lastPanelSide || "right"); + } + } + + async toggleEditorToolbar() { + this.settings.showEditorToolbar = !this.settings.showEditorToolbar; + await this.saveSettings(); + this.syncAllToolbars(); + new Notice(this.settings.showEditorToolbar ? "Панель инструментов показана" : "Панель инструментов скрыта"); + } + + refreshStructurePanels() { + try { + const leaves = this.app.workspace.getLeavesOfType(STRUCTURE_PANEL_VIEW); + leaves.forEach((leaf) => { + if (leaf.view && typeof leaf.view.scheduleRender === "function") leaf.view.scheduleRender(); + }); + } catch (e) {} + } + + async jumpToHeading(file, line) { + if (!(file instanceof TFile)) return; + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(file); + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (view && view.editor) { + view.editor.setCursor({ line, ch: 0 }); + view.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true); + } + } + + openHotkeysSettings() { + try { + if (this.app.setting && typeof this.app.setting.open === "function") this.app.setting.open(); + if (this.app.setting && typeof this.app.setting.openTabById === "function") this.app.setting.openTabById("hotkeys"); + new Notice("Настройки → Hotkeys. Ищите: Structure"); + } catch (e) { + console.error(e); + new Notice("Откройте: Настройки → Hotkeys, и найдите команду Structure"); + } + } + + // ─────────────────────── toolbar над редактором ─────────────────────── + + syncAllToolbars() { + if (!this.settings.showEditorToolbar) { + // снять все + const leaves = this.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + const v = leaf.view; + if (v instanceof MarkdownView) { + const t = this.toolbars.get(v); + if (t) { t.destroy(); this.toolbars.delete(v); } + } + }); + return; + } + const leaves = this.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + const v = leaf.view; + if (!(v instanceof MarkdownView)) return; + let tb = this.toolbars.get(v); + if (!tb) { tb = new EditorToolbar(this, v); this.toolbars.set(v, tb); } + tb.attach(); tb.refresh(); + }); + } + + refreshActiveToolbar() { + const v = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!v) return; + const tb = this.toolbars.get(v); + if (tb) tb.refresh(); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// HotkeyCaptureModal — модальное окно для быстрого назначения хоткея +// (запись комбинации клавиш и сохранение через app.hotkeyManager). +// ═══════════════════════════════════════════════════════════════════════════════ +class HotkeyCaptureModal extends Modal { + constructor(app, plugin, commandId, commandName) { + super(app); + this.plugin = plugin; + this.commandId = commandId; + this.commandName = commandName; + this.captured = null; // { modifiers: [...], key: "..." } + this._keyHandler = null; + } + + onOpen() { + const el = this.contentEl; + el.empty(); + el.addClass("structure-commander-small-modal"); + + el.createEl("h2", { text: "Назначить хоткей" }); + el.createDiv({ cls: "structure-commander-muted", text: this.commandName }); + + // Текущий хоткей + const current = this.plugin.getHotkeyLabel(this.commandId); + const currentDiv = el.createDiv({ cls: "scmd-hk-current" }); + currentDiv.setText("Текущий: " + (current || "не назначен")); + + // Зона захвата + const cap = el.createDiv({ cls: "scmd-hk-capture" }); + cap.setText("Нажмите комбинацию клавиш…"); + cap.setAttribute("tabindex", "0"); + setTimeout(() => cap.focus(), 50); + + this._keyHandler = (evt) => { + // Игнорируем чистые модификаторы — ждём «настоящую» клавишу + const onlyModifier = ["Control", "Shift", "Alt", "Meta", "AltGraph"].includes(evt.key); + if (onlyModifier) return; + + evt.preventDefault(); + evt.stopPropagation(); + + const modifiers = []; + if (evt.ctrlKey) modifiers.push("Ctrl"); + if (evt.metaKey) modifiers.push("Meta"); + if (evt.altKey) modifiers.push("Alt"); + if (evt.shiftKey) modifiers.push("Shift"); + + const key = HotkeyCaptureModal.normalizeKey(evt.key, evt.code); + if (!key) return; + + this.captured = { modifiers, key }; + cap.setText("Поймано: " + this.formatHk(this.captured)); + cap.classList.add("scmd-hk-captured"); + }; + + cap.addEventListener("keydown", this._keyHandler); + + // Кнопки + const btns = el.createDiv({ cls: "structure-modal-buttons" }); + const ok = btns.createEl("button", { text: "Сохранить" }); + ok.addClass("mod-cta"); + ok.onclick = () => this.save(); + const clear = btns.createEl("button", { text: "Очистить" }); + clear.onclick = () => this.clearHotkey(); + const cancel = btns.createEl("button", { text: "Отмена" }); + cancel.onclick = () => this.close(); + const fallback = btns.createEl("button", { text: "Открыть настройки Hotkeys" }); + fallback.onclick = () => { this.close(); this.plugin.openHotkeysSettings(); }; + } + + formatHk(hk) { + if (!hk) return ""; + const parts = (hk.modifiers || []).slice(); + parts.push(hk.key); + return parts.join(" + "); + } + + static normalizeKey(key, code) { + // Numpad — особый случай. Browser возвращает key="1", code="Numpad1". + if (code && code.startsWith("Numpad")) return code; // "Numpad1" + if (key === " " || key === "Spacebar") return " "; + if (key.length === 1) return key.toUpperCase(); + return key; // "ArrowUp", "Enter", "Tab", "F5" и т.д. + } + + async save() { + if (!this.captured) { + new Notice("Сначала нажмите комбинацию клавиш"); + return; + } + const ok = this._setHotkey(this.commandId, [this.captured]); + if (ok) { + new Notice("Хоткей сохранён: " + this.formatHk(this.captured)); + this.plugin.refreshActiveToolbar(); + this.close(); + } else { + new Notice("Не удалось сохранить — открываю настройки Hotkeys"); + this.plugin.openHotkeysSettings(); + this.close(); + } + } + + async clearHotkey() { + const ok = this._setHotkey(this.commandId, []); + if (ok) { + new Notice("Хоткей удалён"); + this.plugin.refreshActiveToolbar(); + this.close(); + } else { + this.plugin.openHotkeysSettings(); + this.close(); + } + } + + /** Использует внутреннее API Obsidian для записи хоткея. */ + _setHotkey(commandId, hotkeys) { + try { + const hm = this.app.hotkeyManager; + if (!hm) return false; + // Полный id команды: ":" + const fullId = commandId.includes(":") ? commandId : ("structure-commander:" + commandId); + if (typeof hm.setHotkeys === "function") { + hm.setHotkeys(fullId, hotkeys); + } else if (typeof hm.removeHotkeys === "function" && typeof hm.addHotkey === "function") { + hm.removeHotkeys(fullId); + hotkeys.forEach((hk) => hm.addHotkey(fullId, hk)); + } else { + return false; + } + // Сохранение конфига + if (typeof hm.save === "function") hm.save(); + return true; + } catch (e) { + console.error("HotkeyCaptureModal._setHotkey error:", e); + return false; + } + } + + onClose() { + this.contentEl.empty(); + } +} + +class EditorToolbar { + constructor(plugin, view) { + this.plugin = plugin; + this.view = view; // MarkdownView + this.host = null; // div toolbar + this.attached = false; + + this.scheduleRefresh = debounce(() => this.refresh(), 120, true); + + // Обновлять toolbar при движении курсора + this._cursorHandler = () => this.scheduleRefresh(); + } + + /** Привязать toolbar к контейнеру view, если ещё не привязан. */ + attach() { + if (this.attached) { + // Может потребоваться перенести toolbar, если DOM пересоздан. + if (this.host && this.host.isConnected) return; + } + const container = this.findHostContainer(); + if (!container) return; + + if (!this.host) this.buildHost(); + + // Не дублируем + const existing = container.querySelector(":scope > .scmd-toolbar"); + if (existing && existing !== this.host) existing.remove(); + + if (this.host.parentElement !== container) { + container.prepend(this.host); + } + this.attached = true; + + // подписка на курсор + try { + const ed = this.view.editor; + if (ed && ed.cm && typeof ed.cm.dom !== "undefined") { + ed.cm.dom.addEventListener("keyup", this._cursorHandler); + ed.cm.dom.addEventListener("mouseup", this._cursorHandler); + } + } catch (e) { /* noop */ } + } + + findHostContainer() { + // Размещаем toolbar внутри .view-content, перед .markdown-source-view / preview. + // Это узкая горизонтальная плашка, она не перекрывает текст — она его «толкает» вниз. + const root = this.view.containerEl; + if (!root) return null; + return root.querySelector(".view-content") || root; + } + + buildHost() { + const host = document.createElement("div"); + host.className = "scmd-toolbar"; + host.setAttribute("role", "toolbar"); + host.setAttribute("aria-label", "Structure Commander"); + + const btns = [ + { id: "move-current-branch-up", icon: "↑", label: "Ветка выше" }, + { id: "move-current-branch-down", icon: "↓", label: "Ветка ниже" }, + { id: "promote-current-branch", icon: "←", label: "Повысить ветку" }, + { id: "demote-current-branch", icon: "→", label: "Понизить ветку" }, + { id: "collapse-current-branch", icon: "−", label: "Свернуть ветку" }, + { id: "expand-current-branch", icon: "+", label: "Развернуть ветку" } + ]; + + btns.forEach((b) => { + const el = document.createElement("button"); + el.type = "button"; + el.className = "scmd-tb-btn"; + el.dataset.cmd = b.id; + el.textContent = b.icon; + this.applyTooltip(el, b.label, b.id); + el.addEventListener("click", (e) => { e.preventDefault(); this.runCommand(b.id); }); + el.addEventListener("contextmenu", (e) => { e.preventDefault(); this.openButtonContextMenu(b.id, b.label, el); }); + host.appendChild(el); + }); + + // — выпадающее «Показать: Hn ▾» + const showWrap = document.createElement("div"); + showWrap.className = "scmd-tb-dd"; + const showBtn = document.createElement("button"); + showBtn.type = "button"; + showBtn.className = "scmd-tb-btn scmd-tb-btn-text scmd-tb-show"; + showBtn.dataset.kind = "show"; + showBtn.textContent = "Показать: H" + (this.plugin.settings.lastShowToLevel || 1) + " ▾"; + showBtn.title = "Свернуть ниже выбранного уровня"; + showBtn.addEventListener("click", (e) => { e.preventDefault(); this.openShowToMenu(showBtn); }); + showWrap.appendChild(showBtn); + host.appendChild(showWrap); + + // — назначить уровень текущему заголовку + const lvlWrap = document.createElement("div"); + lvlWrap.className = "scmd-tb-dd"; + const lvlBtn = document.createElement("button"); + lvlBtn.type = "button"; + lvlBtn.className = "scmd-tb-btn scmd-tb-btn-text scmd-tb-level"; + lvlBtn.dataset.kind = "level"; + lvlBtn.textContent = "H? ▾"; + lvlBtn.title = "Уровень текущего заголовка"; + lvlBtn.addEventListener("click", (e) => { e.preventDefault(); this.openLevelMenu(lvlBtn); }); + lvlWrap.appendChild(lvlBtn); + host.appendChild(lvlWrap); + + // — № + const numBtn = document.createElement("button"); + numBtn.type = "button"; + numBtn.className = "scmd-tb-btn"; + numBtn.textContent = "№"; + numBtn.title = "Перенумеровать структуру"; + numBtn.addEventListener("click", (e) => { e.preventDefault(); this.plugin.openRenumberModal("renumber"); }); + numBtn.addEventListener("contextmenu", (e) => { e.preventDefault(); this.openNumberingMenu(numBtn); }); + host.appendChild(numBtn); + + // — спейсер «протолкнёт» × вправо + const spacer = document.createElement("div"); + spacer.className = "scmd-tb-spacer"; + host.appendChild(spacer); + + // — × скрыть тулбар + const hideBtn = document.createElement("button"); + hideBtn.type = "button"; + hideBtn.className = "scmd-tb-btn scmd-tb-hide"; + hideBtn.textContent = "×"; + hideBtn.title = "Скрыть панель инструментов (включается в настройках или командой)"; + hideBtn.addEventListener("click", (e) => { e.preventDefault(); this.plugin.toggleEditorToolbar(); }); + host.appendChild(hideBtn); + + this.host = host; + } + + applyTooltip(el, label, commandId) { + const hk = this.plugin.getHotkeyLabel(commandId); + el.title = hk ? label + " (" + hk + ")" : label; + el.setAttribute("aria-label", el.title); + } + + runCommand(commandId) { + switch (commandId) { + case "move-current-branch-up": return this.plugin.moveCurrentBranch(-1); + case "move-current-branch-down": return this.plugin.moveCurrentBranch(1); + case "promote-current-branch": return this.plugin.shiftCurrentBranch(-1); + case "demote-current-branch": return this.plugin.shiftCurrentBranch(1); + case "collapse-current-branch": return this.plugin.collapseCurrentBranch(); + case "expand-current-branch": return this.plugin.expandCurrentBranch(); + } + } + + openButtonContextMenu(commandId, label, anchor) { + const m = new Menu(); + const currentHk = this.plugin.getHotkeyLabel(commandId); + m.addItem((it) => it + .setTitle("Назначить хоткей" + (currentHk ? " (сейчас: " + currentHk + ")" : "")) + .setIcon("keyboard") + .onClick(() => new HotkeyCaptureModal(this.plugin.app, this.plugin, commandId, label).open())); + m.addItem((it) => it + .setTitle("Открыть настройки Hotkeys") + .onClick(() => this.plugin.openHotkeysSettings())); + const r = anchor.getBoundingClientRect(); + m.showAtPosition({ x: r.left, y: r.bottom }); + } + + openShowToMenu(anchor) { + const editor = this.plugin.getEditor(true); + if (!editor) { new Notice("Откройте Markdown-заметку"); return; } + const headings = this.plugin.getHeadings(editor); + const levels = uniqueSortedLevels(headings); + + const m = new Menu(); + if (!levels.length) { + m.addItem((it) => it.setTitle("В документе нет заголовков").setDisabled(true)); + } else { + levels.forEach((lv) => { + const cmdId = "show-document-to-h" + lv; + const hk = this.plugin.getHotkeyLabel(cmdId); + const title = "H" + lv + (hk ? " " + hk : ""); + m.addItem((it) => it.setTitle(title).onClick(() => this.plugin.showDocumentToLevel(lv))); + }); + m.addSeparator(); + m.addItem((it) => it.setTitle("Показать всё").setIcon("expand").onClick(() => this.plugin.unfoldAll())); + } + const r = anchor.getBoundingClientRect(); + m.showAtPosition({ x: r.left, y: r.bottom }); + } + + openLevelMenu(anchor) { + const editor = this.plugin.getEditor(true); + if (!editor) { new Notice("Откройте Markdown-заметку"); return; } + const cur = this.plugin.getCurrentHeadingSilently(editor); + + const m = new Menu(); + if (!cur) { + m.addItem((it) => it.setTitle("Курсор не в ветке заголовков").setDisabled(true)); + } else { + m.addItem((it) => it + .setTitle("Текущий: H" + cur.level + " — " + truncate(cur.title, 40)) + .setDisabled(true)); + m.addSeparator(); + for (let lv = 1; lv <= 6; lv++) { + const t = lv; + const cmdId = "set-current-heading-h" + lv; + const hk = this.plugin.getHotkeyLabel(cmdId); + const title = (lv === cur.level ? "✓ " : " ") + "H" + lv + " (только этот заголовок)" + (hk ? " " + hk : ""); + m.addItem((it) => it.setTitle(title).onClick(() => this.plugin.setCurrentHeadingLevel(t))); + } + m.addSeparator(); + for (let lv = 1; lv <= 6; lv++) { + if (lv === cur.level) continue; + const t = lv; + const cmdId = "set-current-branch-root-h" + lv; + const hk = this.plugin.getHotkeyLabel(cmdId); + const title = "Заголовок → H" + lv + " (вся ветка)" + (hk ? " " + hk : ""); + m.addItem((it) => it.setTitle(title).onClick(() => this.plugin.setCurrentBranchRootLevel(t))); + } + } + const r = anchor.getBoundingClientRect(); + m.showAtPosition({ x: r.left, y: r.bottom }); + } + + openNumberingMenu(anchor) { + const m = new Menu(); + const mk = (cmdId, title, fn) => { + const hk = this.plugin.getHotkeyLabel(cmdId); + const t = title + (hk ? " " + hk : ""); + m.addItem((it) => it.setTitle(t).onClick(fn)); + }; + mk("renumber-structure", "Перенумеровать…", () => this.plugin.openRenumberModal("renumber")); + mk("remove-structure-numbering", "Удалить нумерацию…", () => this.plugin.openRenumberModal("remove")); + mk("emoji-number-structure", "Emoji-цифры…", () => this.plugin.openRenumberModal("emoji")); + m.addSeparator(); + mk("normalize-heading-levels", "Исправить пропуски уровней", () => this.plugin.normalizeHeadingLevels()); + const r = anchor.getBoundingClientRect(); + m.showAtPosition({ x: r.left, y: r.bottom }); + } + + refresh() { + if (!this.host || !this.host.isConnected) return; + const editor = this.view.editor; + if (!editor) return; + + const headings = this.plugin.getHeadings(editor); + const cur = this.plugin.getCurrentHeadingSilently(editor); + const lvlBtn = this.host.querySelector('.scmd-tb-level'); + + if (lvlBtn) { + lvlBtn.textContent = (cur ? "H" + cur.level : "H?") + " ▾"; + lvlBtn.classList.toggle("scmd-tb-disabled", !cur); + } + + const showBtn = this.host.querySelector('.scmd-tb-show'); + if (showBtn) { + const lv = this.plugin.settings.lastShowToLevel || 1; + showBtn.textContent = "Показать: H" + lv + " ▾"; + } + + // Если в файле нет заголовков — гасим базовые операции + const noHeadings = headings.length === 0; + this.host.classList.toggle("scmd-tb-empty", noHeadings); + + // Обновляем tooltip на случай смены хоткея + this.host.querySelectorAll('.scmd-tb-btn[data-cmd]').forEach((el) => { + const id = el.dataset.cmd; + const labels = { + "move-current-branch-up": "Ветка выше", + "move-current-branch-down": "Ветка ниже", + "promote-current-branch": "Повысить ветку", + "demote-current-branch": "Понизить ветку", + "collapse-current-branch": "Свернуть ветку", + "expand-current-branch": "Развернуть ветку" + }; + this.applyTooltip(el, labels[id] || id, id); + }); + } + + destroy() { + try { + const ed = this.view && this.view.editor; + if (ed && ed.cm && ed.cm.dom) { + ed.cm.dom.removeEventListener("keyup", this._cursorHandler); + ed.cm.dom.removeEventListener("mouseup", this._cursorHandler); + } + } catch (e) { /* noop */ } + if (this.host && this.host.parentElement) this.host.parentElement.removeChild(this.host); + this.host = null; + this.attached = false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// StructurePanelView — боковая панель с поиском и фильтром. +// Уровень заголовка передаётся ВИЗУАЛЬНО (отступ + жирность + цвет), +// без подписей "H1/H2/...". +// ═══════════════════════════════════════════════════════════════════════════════ +class StructurePanelView extends ItemView { + constructor(leaf, plugin) { + super(leaf); + this.plugin = plugin; + this.maxLevel = plugin.settings.panelMaxLevel || 6; + this.search = ""; + this._readToken = 0; + this.scheduleRender = debounce(() => this.render(), 220, true); + } + + getViewType() { return STRUCTURE_PANEL_VIEW; } + getDisplayText() { return "Структура"; } + getIcon() { return "list-tree"; } + + async onOpen() { + this.render(); + // Подсветка активной ветки при движении курсора + this.registerEvent(this.app.workspace.on("editor-change", () => this.scheduleRender())); + this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRender())); + } + + render() { + const container = this.containerEl.children[1]; + container.empty(); + container.addClass("structure-panel-root"); + + const file = this.plugin.getCurrentMarkdownFile(); + + // ── header ── + const header = container.createDiv({ cls: "structure-panel-header" }); + header.createDiv({ cls: "structure-panel-title", text: "Структура" }); + + const select = header.createEl("select", { cls: "structure-panel-level-select" }); + [ + ["1", "До H1"], + ["2", "До H2"], + ["3", "До H3"], + ["4", "До H4"], + ["5", "До H5"], + ["6", "До H6"], + ["all", "Все"] + ].forEach((p) => { + const o = select.createEl("option"); + o.value = p[0]; o.text = p[1]; + if ( + (p[0] === "all" && this.maxLevel >= 6) || + (p[0] !== "all" && Number(p[0]) === this.maxLevel) + ) o.selected = true; + }); + select.addEventListener("change", async () => { + this.maxLevel = (select.value === "all") ? 6 : Number(select.value); + this.plugin.settings.panelMaxLevel = this.maxLevel; + await this.plugin.saveSettings(); + this.render(); + }); + + const refresh = header.createEl("button", { cls: "structure-panel-icon-btn", text: "↻" }); + refresh.title = "Обновить"; + refresh.addEventListener("click", () => this.render()); + + const closeBtn = header.createEl("button", { cls: "structure-panel-icon-btn", text: "×" }); + closeBtn.title = "Скрыть панель"; + closeBtn.addEventListener("click", () => this.plugin.closeStructurePanel()); + + // ── search ── + const searchRow = container.createDiv({ cls: "structure-panel-searchrow" }); + const searchInput = searchRow.createEl("input", { type: "text", cls: "structure-panel-search" }); + searchInput.placeholder = "Поиск по заголовкам"; + searchInput.value = this.search; + searchInput.addEventListener("input", () => { + this.search = searchInput.value || ""; + this.renderList(); + }); + + if (!file) { + container.createDiv({ cls: "structure-panel-empty", text: "Откройте Markdown-заметку" }); + return; + } + + const body = container.createDiv({ cls: "structure-panel-body" }); + body.createDiv({ cls: "structure-panel-file", text: file.name }); + + this.bodyEl = body; + this.fileForRender = file; + + // Защита от race condition — увеличиваем токен и ждём только свой результат. + const myToken = ++this._readToken; + this.app.vault.cachedRead(file).then((text) => { + if (myToken !== this._readToken) return; + this.allHeadings = parseHeadingsFromLines(String(text || "").split(/\r?\n/)); + this.renderList(); + }); + } + + renderList() { + if (!this.bodyEl) return; + const body = this.bodyEl; + const file = this.fileForRender; + if (!file || !this.allHeadings) return; + + // Очищаем тело, оставляем строку файла + const fileLine = body.querySelector(".structure-panel-file"); + body.empty(); + if (fileLine) body.appendChild(fileLine); + + const all = this.allHeadings; + let filtered = all.filter((h) => h.level <= this.maxLevel); + const q = this.search.trim().toLowerCase(); + if (q) { + filtered = filtered.filter((h) => h.title.toLowerCase().includes(q)); + } + + // Счётчик + const counter = body.createDiv({ cls: "structure-panel-counter" }); + counter.setText(filtered.length + " / " + all.length); + + if (filtered.length === 0) { + body.createDiv({ cls: "structure-panel-empty", text: q ? "Ничего не найдено" : "Нет заголовков" }); + return; + } + + // Текущий заголовок (для подсветки) + let activeLine = -1; + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (view && view.file && view.file.path === file.path && view.editor) { + const cur = this.plugin.getCurrentHeadingSilently(view.editor); + if (cur) activeLine = cur.line; + } + + const list = body.createDiv({ cls: "structure-panel-list" }); + + filtered.forEach((h) => { + const item = list.createDiv({ cls: "structure-panel-item structure-panel-level-" + h.level }); + item.setText(h.title || "(без названия)"); + if (h.line === activeLine) item.addClass("structure-panel-item-active"); + item.title = "Строка " + (h.line + 1); + item.addEventListener("click", () => this.plugin.jumpToHeading(file, h.line)); + item.addEventListener("contextmenu", (e) => { + e.preventDefault(); + this.openItemContextMenu(h, e.clientX, e.clientY); + }); + }); + } + + openItemContextMenu(heading, x, y) { + const m = new Menu(); + m.addItem((it) => it.setTitle("Перейти").setIcon("arrow-right-circle") + .onClick(() => this.plugin.jumpToHeading(this.fileForRender, heading.line))); + m.addSeparator(); + m.addItem((it) => it.setTitle("Свернуть ветку").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.collapseCurrentBranch(); + })); + m.addItem((it) => it.setTitle("Развернуть ветку").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.expandCurrentBranch(); + })); + m.addSeparator(); + m.addItem((it) => it.setTitle("Ветку выше").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.moveCurrentBranch(-1); + })); + m.addItem((it) => it.setTitle("Ветку ниже").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.moveCurrentBranch(1); + })); + m.addItem((it) => it.setTitle("Повысить").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.shiftCurrentBranch(-1); + })); + m.addItem((it) => it.setTitle("Понизить").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.shiftCurrentBranch(1); + })); + m.addSeparator(); + m.addItem((it) => it.setTitle("Экспорт ветки…").onClick(async () => { + await this.plugin.jumpToHeading(this.fileForRender, heading.line); + await sleep(30); + this.plugin.openExportBranchModal(); + })); + m.showAtPosition({ x, y }); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// StructureCommanderModal — общий «пульт» (по ribbon-иконке). +// ═══════════════════════════════════════════════════════════════════════════════ +class StructureCommanderModal extends Modal { + constructor(app, plugin) { super(app); this.plugin = plugin; } + + onOpen() { + const el = this.contentEl; + el.empty(); + el.addClass("structure-commander-modal"); + + el.createEl("h2", { text: "Structure Commander" }); + + this.section(el, "Боковая панель", [ + ["Открыть справа", () => this.plugin.openStructurePanel("right")], + ["Открыть слева", () => this.plugin.openStructurePanel("left")], + ["Открыть снизу", () => this.plugin.openStructurePanel("bottom")] + ]); + + this.section(el, "Документ", [ + ["Свернуть всё", () => this.plugin.foldAll()], + ["Показать всё", () => this.plugin.unfoldAll()] + ]); + + this.levelButtons(el, "Показать документ до уровня", (lv) => this.plugin.showDocumentToLevel(lv)); + + this.section(el, "Текущая ветка", [ + [this.plugin.labelWithHotkey("Свернуть ветку", "collapse-current-branch"), () => this.plugin.collapseCurrentBranch()], + [this.plugin.labelWithHotkey("Развернуть ветку", "expand-current-branch"), () => this.plugin.expandCurrentBranch()], + ["Тумблер сворачивания", () => this.plugin.toggleCurrentBranchFold()], + ["Скопировать ветку", () => this.plugin.copyCurrentBranch()], + ["Экспорт ветки…", () => this.plugin.openExportBranchModal()], + [this.plugin.labelWithHotkey("Ветка выше", "move-current-branch-up"), () => this.plugin.moveCurrentBranch(-1)], + [this.plugin.labelWithHotkey("Ветка ниже", "move-current-branch-down"), () => this.plugin.moveCurrentBranch(1)] + ]); + + const depthWrap = el.createDiv({ cls: "structure-commander-section" }); + depthWrap.createEl("h3", { text: "Показать ветку до глубины" }); + const depthBtns = depthWrap.createDiv({ cls: "structure-commander-buttons" }); + for (let d = 1; d <= 6; d++) { + this.button(depthBtns, String(d), () => this.plugin.showCurrentBranchDepth(d)); + } + + this.section(el, "Уровни", [ + ["Заголовок выше", () => this.plugin.shiftCurrentHeading(-1)], + ["Заголовок ниже", () => this.plugin.shiftCurrentHeading(1)], + [this.plugin.labelWithHotkey("Повысить ветку", "promote-current-branch"), () => this.plugin.shiftCurrentBranch(-1)], + [this.plugin.labelWithHotkey("Понизить ветку", "demote-current-branch"), () => this.plugin.shiftCurrentBranch(1)], + ["Исправить пропуски уровней", () => this.plugin.normalizeHeadingLevels()] + ]); + + this.section(el, "Нумерация", [ + ["Перенумеровать…", () => this.plugin.openRenumberModal("renumber")], + ["Удалить нумерацию…", () => this.plugin.openRenumberModal("remove")], + ["Emoji-цифры…", () => this.plugin.openRenumberModal("emoji")] + ]); + + this.levelButtons(el, "Уровень текущему заголовку", + (lv) => this.plugin.setCurrentHeadingLevel(lv)); + this.levelButtons(el, "Заголовок ветки → уровень (с сохранением иерархии)", + (lv) => this.plugin.setCurrentBranchRootLevel(lv)); + + el.createDiv({ cls: "structure-commander-note", text: + "Ветка определяется по ближайшему заголовку выше курсора. Хоткеи меняются: Настройки → Hotkeys → Structure." }); + } + + section(parent, title, actions) { + const w = parent.createDiv({ cls: "structure-commander-section" }); + w.createEl("h3", { text: title }); + const btns = w.createDiv({ cls: "structure-commander-buttons" }); + actions.forEach((a) => this.button(btns, a[0], a[1])); + } + + levelButtons(parent, title, cb) { + const w = parent.createDiv({ cls: "structure-commander-section" }); + w.createEl("h3", { text: title }); + const btns = w.createDiv({ cls: "structure-commander-buttons structure-commander-levels" }); + for (let lv = 1; lv <= 6; lv++) this.button(btns, "H" + lv, () => cb(lv)); + } + + button(parent, title, cb) { + const b = parent.createEl("button", { text: title }); + b.onclick = async () => { + try { await cb(); } + catch (e) { console.error(e); new Notice("Ошибка Structure Commander"); } + }; + return b; + } + + onClose() { this.contentEl.empty(); } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ExportBranchModal — экспорт ветки в .md. +// ═══════════════════════════════════════════════════════════════════════════════ +class ExportBranchModal extends Modal { + constructor(app, plugin, editor, file, root) { + super(app); + this.plugin = plugin; + this.editor = editor; + this.file = file; + this.root = root; + this.mode = plugin.settings.exportMode || "same-folder"; + this.folder = plugin.settings.exportFolder || "_Exports"; + this.askEachTime = !!plugin.settings.exportAskEachTime; + this.openAfter = !!plugin.settings.exportOpenAfter; + this.promoteToH1 = !!plugin.settings.exportPromoteToH1; + } + + onOpen() { + const el = this.contentEl; + el.empty(); + el.addClass("structure-commander-small-modal"); + + el.createEl("h2", { text: "Экспорт ветки" }); + el.createDiv({ cls: "structure-commander-muted", text: stripHeadingMarks(this.root.text) }); + + // Куда сохранить + const modes = [ + ["same-folder", "Рядом с текущей заметкой"], + ["folder", "В указанную папку"], + ["ask", "Спрашивать каждый раз"] + ]; + const wrap = el.createDiv({ cls: "structure-radio-block" }); + wrap.createDiv({ cls: "structure-radio-title", text: "Куда сохранить" }); + modes.forEach((m) => { + const lab = wrap.createEl("label"); + const inp = lab.createEl("input", { type: "radio" }); + inp.name = "scmd-export-mode"; + inp.value = m[0]; + inp.checked = (this.mode === m[0]); + inp.onchange = () => { this.mode = m[0]; }; + lab.createSpan({ text: " " + m[1] }); + }); + + new Setting(el) + .setName("Папка") + .setDesc("Используется в режиме «В указанную папку».") + .addText((t) => { + t.setPlaceholder("_Exports"); + t.setValue(this.folder); + t.onChange((v) => { this.folder = v || "_Exports"; }); + }); + + // Чекбоксы + new Setting(el).setName("Открыть файл после экспорта") + .addToggle((tg) => tg.setValue(this.openAfter).onChange((v) => { this.openAfter = v; })); + + new Setting(el).setName("Привести верхний заголовок к H1") + .setDesc("При экспорте сдвинуть уровни так, чтобы заголовок ветки стал H1.") + .addToggle((tg) => tg.setValue(this.promoteToH1).onChange((v) => { this.promoteToH1 = v; })); + + const btns = el.createDiv({ cls: "structure-modal-buttons" }); + const ok = btns.createEl("button", { text: "Экспорт" }); + ok.addClass("mod-cta"); + ok.onclick = () => this.submit(); + const cancel = btns.createEl("button", { text: "Отмена" }); + cancel.onclick = () => this.close(); + + this.scope.register([], "Enter", (evt) => { evt.preventDefault(); this.submit(); return false; }); + } + + async submit() { + await this.plugin.exportBranch(this.editor, this.file, this.root, { + mode: this.mode, + folder: this.folder, + openAfter: this.openAfter, + promoteToH1: this.promoteToH1, + askEachTime: this.askEachTime + }); + this.close(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// RenumberModal — перенумерация / удаление / emoji с предпросмотром. +// ═══════════════════════════════════════════════════════════════════════════════ +class RenumberModal extends Modal { + constructor(app, plugin, editor, mode) { + super(app); + this.plugin = plugin; + this.editor = editor; + this.mode = mode || "renumber"; + this.scopeValue = plugin.settings.renumberScope || "document"; + this.targets = plugin.settings.renumberTargets || "headings"; + this.style = (mode === "emoji") ? "emoji" : (plugin.settings.renumberStyle || "numeric"); + } + + onOpen() { + const el = this.contentEl; + el.empty(); + el.addClass("structure-commander-small-modal"); + + const title = (this.mode === "remove") ? "Удалить нумерацию" + : (this.mode === "emoji") ? "Emoji-нумерация" + : "Перенумеровать структуру"; + el.createEl("h2", { text: title }); + + // Область + this.radioGroup(el, "Область", "scmd-rn-scope", [ + ["document", "Весь документ"], + ["branch", "Текущая ветка"], + ["selection", "Выделенный фрагмент"] + ], this.scopeValue, (v) => { this.scopeValue = v; this.refreshPreview(); }); + + // Что менять + this.radioGroup(el, "Что менять", "scmd-rn-targets", [ + ["headings", "Только заголовки"], + ["bullets", "Только списки"], + ["both", "Заголовки и списки"] + ], this.targets, (v) => { this.targets = v; this.refreshPreview(); }); + + if (this.mode !== "remove") { + this.radioGroup(el, "Стиль", "scmd-rn-style", [ + ["numeric", "Цифры: 1. / 1.1."], + ["emoji", "Emoji: 1️⃣ / 1️⃣.1️⃣"] + ], this.style, (v) => { this.style = v; this.refreshPreview(); }); + } + + // Предпросмотр + this.previewBox = el.createDiv({ cls: "structure-commander-preview" }); + this.previewBox.createEl("h3", { text: "Предпросмотр" }); + this.previewList = this.previewBox.createDiv({ cls: "structure-preview-list" }); + this.previewCount = this.previewBox.createDiv({ cls: "structure-commander-muted" }); + + const btns = el.createDiv({ cls: "structure-modal-buttons" }); + const ok = btns.createEl("button", { text: "Применить" }); + ok.addClass("mod-cta"); + ok.onclick = () => this.submit(); + const cancel = btns.createEl("button", { text: "Отмена" }); + cancel.onclick = () => this.close(); + + this.scope.register([], "Enter", (evt) => { evt.preventDefault(); this.submit(); return false; }); + + this.refreshPreview(); + } + + radioGroup(parent, title, name, options, selected, onChange) { + const w = parent.createDiv({ cls: "structure-radio-block" }); + w.createDiv({ cls: "structure-radio-title", text: title }); + options.forEach((o) => { + const lab = w.createEl("label"); + const inp = lab.createEl("input", { type: "radio" }); + inp.name = name; inp.value = o[0]; + inp.checked = (selected === o[0]); + inp.onchange = () => onChange(o[0]); + lab.createSpan({ text: " " + o[1] }); + }); + } + + refreshPreview() { + if (!this.previewList) return; + this.previewList.empty(); + + const action = (this.mode === "remove") ? "remove" : "renumber"; + const style = (this.mode === "emoji") ? "emoji" : this.style; + + const reps = this.plugin.collectRenumberReplacements(this.editor, { + scope: this.scopeValue, targets: this.targets, + style, action + }); + + if (!reps) { + this.previewCount.setText("Нет ветки под курсором."); + return; + } + if (!reps.length) { + this.previewCount.setText("Нечего менять."); + return; + } + + const sample = reps.slice(0, 8); + sample.forEach((r) => { + const row = this.previewList.createDiv({ cls: "structure-preview-row" }); + const before = this.editor.getLine(r.line); + row.createDiv({ cls: "structure-preview-before", text: truncate(before, 80) }); + row.createDiv({ cls: "structure-preview-arrow", text: "→" }); + row.createDiv({ cls: "structure-preview-after", text: truncate(r.text, 80) }); + }); + + this.previewCount.setText("Будет изменено строк: " + reps.length + + (reps.length > sample.length ? " (показаны первые " + sample.length + ")" : "")); + } + + async submit() { + const action = (this.mode === "remove") ? "remove" : "renumber"; + const style = (this.mode === "emoji") ? "emoji" : this.style; + await this.plugin.applyRenumber(this.editor, { + scope: this.scopeValue, targets: this.targets, + style, action + }); + this.close(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Settings tab. +// ═══════════════════════════════════════════════════════════════════════════════ +class StructureCommanderSettingTab extends PluginSettingTab { + constructor(app, plugin) { super(app, plugin); this.plugin = plugin; } + + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "Structure Commander" }); + + // ── Полный список команд с быстрой записью хоткеев ── + this.renderHotkeysSection(containerEl); + + new Setting(containerEl) + .setName("Глубина боковой панели по умолчанию") + .setDesc("До какого уровня показывать заголовки в боковой панели.") + .addDropdown((dd) => { + dd.addOption("1", "До H1"); + dd.addOption("2", "До H2"); + dd.addOption("3", "До H3"); + dd.addOption("4", "До H4"); + dd.addOption("5", "До H5"); + dd.addOption("6", "Все"); + dd.setValue(String(this.plugin.settings.panelMaxLevel || 6)); + dd.onChange(async (v) => { + this.plugin.settings.panelMaxLevel = Number(v); + await this.plugin.saveSettings(); + this.plugin.refreshStructurePanels(); + }); + }); + + new Setting(containerEl) + .setName("Открыть боковую панель") + .addButton((b) => b.setButtonText("Справа").onClick(() => this.plugin.openStructurePanel("right"))) + .addButton((b) => b.setButtonText("Слева").onClick(() => this.plugin.openStructurePanel("left"))) + .addButton((b) => b.setButtonText("Снизу").onClick(() => this.plugin.openStructurePanel("bottom"))); + + new Setting(containerEl) + .setName("Toolbar над редактором") + .setDesc("Показывать узкую панель кнопок над Markdown-редактором.") + .addToggle((tg) => { + tg.setValue(this.plugin.settings.showEditorToolbar !== false) + .onChange(async (v) => { + this.plugin.settings.showEditorToolbar = v; + await this.plugin.saveSettings(); + this.plugin.syncAllToolbars(); + }); + }); + + new Setting(containerEl) + .setName("Папка экспорта") + .setDesc("Используется в режиме «В указанную папку».") + .addText((t) => { + t.setPlaceholder("_Exports"); + t.setValue(this.plugin.settings.exportFolder || "_Exports"); + t.onChange(async (v) => { + this.plugin.settings.exportFolder = v || "_Exports"; + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName("Сбросить настройки") + .setDesc("Вернуть настройки плагина к значениям по умолчанию.") + .addButton((b) => { + b.setButtonText("Сбросить"); + b.setWarning(); + b.onClick(async () => { + this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS); + await this.plugin.saveSettings(); + this.display(); + this.plugin.refreshStructurePanels(); + this.plugin.syncAllToolbars(); + new Notice("Настройки сброшены"); + }); + }); + + const note = containerEl.createDiv({ cls: "structure-commander-muted" }); + note.setText("Плагин видит коллизии хоткеев внутри Obsidian. Глобальные перехваты Windows или других приложений из Obsidian не проверяются."); + } + + /** + * Полный список команд плагина с быстрой записью хоткеев прямо в настройках. + * Группы — для удобства восприятия. Каждая строка: название + текущий хоткей + кнопка «Изменить». + */ + renderHotkeysSection(containerEl) { + const wrap = containerEl.createDiv({ cls: "scmd-hk-section" }); + wrap.createEl("h3", { text: "Хоткеи команд" }); + wrap.createDiv({ + cls: "structure-commander-muted", + text: "Нажмите «Изменить», затем введите комбинацию клавиш — она применится сразу. Кнопка «Открыть полные настройки Hotkeys» откроет штатный редактор Obsidian." + }); + + const groups = [ + ["Перемещение и уровни", [ + ["move-current-branch-up", "Ветка выше"], + ["move-current-branch-down", "Ветка ниже"], + ["promote-current-branch", "Повысить (заголовок/текст/список)"], + ["demote-current-branch", "Понизить (заголовок/текст/список)"] + ]], + ["Сворачивание", [ + ["collapse-current-branch", "Свернуть ветку"], + ["expand-current-branch", "Развернуть ветку"], + ["fold-all-headings", "Свернуть все заголовки"], + ["unfold-all-headings", "Развернуть всё"], + ["toggle-current-branch-fold", "Переключить сворачивание ветки"] + ]], + ["Показ до уровня", [ + ["show-document-to-h1", "Показать документ до H1"], + ["show-document-to-h2", "Показать документ до H2"], + ["show-document-to-h3", "Показать документ до H3"], + ["show-document-to-h4", "Показать документ до H4"], + ["show-document-to-h5", "Показать документ до H5"], + ["show-document-to-h6", "Показать документ до H6"] + ]], + ["Поставить уровень текущему заголовку", [ + ["set-current-heading-h1", "Текущий заголовок → H1"], + ["set-current-heading-h2", "Текущий заголовок → H2"], + ["set-current-heading-h3", "Текущий заголовок → H3"], + ["set-current-heading-h4", "Текущий заголовок → H4"], + ["set-current-heading-h5", "Текущий заголовок → H5"], + ["set-current-heading-h6", "Текущий заголовок → H6"] + ]], + ["Поставить уровень всей ветке (с сохранением иерархии)", [ + ["set-current-branch-root-h1", "Заголовок ветки → H1"], + ["set-current-branch-root-h2", "Заголовок ветки → H2"], + ["set-current-branch-root-h3", "Заголовок ветки → H3"], + ["set-current-branch-root-h4", "Заголовок ветки → H4"], + ["set-current-branch-root-h5", "Заголовок ветки → H5"], + ["set-current-branch-root-h6", "Заголовок ветки → H6"] + ]], + ["Панель структуры и тулбар", [ + ["open-structure-panel-right", "Открыть панель структуры справа"], + ["open-structure-panel-left", "Открыть панель структуры слева"], + ["open-structure-panel-bottom", "Открыть панель структуры снизу"], + ["toggle-structure-panel", "Скрыть/показать боковую панель"], + ["toggle-editor-toolbar", "Скрыть/показать панель инструментов"] + ]], + ["Прочее", [ + ["promote-current-heading", "Повысить только текущий заголовок"], + ["demote-current-heading", "Понизить только текущий заголовок"], + ["copy-current-branch", "Скопировать ветку"], + ["export-current-branch", "Экспорт ветки"], + ["normalize-heading-levels", "Исправить пропуски уровней"], + ["renumber-structure", "Перенумеровать структуру"], + ["remove-structure-numbering", "Удалить нумерацию"], + ["emoji-number-structure", "Emoji-цифры"] + ]] + ]; + + groups.forEach(([groupTitle, items]) => { + wrap.createEl("h4", { text: groupTitle, cls: "scmd-hk-group" }); + const list = wrap.createDiv({ cls: "scmd-hk-list" }); + items.forEach(([cmdId, label]) => this.makeHotkeyRow(list, cmdId, label)); + }); + + new Setting(wrap) + .setName("Полный редактор Hotkeys") + .setDesc("Открыть штатный редактор горячих клавиш Obsidian.") + .addButton((b) => b.setButtonText("Открыть Hotkeys").onClick(() => this.plugin.openHotkeysSettings())); + } + + makeHotkeyRow(parent, cmdId, label) { + const row = parent.createDiv({ cls: "scmd-hk-row" }); + const lbl = row.createDiv({ cls: "scmd-hk-row-label", text: label }); + const cur = row.createDiv({ cls: "scmd-hk-row-current" }); + const refresh = () => { + const hk = this.plugin.getHotkeyLabel(cmdId); + cur.setText(hk || "не назначен"); + cur.classList.toggle("scmd-hk-row-empty", !hk); + }; + refresh(); + + const editBtn = row.createEl("button", { text: "Изменить", cls: "scmd-hk-row-btn" }); + editBtn.addEventListener("click", () => { + const modal = new HotkeyCaptureModal(this.plugin.app, this.plugin, cmdId, label); + const orig = modal.onClose ? modal.onClose.bind(modal) : null; + modal.onClose = () => { + if (orig) orig(); + refresh(); + }; + modal.open(); + }); + + const clearBtn = row.createEl("button", { text: "×", cls: "scmd-hk-row-clear" }); + clearBtn.title = "Удалить хоткей"; + clearBtn.addEventListener("click", () => { + try { + const hm = this.plugin.app.hotkeyManager; + const fullId = "structure-commander:" + cmdId; + if (hm) { + if (typeof hm.setHotkeys === "function") hm.setHotkeys(fullId, []); + else if (typeof hm.removeHotkeys === "function") hm.removeHotkeys(fullId); + if (typeof hm.save === "function") hm.save(); + } + refresh(); + this.plugin.refreshActiveToolbar(); + } catch (e) { console.error(e); } + }); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Helpers (не зависят от плагина). +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Парсер ATX-заголовков. Игнорирует fenced code blocks. + * Принимает массив строк, возвращает [{line, level, text, title}]. + */ +function parseHeadingsFromLines(lines) { + const headings = []; + let inFence = false; let fenceMarker = ""; + + for (let line = 0; line < lines.length; line++) { + const text = lines[line]; + const fence = text.match(/^\s*(```+|~~~+)/); + if (fence) { + const mk = fence[1].charAt(0); + if (!inFence) { inFence = true; fenceMarker = mk; } + else if (mk === fenceMarker) { inFence = false; fenceMarker = ""; } + continue; + } + if (inFence) continue; + + // ATX: 1..6 диезов, минимум один пробел, дальше — содержимое. + // Пустые "## " без текста пропускаем (требование «не ломаться на пустых»). + const m = text.match(/^(#{1,6})\s+(.+?)\s*$/); + if (m) { + headings.push({ line, level: m[1].length, text, title: m[2].trim() }); + } + } + return headings; +} + +function uniqueSortedLevels(headings) { + const set = {}; + headings.forEach((h) => { set[h.level] = true; }); + return Object.keys(set).map(Number).sort((a, b) => a - b); +} + +function sleep(ms) { return new Promise((r) => window.setTimeout(r, ms)); } + +function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)); } + +function truncate(s, n) { + s = String(s || ""); + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} + +function sanitizeFileName(value) { + return String(value || "") + .replace(/[\\/:*?"<>|#^[\]]/g, " ") + .replace(/\s+/g, " ") + .trim() + .substring(0, 120); +} + +function stripHeadingMarks(text) { + return String(text || "").replace(/^#{1,6}\s+/, "").trim(); +} + +/** + * Снимает префикс-нумерацию любого вида: + * - 1. / 1) / 1.2. / 1.2.3. + * - 1️⃣ / 1️⃣2️⃣ / 1️⃣.2️⃣ / 1️⃣2️⃣.3️⃣ + * - ① ② … ⑩ + * - ➊ ➋ … ➓ + * - 1 - / 12 — и т.п. + */ +function stripNumberPrefix(text) { + let value = String(text || "").trim(); + + // emoji-цифры (любая длина), возможно с точечной иерархией: + // 1️⃣2️⃣.3️⃣4️⃣ и т.п. + const emojiSeq = "(?:\\d\\uFE0F?\\u20E3)+"; + const emojiPath = "(?:" + emojiSeq + "(?:\\." + emojiSeq + ")*)"; + value = value.replace(new RegExp("^(?:" + emojiPath + ")[.)]?\\s+"), ""); + + // обычные цифры с иерархией: 1.2.3. / 1) + // Точка/скобка после цифр обязательны — иначе перехватим формат "12 — Тире" + value = value.replace(/^(?:\d+(?:\.\d+)*[.)])\s+/, ""); + + // кружочки и стрелочные числа + value = value.replace(/^[①②③④⑤⑥⑦⑧⑨⑩](?:\.[①②③④⑤⑥⑦⑧⑨⑩])*[.)]?\s+/, ""); + value = value.replace(/^[➊➋➌➍➎➏➐➑➒➓]\s+/, ""); + + // X — / X – + value = value.replace(/^\d+\s*[-–—]\s+/, ""); + + return value.trim(); +} + +const EMOJI_DIGITS = { + 0: "0\uFE0F\u20E3", 1: "1\uFE0F\u20E3", 2: "2\uFE0F\u20E3", + 3: "3\uFE0F\u20E3", 4: "4\uFE0F\u20E3", 5: "5\uFE0F\u20E3", + 6: "6\uFE0F\u20E3", 7: "7\uFE0F\u20E3", 8: "8\uFE0F\u20E3", + 9: "9\uFE0F\u20E3" +}; + +function emojiNumber(n) { + return String(n).split("").map((ch) => EMOJI_DIGITS[ch] || ch).join(""); +} + +function emojiPath(path) { + return path.map((n) => emojiNumber(n)).join("."); +} + +function formatHotkey(hotkey) { + if (!hotkey) return ""; + const mods = hotkey.modifiers || []; + const key = hotkey.key || ""; + const parts = mods.slice(); + parts.push(formatKey(key)); + return parts.join("+"); +} + +function formatKey(key) { + const map = { + ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", + Mod: "Ctrl" + }; + return map[key] || key; +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b923b56 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "structure-commander", + "name": "Structure Commander", + "version": "2.2.1", + "minAppVersion": "1.0.0", + "description": "Управление Markdown-структурой: ветки, уровни, сворачивание, перенумерация, экспорт ветки, боковая панель и панель инструментов над редактором.", + "author": "Structure Commander", + "isDesktopOnly": false +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c0a36ec --- /dev/null +++ b/styles.css @@ -0,0 +1,394 @@ +/* ========================== Modal: общий пульт ========================== */ +.structure-commander-modal { + max-width: 820px; +} +.structure-commander-small-modal { + max-width: 580px; +} + +.structure-commander-section { + margin: 14px 0; + padding: 10px 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-secondary); +} +.structure-commander-section h3 { + margin: 0 0 8px 0; + font-size: 15px; +} + +.structure-commander-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.structure-commander-buttons button { + cursor: pointer; + padding: 4px 10px; +} +.structure-commander-levels button { + min-width: 42px; +} + +.structure-commander-note, +.structure-commander-muted { + color: var(--text-muted); + font-size: 12px; + margin-top: 12px; +} + +.structure-radio-group, +.structure-radio-block { + display: flex; + flex-direction: column; + gap: 6px; + margin: 12px 0; +} +.structure-radio-title { + font-weight: 600; + margin-bottom: 2px; +} +.structure-radio-group label, +.structure-radio-block label { + cursor: pointer; +} + +.structure-modal-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* Renumber preview */ +.structure-commander-preview { + margin: 14px 0 4px 0; + padding: 10px 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-primary-alt, var(--background-secondary)); +} +.structure-commander-preview h3 { + margin: 0 0 8px 0; + font-size: 14px; +} +.structure-preview-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 6px; +} +.structure-preview-row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 8px; + font-family: var(--font-monospace); + font-size: 12px; + align-items: center; +} +.structure-preview-before { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.structure-preview-arrow { color: var(--text-faint); } +.structure-preview-after { color: var(--text-normal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ========================== Боковая панель ========================== */ +.structure-panel-root { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.structure-panel-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); + flex: 0 0 auto; +} +.structure-panel-title { + font-weight: 700; + flex: 1 1 auto; +} +.structure-panel-level-select { + max-width: 100px; +} +.structure-panel-icon-btn { + padding: 2px 8px; +} + +.structure-panel-searchrow { + padding: 6px 8px 4px 8px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); +} +.structure-panel-search { + width: 100%; + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-modifier-form-field); + color: var(--text-normal); + font-size: 13px; +} + +.structure-panel-body { + overflow: auto; + padding: 8px; + flex: 1 1 auto; +} +.structure-panel-file { + color: var(--text-muted); + font-size: 12px; + margin-bottom: 4px; + word-break: break-word; +} +.structure-panel-counter { + font-size: 11px; + color: var(--text-faint); + margin-bottom: 6px; +} + +.structure-panel-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.structure-panel-item { + cursor: pointer; + border-radius: 6px; + padding: 3px 8px; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + border-left: 2px solid transparent; +} +.structure-panel-item:hover { + background: var(--background-modifier-hover); +} + +/* — уровни передаются ВИЗУАЛЬНО, без подписей H1/H2 — */ +.structure-panel-level-1 { + font-weight: 700; + font-size: 14px; + color: var(--text-normal); + margin-left: 0; +} +.structure-panel-level-2 { + font-weight: 600; + color: var(--text-normal); + margin-left: 12px; +} +.structure-panel-level-3 { + font-weight: 500; + color: var(--text-normal); + margin-left: 24px; +} +.structure-panel-level-4 { + font-weight: 400; + font-style: italic; + color: var(--text-muted); + margin-left: 36px; +} +.structure-panel-level-5 { + font-weight: 400; + font-style: italic; + color: var(--text-muted); + margin-left: 48px; + font-size: 12px; +} +.structure-panel-level-6 { + font-weight: 400; + font-style: italic; + color: var(--text-faint); + margin-left: 60px; + font-size: 12px; +} + +/* Подсветка активного заголовка */ +.structure-panel-item-active { + background: var(--background-modifier-active-hover, var(--background-modifier-hover)); + border-left-color: var(--interactive-accent); +} + +.structure-panel-empty { + padding: 12px; + color: var(--text-muted); +} + +/* ========================== Toolbar над редактором ========================== */ +.scmd-toolbar { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + padding: 4px 6px; + height: auto; + min-height: 30px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); + overflow-x: auto; + flex: 0 0 auto; +} +.scmd-toolbar.scmd-tb-empty .scmd-tb-btn { + opacity: 0.45; + pointer-events: none; +} +.scmd-toolbar.scmd-tb-empty .scmd-tb-btn-text { + pointer-events: auto; /* "Показать до" / "Экспорт" пусть остаются доступны */ + opacity: 0.6; +} + +.scmd-tb-btn { + cursor: pointer; + padding: 2px 8px; + height: 24px; + min-width: 26px; + font-size: 13px; + border: 1px solid var(--background-modifier-border); + border-radius: 5px; + background: var(--background-secondary); + color: var(--text-normal); + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} +.scmd-tb-btn:hover { + background: var(--background-modifier-hover); +} +.scmd-tb-btn:active { + background: var(--background-modifier-active-hover); +} +.scmd-tb-btn-text { + font-size: 12px; + padding: 2px 10px; +} +.scmd-tb-disabled { + opacity: 0.55; +} +.scmd-tb-dd { + display: inline-flex; +} + +/* ========================== Settings tab: таблица хоткеев ========================== */ +.structure-settings-hotkeys table { + width: 100%; + border-collapse: collapse; + margin-top: 8px; +} +.structure-settings-hotkeys td { + padding: 4px 6px; + border-bottom: 1px solid var(--background-modifier-border); +} +.structure-settings-hotkeys td:last-child { + font-family: var(--font-monospace); + color: var(--text-muted); + text-align: right; +} + +/* ========================== Toolbar: spacer + кнопка скрытия ========================== */ +.scmd-tb-spacer { + flex: 1 1 auto; +} +.scmd-tb-hide { + font-size: 16px; + line-height: 1; + padding: 2px 8px; + color: var(--text-muted); +} +.scmd-tb-hide:hover { + color: var(--text-error); +} + +/* ========================== HotkeyCaptureModal ========================== */ +.scmd-hk-current { + margin: 8px 0; + color: var(--text-muted); + font-size: 13px; +} +.scmd-hk-capture { + margin: 12px 0; + padding: 14px 16px; + border: 2px dashed var(--background-modifier-border); + border-radius: 8px; + background: var(--background-secondary); + text-align: center; + font-family: var(--font-monospace); + font-size: 14px; + cursor: text; + outline: none; + user-select: none; +} +.scmd-hk-capture:focus { + border-color: var(--interactive-accent); +} +.scmd-hk-capture.scmd-hk-captured { + border-style: solid; + border-color: var(--interactive-accent); + color: var(--text-normal); + background: var(--background-modifier-form-field); +} + +/* ========================== Settings: список хоткеев команд ========================== */ +.scmd-hk-section h3 { + margin-top: 0.5em; +} +.scmd-hk-section h4.scmd-hk-group { + margin: 18px 0 6px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--background-modifier-border); + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.scmd-hk-list { + display: flex; + flex-direction: column; + gap: 2px; +} +.scmd-hk-row { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 8px; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--background-modifier-border-hover); +} +.scmd-hk-row-label { + font-size: 13px; +} +.scmd-hk-row-current { + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-normal); + padding: 2px 8px; + background: var(--background-secondary); + border-radius: 4px; + min-width: 140px; + text-align: center; +} +.scmd-hk-row-current.scmd-hk-row-empty { + color: var(--text-faint); + font-style: italic; +} +.scmd-hk-row-btn { + padding: 2px 10px; + font-size: 12px; +} +.scmd-hk-row-clear { + padding: 2px 8px; + font-size: 14px; + line-height: 1; + color: var(--text-muted); +} +.scmd-hk-row-clear:hover { + color: var(--text-error); +}