Mini Plugin: Update Internal Links When Renaming Headings

GitHub: obsidian-keep-headings
Original Post: Obsidian Chinese Forum t38592

Test in v1.6.7 Sandbox Vault:

240813

  • Trigger when clicking on a heading or heading line. Auto submit when clicking outside. Press Esc to cancel.
  • Won’t trigger on right click or click with altKey. For management.
  • Data processing fully relies on official “Rename this heading” command. Safe.
  • Avoid interrupting the editing experience. Convenient.

240831

  • When you press Enter in the middle of a heading, the content before the cursor will be submitted as a new heading, and the rest will move to the next line.

  • The background color is not set by default. If you want a background color similar as the GIF shows, you can add the following CSS:

    .kh-bg {
      background: var(--background-modifier-cover);
    }
    

241017

The built-in editing popover of the v1.7.4 Core Plugin Page Preview has not been well integrated with other functions of the software itself. Issues such as the right-click menu closing the popover automatically are not caused by this plugin. You can retain those functions along with other plugins or your own preview editing view. As an example you can refer to Editable Popover and Exportable Markdown Mindmap

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 Thanks. I took a screenshot of the “Features” section from the obsidian-filename-heading-sync plugin’s README.md on 2024-08-16. It appears that the 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.

@teme Did you see the Readme I wrote on the GitHub? That’s for developers, because most of the plugins are written in TS. They might think this is compiled code when they see me putting a JS file there. In fact, this is the source code and can be edited directly. That’s what I meant.

As for installing this plugin, whether you download and install it from the releases or use another method is ok.

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