Always run "Rename this heading" command when a heading is clicked

If there was a plugin which could always trigger the Rename this heading command upon clicking/focusing a header then that would reduce the chance of ever editing a header directly and thereby breaking links in the vault to it.

There is a feature request to improve upon how Obsidian works to make renames of headings update links automatically without this pop-up, but in the interim, this plugin could serve as a basic fix to an over two years old problem.

It could be as simple as: If the cursor changes to a line beginning with 0+ whitepace, followed by 1-6 # characters, run Rename this heading. There’d likely be some undesirable UX side effects, but probably not major ones, ones I’d consider worth the trouble to deal with to help ensure links to headings don’t break.

However, I’m not sure if it’s even possible to trigger this command as there’s a topic named API to access the “Rename heading” functionality?.

Thanks!

8 Likes

In the same opening, and move this file to option would be great.

I’d add “followed by whitespace” but also there may be some other way to target, like using HTML class(es).

2 Likes

I’ve been working on this recently, inspired by another plugin made by someone. I’ll probably turn it into a real plugin soon but idk how to do that. here’s the code if ya’ll know how to use it.

const DEBOUNCE_DELAY = 30;
const DOM_EVENTS = ["click", "keyup"];

class EditorModeManager {
    constructor(app) {
        this.app = app;
        this.activeMode = null;
    }

    create() {
        const markdownEmbed = this.app.embedRegistry.embedByExtension.md({
            app: this.app,
            containerEl: createDiv(),
        });

        markdownEmbed.load();
        markdownEmbed.editable = !0;
        markdownEmbed.showEditor();

        const EMode = Object.getPrototypeOf(
            Object.getPrototypeOf(markdownEmbed.editMode),
        ).constructor;

        markdownEmbed.unload();

        // the let here enables us toGet a self referencing mardown editor
        let eMode;
        return (eMode = new EMode(this.app, createDiv(), {
            app: this.app,
            scroll: 0,
            editMode: null,
            get editor() {
                return eMode.editor;
            },
            get file() {},
            getMode: () => "source",
            showSearch: () => {},
            toggleMode: () => {},
            onMarkdownScroll: () => {},
        }));
    }
}

class HeadingEditor {
    constructor(app) {
        this.app = app;
        this.hEditorManager = new EditorModeManager(app);
        this.hEditor = null;
        this.activeModal = null;
        this.modalEls = null;
    }

    get lineEl() {
        return this.app.workspace.activeEditor?.contentEl?.querySelector(
            ".cm-editor.cm-focused .cm-active.HyperMD-header.cm-line",
        );
    }

    cleanup() {
        if (this.hEditor) {
            this.hEditor.unload();
            this.hEditor = null;
        }
        if (this.activeModal) {
            this.activeModal.remove();
            this.activeModal = null;
        }
    }

    async captureModalElements() {
        await this.app.commands.executeCommandById("editor:rename-heading");
        this.activeModal = document.querySelector(".modal-container");
        this.modalEls = {
            parentEl: this.activeModal,
            renameEl: this.activeModal.querySelector(".rename-textarea"),
            submitBtn: this.activeModal.querySelector("button.mod-cta"),
            cancelBtn: this.activeModal.querySelector("button.mod-cancel"),
        };
        this.modalEls.parentEl.empty();
        return this.modalEls;
    }

    setupModalContainer() {
        const rect = this.lineEl.getBoundingClientRect();

        this.hEditor.containerEl.setCssProps({
            position: "absolute",
            left: `${rect.left}px`,
            top: `${rect.top}px`,
            width: `${rect.width}px`,
            height: `${rect.height}px`,
            background: "var(--background-secondary)",
        });

        this.hEditor.set(this.modalEls.renameEl.value);
        this.hEditor.focus();
        this.hEditor.editor.setCursor(0, this.hEditor.editor.getValue().length);
    }

    isHeader(target) {
        return (
            target === this.lineEl ||
            (target.classList.contains("cm-header") &&
                !target.classList.contains("cm-formatting"))
        );
    }

    handleSubmit(key) {
        const cursor = this.mEditor.getCursor();
        const leadingSpace = this.mEditor.getLine(cursor.line).indexOf(" ");
        const [newHeading, nextLine] = this.hEditor.editor
            .getValue()
            .split("\n")
            .filter((p) => p);

        if (key !== "Enter") {
            this.modalEls.cancelBtn.click();
        } else {
            this.modalEls.renameEl.value = newHeading;
            this.modalEls.submitBtn.click();
            setTimeout(() => {
                if (nextLine) {
                    this.mEditor.replaceRange(`\n${nextLine}`, {
                        line: cursor.line,
                        ch: leadingSpace + newHeading.length + 1,
                    });
                }
            }, 50);
        }

        setTimeout(() => {
            this.mEditor.setCursor({
                line: cursor.line + (key === "ArrowUp" ? -1 : 1),
                ch: 0,
            });
        }, 50);
        this.cleanup();
    }

    async handleHeaderEdit(event) {
        const { view } = event;
        if (!view?.document) return;

        if (
            this.isHeader(event.target) ||
            (event.type === "keyup" && this.lineEl)
        ) {
            if (!this.hEditor) {
                this.hEditor = this.hEditorManager.create();
                this.hEditor.load();
            }
            this.mEditor = this.app.workspace.activeEditor.editor;
            const modalEls = await this.captureModalElements();

            modalEls.parentEl.onclick = (evt) => {
                if (!this.hEditor.containerEl.contains(evt.target)) {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this.handleSubmit("Escape");
                } else {
                    evt.stopPropagation();
                }
            };

            modalEls.parentEl.createDiv({
                cls: "kh-bg",
                attr: { style: "width: 100%; height: 100%;" },
            });

            modalEls.parentEl.append(this.hEditor.containerEl);
            this.hEditor.containerEl.onkeydown = (evt) => {
                if (evt.ctrlKey) this.app.workspace.activeLeaf = null;

                const actionKeys = ["ArrowUp", "ArrowDown", "Enter", "Escape"];
                if (actionKeys.includes(evt.key)) {
                    evt.preventDefault();
                    if (evt.key === "Escape") {
                        this.cleanup();
                    } else {
                        this.handleSubmit(evt.key);
                    }
                }
            };

            this.setupModalContainer();
        }
    }
}

const ob = require("obsidian");
module.exports = class HeadingEditorPlugin extends ob.Plugin {
    onload() {
        const headingEditor = new HeadingEditor(this.app);
        const debouncedHandler = ob.debounce(
            (evt) => headingEditor.handleHeaderEdit(evt),
            DEBOUNCE_DELAY,
            true,
        );

        this.registerEvent(
            this.app.workspace.on("active-leaf-change", (leaf) => {
                const { view } = leaf;
                if (!view?.contentEl) return;

                headingEditor.cleanup();

                DOM_EVENTS.forEach((eventType) => {
                    this.registerDomEvent(
                        view.contentEl.ownerDocument,
                        eventType,
                        debouncedHandler,
                    );
                });
            }),
        );

        this.register(() => headingEditor.cleanup());
    }

    onunload() {}
};

1 Like

I’m not sure how to put this to use, but, assuming it works, this is a plugin would be very welcome! This should be the resource you need for making it into a plugin: GitHub - obsidianmd/obsidian-sample-plugin. Thanks for your contribution.

Yeah of course. If people seem to really want this then I’ll try expediting making it official.

If your brave enough though, all you have to do is create a new folder in your plugins folder, paste my code as a JS file, then paste this into a manifest.json file (this manifest is just taken from the plugin I based mine off of). Naming the things correctly is essential, I’llInclude a screenshot of what my Finder looks like. to see the greyed out folders you’ll need to look up how to make them visible, on Mac I thinks its shift+cmd+. but I just keep it enabled all the time.

{
  "id": "keep-headings",
  "name": "Keep Headings",
  "version": "0.0.1",
  "minAppVersion": "1.4.4",
  "description": "Update internal links when renaming headings.",
  "author": "PlayerMiller109",
  "authorUrl": "https://github.com/PlayerMiller109/obsidian-keep-headings",
  "isDesktopOnly": false
}

1 Like

Thanks for the how-to. It worked. I tried it on a heading and it worked too. A nice inline implementation (rather than using a pop-up for it since we can still use autocompletion etc).

One UX idea immediately came to mind however: In addition to Return triggering a renaming, clicking a heading, making an edit, and clicking off of it (to another line) could also cause the renaming to occur. This would be closer to how we interact with headings normally, but with the added benefit that links to them are automatically being updated across our vault (and that we don’t click away and accidentally lose our edit). Escape works nicely to cancel the renaming, so it can be pressed before clicking to another line if needed. Although, special care would need to be taken when using Undo / Redo (which currently doesn’t work very well).

For example, after renaming a heading, pressing Undo will go back to the heading’s line with the old heading’s name, but Redo cannot be pressed a this stage. Now renaming the heading again will not rename it across the vault because the links to it use the edited heading, not the old heading – they’re out of sync.

The more I think about this whole concept, it seems it would take a lot to get it working just right, but at minimum, it would be great if the script could handle Undo / Redo so that the headings and links to them would remain synced. I understand Obsidian doesn’t do this either, but since your script knows were on a heading line, perhaps it could catch the Undo / Redo on it?

Nonetheless, thanks again. It’s a welcome step in the right direction.