Mini Plugin: Update Internal Links When Renaming a Heading

GitHub: obsidian-keep-headings
Original Post: Obsidian Chinese Forum t38592 (same author)

Update internal links, such as [[MD#Test]], when renaming a heading Test within the note MD.md.

Triggered when clicking on a heading.

20250828_143117

5 Likes

I haven’t tested this yet, but have been waiting for something like this for the longest time! Thanks so much!

I look forward to seeing this in Community Plugins!

Are you aware of GitHub - dvcrn/obsidian-filename-heading-sync: Obisdian.md plugin to keep the filename and the first header of the file in sync, which provides roughly the same functionality?
I’ve been using it for years; works fine for me.

@FilSalustri Hi, it appears that the obsidian-filename-heading-sync plugin does not mention the feature to automatically update internal links, such as [[MD#Test]] , when renaming a heading Test within the note MD.md. Did it provide similar functionality?

Ah, I missed that part of your plugin - sorry.
As far as I know, filename-heading-sync does NOT handle internal links, so that’s definitely a distinguishing feature of your work.

How do you install this? Do I need something like a javascript plugin to run this?

edit: got it. in case anyone else is wondering - after I saw the manifest on github I realized I could probably add it through BRAT - and it worked.

I made a slightly different version of your plugin for anyone whose interested. It automatically triggers the header renaming when navigated to by cursor or caret. only pressing enter will accept the rename. it definitely has its quirks.

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() {}
};