Mini Plugin: Update Internal Links When Renaming a Heading

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

Test in v1.6.7 Sandbox Vault:

240813

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 clicking the right-click menu closing the popover automatically are not caused by this plugin. It is a feature of the new version; you can test this in the Sandbox Vault. You can retain those functions along with other plugins or your own preview editing view. As a programming 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 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() {}
};